Two row orders can produce the same €35 quote while disagreeing about the fuel charge. In one, fuel is €2.70; in the other, it’s €2.40. A minimum-charge top-up hides the difference in the total. Assert only that total and the test passes either way.
The difference comes from when the fuel row sees a handling charge. The percentage reads the components already built, not a final basket reconstructed after every row has run. Scope also limits which of those components it can use. A shipment percentage doesn’t automatically include parcel charges just because they contribute to the same quote.
This is the composition part of the engine: matched rows become an ordered calculation over a growing component list. The example below uses fictional prices executed through the actual materializer and pricing pipeline with prepared configuration. It doesn’t use customer data or exercise the complete HTTP admission and publication paths.
A contract price wasn’t another fee on top
The opening quote introduced a generic base price and a negotiated replacement. Here the generic base is €30 and the contract base is €24. Adding both would already be wrong before any percentage or rounding entered the calculation.
The materializer asked the contract schedule for components first. That result
carried a separate blocks_generic_grid flag. The generic tariff grid only
ran when the flag was false. This excerpt leaves out the calls’ argument lists
and failure handling, but preserves the branch and component accumulation:
$priceComponents = $this->componentList(
$contractTariffComponents['components'] ?? null,
);
$matchedContractTariffSchedule =
($contractTariffComponents['blocks_generic_grid'] ?? false) === true;
if (!$matchedContractTariffSchedule) {
// Materialize the generic grid and handle failure here.
$priceComponents = [
...$priceComponents,
...$this->componentList($tariffGridComponents['components'] ?? null),
];
}The schedule could deliberately append the generic grid instead of replacing it. Those modes produced different calculations, not two ways to spell the same override. Changing only the example’s mode to append retained €30 as well as €24; the final quote became €66.70 after the other charges. Making the contract version fail to match instead used the generic base and produced €40.30.
Schedule resolution and component matching were separate steps too. The
schedule resolver selected rows using the request contract version and customer
specificity, then the tariff-row path still applied service, criteria and scope
matching. The replacement flag wasn’t simply components !== []: a selected
replacement row could block the generic grid even when its component matcher
produced nothing. Treating an empty result as permission to fall back would
change that behavior.
After the base selection, this example had five further contributions:
| Contribution | Rule |
|---|---|
| Parcel A | €2 per parcel |
| Parcel B | €2 per parcel |
| Handling | €3 per shipment |
| Fuel | 10% of shipment base and handling |
| Minimum | Top up the current total to €35 |
That makes six components including the contract base. The engine kept those components individually; it didn’t discard their identities after finding a total. Later percentages, precedence rules, explanations and leg summaries needed more information than one number could provide.
One row could produce several scoped components
A fixed €2 package row produced two components because the request contained two explicit parcels. The scope resolver expanded the row into references:
[
{"package_id": "A"},
{"package_id": "B"}
]These references came from the prepared package index. They weren’t inferred from a copy multiplier. The parcel input decision made each package separately addressable, and the metric pipeline provided the measurements a scoped matcher could inspect.
For each reference, the row materializer built a scope-specific context, checked criteria and metric bounds, resolved the amount, then constructed the component. Shipment scope instead supplied one null reference. Item references included both package and item IDs; a shared item ID alone wouldn’t identify which package allocation the row meant.
Scope survived in the output. Parcel A’s €2 and parcel B’s €2 had the same
component code but different scope_reference values. They were two charges,
not duplicate records to remove because their codes and amounts matched.
The component factory attached more than the amount. Its calculation basis included the ruleset identity and version, artifact identity and version, source row and sequence, and amount-specific details. For a percentage that also included the group selection and numerical basis. A partial fuel component looked like this; other provenance fields are omitted:
{
"code": "FUEL",
"scope": "shipment",
"scope_reference": null,
"amount": {"amount": "2.70", "currency_code": "EUR"},
"calculation_basis": {
"artifact_type": "pricing_surcharge_table",
"artifact_row_code": "FUEL",
"row_sequence": 2,
"amount_mode": "percentage",
"percentage_basis_group_codes": ["base", "handling"],
"percentage_basis_amount": 27
}
}That explains which facts produced €2.70. Merely storing “fuel, ten percent” would leave the most useful part of the calculation missing: ten percent of what, at which stage, and for which scope?
A percentage saw the list that existed when it ran
Artifact families ran in a fixed order: package breaks, shipment breaks, pallet space, distance bands, surcharges, then adjustments. Within a family, candidate rows were ordered by sequence. This execution order was separate from the order used to display the finished components.
The surcharge and adjustment families received the accumulated component list when resolving a row. Other families received an empty list at that seam. The actual resolver then appended each row’s new components before moving to the next row. Here are those two parts together, with unrelated arguments and failure branches omitted:
$components = $this->pricingProfileRows->materializeComponents(
row: $row,
// Other matching, scope, currency and provenance arguments omitted.
existingPriceComponents: in_array(
$artifactConfig['artifact_key'],
['surcharge_table', 'adjustment_table'],
true,
) ? $priceComponents : [],
);
foreach ($components['components'] ?? [] as $component) {
$priceComponents[] = $component;
}In the example, the base and handling components belonged to groups named
base and handling. The relevant fuel-row fields were:
{
"row_code": "FUEL",
"sequence": 2,
"scope": "shipment",
"kind": "surcharge",
"amount_mode": "percentage",
"amount": "10.000000",
"currency_code": "EUR",
"group_code": "fuel",
"percentage_base_group_codes": ["base", "handling"],
"percentage_excluded_group_codes": []
}The factory carried a row’s group_code onto its component as _group_code,
which the percentage helper used for selection. This row’s subtotal was
€24 + €3 = €27. Ten percent gave €2.70. The package components already existed
too, but they weren’t part of that shipment basis.
Group selection wasn’t the only filter. The percentage-basis helper checked scope and, for leg/package/item scopes, the reference signature. Its relevant scope checks were:
if ($scope === 'shipment' && $componentScope !== 'shipment') {
continue;
}
if (
in_array($scope, ['leg', 'package', 'item'], true)
&& (
$componentScope !== $scope
|| $this->priceComponents->scopeReferenceSignature(
$priceComponent['scope_reference'] ?? null,
) !== $scopeReferenceSignature
)
) {
continue;
}Adding the package group to this shipment fuel row’s included groups still left its basis at €27. Group membership didn’t override scope. Changing fuel to package scope and selecting the package group instead produced €0.20 for A and €0.20 for B, each from its own €2 basis.
Now put shipment fuel before handling. Its list contains the €24 base but not the later €3 charge, so it produces €2.40. When handling runs afterward, the engine doesn’t revisit fuel. The minimum top-up grows from €1.30 to €1.60, leaving the same €35 total. That is why this calculation needs assertions about components and their bases, not only the grand total.
A final-subtotal percentage would be a legitimate different rule. It would need a defined dependency stage, especially if percentages can include groups containing other percentages. Replacing the running-list behavior with a final subtotal is not a harmless refactor of this implementation.
Currency and rounding entered before later rows read the amount
The component factory converted a resolved amount to the request currency before formatting it. The formatted amount string then became the value the next percentage could read. A USD100 contract base with a synthetic USD→EUR rate of 0.9 therefore contributed €90, and fuel saw a €93 basis after the €3 handling charge—not a mixed-currency sum of 103.
Formatting used the currency profile’s scale, with a two-decimal default:
$roundingProfile = $this->payloadSection(
$currencyMetadataVersion->currencyMetadata['rounding_profile'] ?? null,
);
$currencyProfile = $this->payloadSection(
$roundingProfile[$currencyCode] ?? null,
);
$scale = $this->values->intOrNull($currencyProfile['scale'] ?? null) ?? 2;
return sprintf('%0.'.$scale.'f', round($amount, $scale));The underlying calculation used floats. Decimal storage on a tariff row didn’t make this an arbitrary-precision money engine. Nor could formatting be moved to the very end without changing results. Two package components configured at €0.005 each were formatted as €0.01 each and summed to €0.02. Summing the raw values first and formatting once would give €0.01.
Percentage rows had their own minimum and maximum amounts before component formatting. Those bounds weren’t the quote-wide minimum policy. A percentage with a non-positive basis returned no component before its minimum could create a fee. A ten-percent row on the €27 basis capped at €2 produced €2; the later quote minimum still operated on the resulting component list.
Failure handling also depended on the stage. Missing FX for the USD base made
component materialization fail with FX_RATE_UNAVAILABLE. But if a percentage
row’s minimum needed an unavailable currency conversion, that resolver returned
null and its caller skipped the row. In the worked quote, an unavailable GBP
minimum removed fuel while the quote-wide top-up still brought the total to
€35. A successful total therefore wasn’t evidence that every configured fee
had materialized.
These are the source paths’ different outcomes, not a recommendation to hide missing currency data. A translation or cleanup has to distinguish an omitted row from a failed candidate before it can decide whether to preserve or change that behavior.
The minimum was calculated before final precedence
After all artifact families, the materializer summed the current components. For the ordinary minimum-amount path it added only the shortfall:
$subtotal = $this->priceComponents->sum($priceComponents);
$minimumAmount = $this->values->floatOrNull(
$pricingMinimumChargePolicy['minimum_amount'] ?? null,
);
if ($minimumAmount !== null && $subtotal < $minimumAmount) {
return $minimumAmount - $subtotal;
}
return null;That made the initial arithmetic easy to follow: €24 base + €4 in package charges + €3 handling + €2.70 fuel = €33.70, then a €1.30 minimum component. The separate included-quantity/overage branch of the same policy wasn’t used in this example; calling every result from that helper a simple floor would misdescribe it.
Precedence ran later, outside this materializer. A same-group winner rule bucketed components by group, scope and reference. Higher precedence won; ties considered kind order, sequence and scope index. A suppression rule could instead remove another group’s components in the same scope bucket. Neither operation recalculated percentages or the minimum afterward.
Add a €1 preferred-handling adjustment to the example, in the same group as the earlier €3 handling charge but with higher precedence. Adjustments run after surcharges, so fuel has already used the original €27 basis. Before precedence, the subtotal is €34.70 and the minimum top-up is €0.30. Then the winner rule removes the original €3 component:
| Stage | Total EUR |
|---|---|
| All components before minimum | 34.70 |
| Add minimum component | 35.00 |
| Remove superseded handling | 32.00 |
Fuel remains €2.70 and the minimum component remains €0.30. This direct execution establishes the stage order, not an admitted minimum-floor bug or a customer incident. It shows why I wouldn’t infer a final invariant from the name “minimum charge” alone: this stage calculated a top-up against the list it had, before another stage changed that list.
If a product requires a floor after all winner and suppression rules, it needs that ordering or a deliberate recalculation policy. If a percentage should use only winning components, winner selection needs to precede its basis. Moving either stage changes a calculation contract; simply retaining the same set of row definitions doesn’t preserve the quote.
Display order couldn’t reconstruct calculation order
The final component sorter used kind, sequence and scope index, then removed several internal ranking fields. The shipping-option factory summed those already formatted component amounts and formatted the total. It didn’t rebuild the original percentage bases from the displayed order.
An unordered collection of matched rows is too little information to reproduce this price. Scope, the state visible to each row, conversion and rounding boundaries, and the timing of winner selection are part of the calculation. Keep them in the execution model and in its tests, rather than trying to recover them from a plausible-looking total.