SIGNALS Documentation
API Reference

Rate Engine Registry

Contract for registering and resolving calculation strategies and rate modifiers in the rate engine.

Overview

App\Services\RateEngine\RateEngineRegistry is the composition root for the pricing engine's extension points. It stores:

  • CalculationStrategy implementations, keyed by strategy identifier
  • RateModifier implementations, keyed by modifier identifier

Core binds the registry as a singleton in AppServiceProvider and registers the built-in strategies and modifiers during boot.

Public surface

The registry exposes two symmetric registration and lookup APIs:

Area Methods Notes
Strategies registerStrategy(), hasStrategy(), strategy(), strategies() strategy() throws if the identifier is missing
Modifiers registerModifier(), hasModifier(), modifier(), modifiers() modifiers() always returns ascending priority() order
Configuration composeSections(), configRules(), sanitiseStrategyConfig(), sanitiseModifierConfigs() Compose, validate, and sanitise strategy and enabled-modifier configuration through their registered schemas

The accepted strategy contract is App\Contracts\CalculationStrategy. It requires:

  • identifier(): string
  • label(): string
  • allowedBasePeriods(): array
  • supportsMultiplier(): bool
  • supportsFactor(): bool
  • configSchema(): Schema
  • calculate(CalculationContext $context): RateBreakdown

The accepted modifier contract is App\Contracts\RateModifier. It requires:

  • identifier(): string
  • label(): string
  • priority(): int
  • configSchema(): Schema
  • apply(RateBreakdown $breakdown, array $config, CalculationContext $context): RateBreakdown

Both contracts are explicitly documented in code as deterministic, side-effect-free functions. They operate only on plain configuration and a CalculationContext; they must not perform database access or external I/O.

The singleton registry retains the same registered strategy and modifier object instances across metadata, validation, sanitisation, and calculation calls. It does not resolve fresh implementations from the flightcase per invocation.

Current core registration shape

Today the framework registers the rate engine in AppServiceProvider like this:

$registry = new RateEngineRegistry;

$registry->registerStrategy(new PeriodStrategy);
$registry->registerStrategy(new FixedStrategy);
$registry->registerStrategy(new HybridStrategy);

$registry->registerModifier(new MultiplierModifier);
$registry->registerModifier(new FactorModifier);

That is the current shipped shape.

Built-in strategies

The current core strategies are:

Identifier Label Base periods Multiplier Factor
period Period-based Half-Hourly, Hourly, Daily, Weekly, Monthly Yes Yes
fixed Fixed None No Yes
hybrid Hybrid Daily, Weekly, Monthly No No

The prototype-only usage strategy is intentionally not shipped and is not registered here.

Built-in modifiers

Identifier Label Priority Behaviour
multiplier Multiplier 100 Rewrites per-period line items using configured duration tiers
factor Factor 200 Scales the post-multiplier per-unit subtotal using quantity ranges

Lower priorities run first. The core ordering therefore guarantees multiplier-before-factor execution.

Invariants and failure modes

Identifier uniqueness

The registry keeps one active entry per identifier in each of its two associative maps. Registering a second strategy or modifier with an existing identifier replaces the active object; it does not accumulate another pipeline entry or reject the duplicate. Replacing a key preserves its original map position.

Lookup failures

  • strategy($identifier) throws InvalidArgumentException when the strategy is missing
  • modifier($identifier) throws InvalidArgumentException when the modifier is missing

Callers that need a guard should use hasStrategy() or hasModifier() first.

The list-driven low-level methods have a different boundary. composeSections(), configRules(), sanitiseModifierConfigs(), and RateCalculator iterate registered modifiers and test them against the enabled identifier list, so unknown identifiers in the enabled list are ignored. They do not call modifier() for each requested identifier.

Rate-definition writes add validation above that low-level behavior. ValidatesRateConfig rejects each unregistered enabled modifier with Unknown modifier [{id}]. before create or update sanitisation. RateEngineMetaController::schema() likewise restricts modifier query values to registered identifiers. Callers should not use the low-level ignore behavior as input validation.

Ordering

strategies() returns the warehoused map keyed by identifier. modifiers() returns a list sorted by ascending priority(). Equal-priority modifiers retain registration order; registration order otherwise does not override a priority difference.

Configuration schema contract

The registry is also responsible for the configuration contract used by the rate-specification UI and API:

  • composeSections($strategyId, $enabledModifierIds) builds the ordered form sections
  • configRules(...) builds Laravel validation rules under strategy_config.* and modifier_configs.{id}.*
  • sanitiseStrategyConfig(...) keeps only visible strategy fields and casts them
  • sanitiseModifierConfigs(...) keeps only enabled modifiers and sanitises each config against its schema

Current rate-specification writes constrain calculation_strategy to the built-in CalculationStrategyType enum. Registering another strategy makes it available through the low-level registry and metadata list, but does not extend that enum-backed create or update input contract.

period and hybrid definitions require a base period from their allowed sets. fixed accepts no base period, and the write actions clear one when changing a definition to fixed. Enabled modifiers must be registered; multiplier and factor must also be supported by the chosen strategy. Additional registered modifier identifiers are not constrained by those two core compatibility flags.

This is how the current built-ins shape configuration:

Strategy config

period and hybrid share the time-option fields:

  • day_type
  • business_hours_start
  • business_hours_end
  • rental_days_per_week
  • leeway_minutes
  • first_day_cutoff
  • last_day_cutoff

fixed has an empty schema.

hybrid adds:

  • fixed_charge
  • fixed_period_units

Hidden fields are stripped during sanitisation. For example, business-hours fields are removed when day_type is not business.

Modifier config

multiplier stores:

[
    'tiers' => [
        ['multiplier' => '1.0'],
        ['multiplier' => '0.7'],
    ],
]

factor stores:

[
    'ranges' => [
        ['from' => 1, 'to' => 5, 'factor' => '1.0'],
        ['from' => 6, 'to' => null, 'factor' => '0.9'],
    ],
]

Disabled modifier configs are dropped entirely during sanitisation.

Schema validation and sanitisation are separate steps. Validation applies rules only for visible strategy fields and enabled registered modifiers. Sanitisation then retains only schema-declared visible fields that were supplied, casts their values, and removes disabled or unregistered modifier configs before persistence.

Runtime consumers

  • RateEngineMetaController reads strategy and modifier metadata and composes configuration sections for API clients.
  • ValidatesRateConfig supplies structural compatibility checks and schema rules to the rate-specification write actions.
  • CreateRateSpecification and UpdateRateSpecification sanitise configuration immediately before persistence.
  • RateCalculator resolves the selected strategy and applies enabled registered modifiers during pricing calculations.

Calculation pipeline

App\Services\RateEngine\RateCalculator consumes the registry in two stages:

  1. Resolve the selected strategy and calculate the base RateBreakdown
  2. Iterate enabled modifiers in registry priority order and apply each one

That means the shipped pipeline is always:

strategy -> multiplier -> factor

when both modifiers are enabled. If only one modifier is enabled, only that modifier runs.

Worked example

With the current core registration, this call is valid:

$breakdown = app(RateCalculator::class)->calculate(
    context: $context,
    strategy: 'period',
    enabledModifiers: ['multiplier', 'factor'],
    modifierConfigs: [
        'multiplier' => [
            'tiers' => [
                ['multiplier' => '1.0'],
                ['multiplier' => '1.0'],
                ['multiplier' => '0.7'],
                ['multiplier' => '0.5'],
            ],
        ],
        'factor' => [
            'ranges' => [
                ['from' => 1, 'to' => 5, 'factor' => '1.0'],
                ['from' => 6, 'to' => 20, 'factor' => '0.9'],
                ['from' => 21, 'to' => null, 'factor' => '0.8'],
            ],
        ],
    ],
);

In the current tests, that produces:

  • a period base breakdown
  • then a multiplier-adjusted per-unit subtotal
  • then a factor-adjusted subtotal based on quantity

The final result is deterministic because both the strategy and modifier contracts are pure and the modifier ordering is fixed.