Formula DSL and Charge Modifiers

June 26, 2026 · View on GitHub

Overview

The formula system transforms human-readable DSL strings into ChargeModifier objects that modify billing charges. Prices can have formulas attached to apply discounts, installments, caps, and other modifications.

Pipeline: formula string → FormulaEngine → Hoa\Ruler AST → ChargeModifier

FormulaEngine

FormulaEngine (src/formula/FormulaEngine.php) parses and evaluates formula strings:

  1. Normalize — trims whitespace, joins multi-line formulas with AND
  2. Interpret — parses string into Hoa\Ruler AST Model (cached via PSR SimpleCache)
  3. Assert — evaluates AST against context variables, producing a ChargeModifier
$engine = new FormulaEngine($cache);
$modifier = $engine->build("discount.fixed('25 USD').reason('bulk')");
$engine->validate($formula); // returns null if valid, error message otherwise

Context variables available in formula strings: discount, installment, increase, cap, once.

Attaching Formulas to Prices

AbstractPrice implements ChargeModifier via SettableChargeModifierTrait:

$price->setModifier($modifier);
// During calculation, Calculator calls $price->modifyCharge($charge, $action)
// which delegates to the attached modifier

When no modifier is set, modifyCharge() returns [$charge] unchanged.

ChargeModifier Interface

interface ChargeModifier {
    public function modifyCharge(?ChargeInterface $charge, ActionInterface $action): array;
    public function isSuitable(?ChargeInterface $charge, ActionInterface $action): bool;
}

Key invariant: modifyCharge() returns an array of charges — typically the original charge plus modifier charges (e.g., a discount charge with negative sum).

Modifier Classes

Base class Modifier provides addon management and time-bound checking (since, till, lasts).

FixedDiscount

Fixed absolute or percentage discount.

discount.fixed('25 USD').reason('TEST')
discount.since('08.2018').fixed('20%')
discount.since('08.2018').till('09.2018').fixed('20%')
discount.fixed('20%').since('08.2018').lasts('2 months')

Returns [$originalCharge, $discountCharge] when applicable. Discount charge has negative sum.

GrowingDiscount

Discount that increases over time by a step amount per period.

discount.since('08.2018').grows('1%').every('month').min('10 USD')
discount.since('08.2000').grows('30%').every('year').max('100%')
discount.since('08.2018').grows('20 USD').every('2 months').min('15 USD').max('80 USD')
discount.since('08.2018').till('12.2018').grows('10pp').every('month')
discount.since('08.2018').grows('10 USD').every('month').stopsGrowing('10.2018')

Supports absolute (USD), relative (%), and percentage point (pp) steps. min/max cap the accumulated discount. stopsGrowing() caps the growth calculation at the given month while the discount continues to apply.

Increase

Like GrowingDiscount but with inverted sign (price goes up instead of down).

increase.since('08.2018').till('12.2018').grows('30%').every('month')

Installment

Spreads a charge over a fixed term as monthly payments.

installment.since('08.2018').lasts('3 months').reason('TEST')

Returns a single charge with type leasing (pre-2024) or installment (2024+). The till() method is forbidden — use lasts() instead. Records domain events InstallmentWasCharged / InstallmentWasFinished.

Cap / MonthlyCap

Limits maximum billable usage per month.

cap.monthly('28 days')
cap.monthly('28 days').since('11.2020')
cap.monthly('28 days').since('11.2020').forNonProportionalizedQuantity()

Splits charges at the cap boundary: usage within cap is charged normally, usage above cap produces a zero charge.

Once

Bills only once per interval (e.g., yearly).

once.per('1 year').since('01.2020')

Returns the original charge if the current month matches the interval, otherwise returns a zero charge.

Combining Modifiers

Multiple modifiers are combined using AND (multi-line formulas):

discount.since('08.2018').fixed('30%').reason('ONE')
discount.since('10.2018').fixed('10 USD').reason('TWO')
discount.since('12.2018').fixed('50%').reason('THREE')

Lines are joined with AND and parsed into a FullCombination tree.

FullCombination

Applies both modifiers sequentially — left modifier first, then right modifier on the combined result. Both produce charges that are merged.

LastCombination

First-match-wins: applies right modifier if suitable, otherwise falls back to left.

ChargeDerivative

ChargeDerivative (src/charge/derivative/) implements a copy-with pattern for producing a modified copy of an existing Charge without mutating the original.

$query = (new ChargeDerivativeQuery())
    ->changeSum($newSum)
    ->changeType($newType);

$derivedCharge = ($derivative)($originalCharge, $query);

ChargeDerivativeQuery is a fluent builder — call change*() methods for any fields to override; unchanged fields fall through from the original charge. Used by modifiers (e.g., Installment, Cap) that need to emit a new Charge with adjusted values based on an existing one.

Addon System

Modifiers use a composable addon system for configuration:

AddonPurpose
Since / TillTime bounds for modifier applicability
MonthPeriod / YearPeriod / DayPeriodTime periods for every() and lasts()
DiscountDiscount value (absolute, relative %, or percentage point)
StepGrowth step for GrowingDiscount
StopsGrowingLast month when GrowingDiscount grows
ReasonHuman-readable comment attached to modifier charges
Minimum / MaximumBounds for accumulated discount values

Fluent API via traits: WithSince, WithTill, WithReason, WithTerm, WithChargeType.