Customer → Plan → Price → Sale
↓
Action → Calculator → Charge → Aggregator → Bill
Customer — the billable party. Has a seller (reseller hierarchy).
Plan — a tariff/pricing plan. Contains a collection of Prices.
Prices are immutable after assignment (CannotReassignException).
Price — a billing rule. Applied when isApplicable(action) returns true.
Matching logic: action.target.matches(price.target) AND action.type.matches(price.type).
Sale — a subscription binding Customer → Target → Plan. Has optional closeTime.
Can have null Plan (for one-time sales). Represents "customer X uses resource Y under plan Z".
Action — a metered activity. The only thing that gets charged.
Has: type, target, quantity, customer, time, optional sale, optional parent, fractionOfMonth.
TemporaryAction is a runtime-only subclass used for reseller hierarchy expansion and ActionMux — it must never be persisted.
Charge — result of matching an Action to a Price. Holds: used quantity (usage), calculated money (sum), reference to the Price that created it, optional parent charge.
Bill — aggregation of Charges. Represents an invoice line item. Immutable once created.
Can carry a BillRequisite (structured billing address/recipient details) via getRequisite().
These small value objects model lifecycle state for Actions and Charges:
ActionState (src/action/ActionState.php) — NEW / FINISHED / FAILED.
Factory methods: ActionState::new(), ::finished(), ::failed(), ::fromString(string).
Check methods: isNew(), isFinished().
ChargeState (src/charge/ChargeState.php) — NEW / FINISHED.
Same factory/check pattern as ActionState.
UsageInterval (src/action/UsageInterval.php) — immutable time span representing how much of a calendar month an action covers. Used for pro-rating charges when a sale starts or ends mid-month.
Static constructors:
wholeMonth(DateTimeImmutable $month)— full month intervalwithinMonth(DateTimeImmutable $month, $start, $end)— clamps sale start/end to the given monthwithMonthAndFraction(DateTimeImmutable $month, $start, float $fraction)— builds interval from a pre-calculated fraction
Key methods: seconds(), ratioOfMonth() (0.0–1.0), extend(UsageInterval $other) (union of two intervals).
Target::ANY(null) — matches any targetTarget::NONE(INF) — matches no targetType::ANY(null) — matches any typeType::NONE(INF) — matches no type
Once set, these fields cannot be reassigned (throws CannotReassignException):
- Plan → prices
- Price → plan
- Action → sale
- Sale → id
- Charge → id, parent
Rationale: billing history integrity. To update a tariff, create a new Plan with new ID.
findSales(order)— matches Actions to Sales (direct or via repository)findPlans(order)— resolves Plans from Sales (loads from repository if needed)calculatePlan(plan, action)— iterates all Prices in the PlancalculatePrice(price, action)— callscalculateCharge(), then appliesChargeModifierif price has onecalculateCharge(price, action)— core calculation:- Checks
action.isApplicable(price)(target + type matching) - Checks sale time is not in the future
- Calculates usage via
price.calculateUsage(quantity) - Calculates sum via
price.calculateSum(quantity) - Specializes type/target via Generalizer
- Returns a Charge
- Checks
Generalizer (src/charge/Generalizer.php) maps Charges to Bills. It is the customization point
for downstream projects that need different aggregation behavior.
Key responsibilities:
createBill(charge)— converts a Charge into a Bill (negates sum for accounting)specializeType(priceType, actionType)— resolves which Type to use on the Charge (base: returns price type)specializeTarget(priceTarget, actionTarget)— resolves which Target to use (base: returns price target)
Aggregator groups Charges into Bills using Bill.getUniqueString() as the aggregation key.
Bill unique key composition:
currency + customer.uniqueId + target.uniqueId + type.uniqueId + time (ISO 8601)
Bills with the same key are merged: sums are added, quantities are added (if same unit), charge arrays are concatenated.
For most use cases, interact through Billing rather than Calculator directly.
Collector (src/order/Collector.php) — normalizes any billing input into an OrderInterface:
OrderInterface→ passed through unchangedActionInterface→ wrapped inOrder::fromAction()ActionInterface[]→ wrapped inOrder::fromActions()OrderInterface[]→ merged into a single Order
Billing (src/order/Billing.php) — top-level entry point that wires everything together:
calculate($source)→ runs Collector → Calculator → Aggregator → Merger; returnsBill[]without persistingperform($source)→ same ascalculate()but also persists viaBillRepository; returns savedBill[]calculateCharges($source)→ returns rawCharge[]before aggregation
$billing = new Billing($calculator, $aggregator, $merger, $repository, $collector);
$bills = $billing->calculate($action); // dry run
$bills = $billing->perform($order); // calculate + saveStatement (src/statement/Statement.php) — a snapshot of a customer's billing state for a given period. Holds: customer, time, month, balance, total, payment, amount, bills[], plans[], period.
Period constants: Statement::PERIOD_MONTH, Statement::PERIOD_YEAR.
StatementRepositoryInterface defines the contract for loading statements from storage.
StatementBill is a bill representation enriched with statement-context data.
Money is a value object — never use floats for monetary values.
Uses hiqdev\php\units for quantity handling.