OrderCalculator
in package
Authoritative recalculation of order totals over the order snapshot.
Input is only the snapshot columns built by OrderSnapshotBuilder, never live price lists.
Called from OrderManager::beforeSave(); stored totals are never trusted. All arithmetic is bcmath over
decimal(10,3) strings (scale 6 intermediates, half-up round to 3), no floats.
Formula (binding; the invoice generator checks parity with the Invoicer InvoiceCalculator, tolerance 0.5 %):
unitAfterDiscount = round3( unit x (1 - itemDiscount/100) x (1 - userDiscount/100) )
itemsTotal(WithVat) = sum of round3(unitAfterDiscount x quantity), base and with-VAT variants.
- Fees (
OrderItemFee) are informative: the set price already contains them, so they are not added. - Shipping and payment are added in both forms;
free_fromis already resolved by the builder. - Vouchers add up: p = min(100, sum of percents), fixed = sum of
voucher.price(base without VAT), maxVat = highestitem.vat(0 without items). discount = min(itemsTotal, round3(itemsTotal x p/100) + fixed) discountWithVat = min(itemsTotalWithVat, round3(itemsTotalWithVat x p/100) + round3(fixed x (1 + maxVat/100))) - A fixed amount has no rate of its own, so its VAT uses
maxVat; the percent part is computed from items only, never from shipping or payment. - IMPORTANT: the discount never exceeds the goods price, otherwise a fixed voucher larger than the cart gives a negative total. Negative voucher values (bad configuration) count as 0.
- The calculator also fills
vat/priceWithVaton voucher snapshot rows (they depend on the final items). - totalPrice = itemsTotal + shipping.price + payment.price - discount totalPriceWithVat = itemsTotalWithVat + shipping.priceWithVat + payment.priceWithVat - discountWithVat Gifts do not enter the totals; a negative result is clipped to 0.
Rounding follows the order currency (currency.orderRoundingWhole):
1: roundedTotal = ceil(total) to whole units, rounding = rounded - total (always >= 0); roundingVat > 0
produces an extra rounding line on the invoice.
0: roundedTotal = half-up to currency.precision, no rounding line (see usesWholeRounding()).
An order without a currency (half-built graph, unit tests) uses whole-unit ceil.
Tags
Table of Contents
Constants
- SCALE : int = 3
- WORK_SCALE : int = 6
Methods
- ancillaryLines() : array<int, array{vat: numeric-string, base: numeric-string, withVat: numeric-string}>
- Returns the rows of one ancillary snapshot (shipping or payment) for the recap and the invoice: with an engine snapshot (`tax_treatment`) split by item rates, for an older order a single row with the stored rate. The sum always matches `price`/`priceWithVat` of the snapshot.
- ancillaryShares() : array<int, array{vat: numeric-string, base: numeric-string, withVat: numeric-string}>
- Splits an ancillary supply (shipping, payment surcharge) like the main supply: the base is spread proportionally to item bases per rate and the rounding remainder goes to the group with the largest base (same algorithm as {@see \App\UI\Api\System\Models\Helpers\TaxAmountCalculator::allocate()}, but at the precision of `OrderCalculator` so the snapshot matches the totals).
- ancillaryTotals() : array{vat: numeric-string, priceWithVat: numeric-string}
- Returns the rate and the price with VAT of an ancillary supply for a 1:1 snapshot (shipping and payment carry one row): `vat` is the rate of the largest group (informative, the split is done by {@see ancillaryShares()}), `priceWithVat` is the sum of the amounts actually charged in all groups.
- applyLineDiscounts() : numeric-string
- Returns the unit price after both discounts. Public input for the VAT engine: the row base for {@see \App\UI\Api\Eshop\Models\Managers\OrderTaxManager}, so budget weights and the choice of the main supply are computed from the same numbers as the totals.
- itemsTotals() : array{base: numeric-string, withVat: numeric-string, maxVat: numeric-string}
- Returns item totals after discounts (see the formula) and the highest VAT rate. Public: for free_from thresholds of shipping/payment {@see \App\UI\Api\Eshop\Models\Services\OrderSnapshotBuilder} takes `cartTotal` from `withVat` (cart value = items with VAT after discounts, without shipping, payment and vouchers).
- recalculate() : void
- Recalculates and writes the totals on the order and fills `vat`/`priceWithVat` on the voucher snapshot rows. Does not flush; call it before saving.
- roundToPrecision() : numeric-string
- Rounds half-up (away from zero) to `$precision` decimals, the result at scale 3; the same algorithm as `PriceTrait::roundToPrecision()` for payments (one rounding of the unrounded sum).
- usesWholeRounding() : bool
- Tells whether the payable amount is rounded up to whole units with a rounding line. The order currency decides (`currency.orderRoundingWhole`); an order without a currency (or with an entity that does not know the flag) keeps the legacy behavior, i.e. yes.
- vatRecap() : array<int, array{vat: numeric-string, base: numeric-string, withVat: numeric-string, vatAmount: numeric-string}>
- Returns the VAT recap per rate (order confirmation e-mail): the sum of `withVat` over rates equals `totalPriceWithVat` and the sum of `base` equals `totalPrice` (before rounding), exactly the sums of {@see recalculate()}.
- voucherDiscounts() : array<int, array{percents: int, base: numeric-string, withVat: numeric-string}>
- Returns the actual discount of each order voucher (in collection order), the base for the cart/checkout summary where every discount code has its own line.
- withoutVat() : numeric-string
- Returns the base from a price with VAT and a rate in % (round3 half-up).
- withVat() : numeric-string
- Returns the price with VAT from a base and a rate in % (round3 half-up).
- addToVatGroup() : void
- applyDiscounts() : numeric-string
- Applies both discounts multiplicatively on the unit price, round3 (see the formula).
- ceilToWhole() : numeric-string
- Rounds up to whole currency units (legacy `ceil()` parity), correct for negative numbers too.
- currencyPrecision() : int
- Returns the order currency `precision` (2 for euro); 2 without a currency, used only for the branch without whole units.
- discountTotals() : array{base: numeric-string, withVat: numeric-string}
- Returns the total voucher discount (see the formula): percents capped at 100 of items plus fixed amounts.
- itemBasesByVat() : array<string|int, numeric-string>
- Returns item bases after discounts grouped by rate (the weights for spreading an ancillary supply).
- minAmount() : numeric-string
- nonNegative() : numeric-string
- norm() : numeric-string
- round3() : numeric-string
- Rounds half-up to 3 decimals (bcmath truncates on its own, hence the explicit rounding).
Constants
SCALE
private
int
SCALE
= 3
WORK_SCALE
private
int
WORK_SCALE
= 6
Methods
ancillaryLines()
Returns the rows of one ancillary snapshot (shipping or payment) for the recap and the invoice: with an engine snapshot (`tax_treatment`) split by item rates, for an older order a single row with the stored rate. The sum always matches `price`/`priceWithVat` of the snapshot.
public
ancillaryLines(TOrderGraph $order, OrderShipper|OrderPaymentMethod $line) : array<int, array{vat: numeric-string, base: numeric-string, withVat: numeric-string}>
Parameters
- $order : TOrderGraph
- $line : OrderShipper|OrderPaymentMethod
Return values
array<int, array{vat: numeric-string, base: numeric-string, withVat: numeric-string}>ancillaryShares()
Splits an ancillary supply (shipping, payment surcharge) like the main supply: the base is spread proportionally to item bases per rate and the rounding remainder goes to the group with the largest base (same algorithm as {@see \App\UI\Api\System\Models\Helpers\TaxAmountCalculator::allocate()}, but at the precision of `OrderCalculator` so the snapshot matches the totals).
public
ancillaryShares(TOrderGraph $order, numeric-string $amount) : array<int, array{vat: numeric-string, base: numeric-string, withVat: numeric-string}>
An order without VAT (withVat = false) or without items has a single group with rate 0.
Parameters
- $order : TOrderGraph
- $amount : numeric-string
-
base of the ancillary supply (after
free_from)
Return values
array<int, array{vat: numeric-string, base: numeric-string, withVat: numeric-string}>ancillaryTotals()
Returns the rate and the price with VAT of an ancillary supply for a 1:1 snapshot (shipping and payment carry one row): `vat` is the rate of the largest group (informative, the split is done by {@see ancillaryShares()}), `priceWithVat` is the sum of the amounts actually charged in all groups.
public
ancillaryTotals(TOrderGraph $order, numeric-string $amount) : array{vat: numeric-string, priceWithVat: numeric-string}
Parameters
- $order : TOrderGraph
- $amount : numeric-string
Return values
array{vat: numeric-string, priceWithVat: numeric-string}applyLineDiscounts()
Returns the unit price after both discounts. Public input for the VAT engine: the row base for {@see \App\UI\Api\Eshop\Models\Managers\OrderTaxManager}, so budget weights and the choice of the main supply are computed from the same numbers as the totals.
public
applyLineDiscounts(numeric-string $unit, numeric-string $itemDiscount, numeric-string $userDiscount) : numeric-string
Parameters
- $unit : numeric-string
- $itemDiscount : numeric-string
- $userDiscount : numeric-string
Return values
numeric-stringitemsTotals()
Returns item totals after discounts (see the formula) and the highest VAT rate. Public: for free_from thresholds of shipping/payment {@see \App\UI\Api\Eshop\Models\Services\OrderSnapshotBuilder} takes `cartTotal` from `withVat` (cart value = items with VAT after discounts, without shipping, payment and vouchers).
public
itemsTotals(TOrderGraph $order) : array{base: numeric-string, withVat: numeric-string, maxVat: numeric-string}
Parameters
- $order : TOrderGraph
Return values
array{base: numeric-string, withVat: numeric-string, maxVat: numeric-string}recalculate()
Recalculates and writes the totals on the order and fills `vat`/`priceWithVat` on the voucher snapshot rows. Does not flush; call it before saving.
public
recalculate(TOrderGraph $order) : void
Parameters
- $order : TOrderGraph
roundToPrecision()
Rounds half-up (away from zero) to `$precision` decimals, the result at scale 3; the same algorithm as `PriceTrait::roundToPrecision()` for payments (one rounding of the unrounded sum).
public
roundToPrecision(string $value, int $precision) : numeric-string
Parameters
- $value : string
- $precision : int
Return values
numeric-stringusesWholeRounding()
Tells whether the payable amount is rounded up to whole units with a rounding line. The order currency decides (`currency.orderRoundingWhole`); an order without a currency (or with an entity that does not know the flag) keeps the legacy behavior, i.e. yes.
public
usesWholeRounding(TOrderGraph $order) : bool
Public: the invoice (OrderInvoiceGenerator, rounding line) and the cart/e-mail summaries (show the line or not) need the same decision.
Parameters
- $order : TOrderGraph
Return values
boolvatRecap()
Returns the VAT recap per rate (order confirmation e-mail): the sum of `withVat` over rates equals `totalPriceWithVat` and the sum of `base` equals `totalPrice` (before rounding), exactly the sums of {@see recalculate()}.
public
vatRecap(TOrderGraph $order) : array<int, array{vat: numeric-string, base: numeric-string, withVat: numeric-string, vatAmount: numeric-string}>
Voucher discount breakdown (a discount has no rate of its own):
- the percent part is split between rates proportionally to the items of that rate (an item discount lowers the base and tax of exactly that rate); the rounding remainder goes to the highest rate;
- the fixed part goes entirely to the highest item rate (the same
maxVatas in the calculation). An order without VAT (withVat=false) has no recap and returns[].
Parameters
- $order : TOrderGraph
Return values
array<int, array{vat: numeric-string, base: numeric-string, withVat: numeric-string, vatAmount: numeric-string}>voucherDiscounts()
Returns the actual discount of each order voucher (in collection order), the base for the cart/checkout summary where every discount code has its own line.
public
voucherDiscounts(TOrderGraph $order) : array<int, array{percents: int, base: numeric-string, withVat: numeric-string}>
OrderVoucher.price/priceWithVat carry only the fixed part of a voucher; a percent voucher has zero
there, so a summary listing them would show "discount -0" although the total included the discount.
The breakdown matches the total of recalculate() to the cent: percents are allocated in order
with a cap of 100 % (a voucher gets nothing that no longer fits) and the last voucher gets the
remainder, so the sum of the rows equals discountBase/discountWithVat even after rounding.
Parameters
- $order : TOrderGraph
Return values
array<int, array{percents: int, base: numeric-string, withVat: numeric-string}>withoutVat()
Returns the base from a price with VAT and a rate in % (round3 half-up).
public
withoutVat(string $withVat, string $vatPercents) : numeric-string
Parameters
- $withVat : string
- $vatPercents : string
Return values
numeric-stringwithVat()
Returns the price with VAT from a base and a rate in % (round3 half-up).
public
withVat(string $base, string $vatPercents) : numeric-string
Parameters
- $base : string
- $vatPercents : string
Return values
numeric-stringaddToVatGroup()
private
addToVatGroup(array<string, array{base: numeric-string, withVat: numeric-string}> &$groups, string $rate, numeric-string $base, numeric-string $withVat) : void
Parameters
- $groups : array<string, array{base: numeric-string, withVat: numeric-string}>
- $rate : string
- $base : numeric-string
- $withVat : numeric-string
applyDiscounts()
Applies both discounts multiplicatively on the unit price, round3 (see the formula).
private
applyDiscounts(numeric-string $unit, numeric-string $itemDiscount, numeric-string $userDiscount) : numeric-string
Parameters
- $unit : numeric-string
- $itemDiscount : numeric-string
- $userDiscount : numeric-string
Return values
numeric-stringceilToWhole()
Rounds up to whole currency units (legacy `ceil()` parity), correct for negative numbers too.
private
ceilToWhole(string $value) : numeric-string
Parameters
- $value : string
Return values
numeric-stringcurrencyPrecision()
Returns the order currency `precision` (2 for euro); 2 without a currency, used only for the branch without whole units.
private
currencyPrecision(TOrderGraph $order) : int
Parameters
- $order : TOrderGraph
Return values
intdiscountTotals()
Returns the total voucher discount (see the formula): percents capped at 100 of items plus fixed amounts.
private
discountTotals(TOrderGraph $order, numeric-string $itemsTotal, numeric-string $itemsTotalWithVat, numeric-string $maxVat) : array{base: numeric-string, withVat: numeric-string}
Parameters
- $order : TOrderGraph
- $itemsTotal : numeric-string
- $itemsTotalWithVat : numeric-string
- $maxVat : numeric-string
Return values
array{base: numeric-string, withVat: numeric-string}itemBasesByVat()
Returns item bases after discounts grouped by rate (the weights for spreading an ancillary supply).
private
itemBasesByVat(TOrderGraph $order) : array<string|int, numeric-string>
Parameters
- $order : TOrderGraph
Return values
array<string|int, numeric-string>minAmount()
private
minAmount(numeric-string $a, numeric-string $b) : numeric-string
Parameters
- $a : numeric-string
- $b : numeric-string
Return values
numeric-stringnonNegative()
private
nonNegative(numeric-string $value) : numeric-string
Parameters
- $value : numeric-string
Return values
numeric-stringnorm()
private
norm(string $value) : numeric-string
Parameters
- $value : string
Return values
numeric-stringround3()
Rounds half-up to 3 decimals (bcmath truncates on its own, hence the explicit rounding).
private
round3(numeric-string $value) : numeric-string
Parameters
- $value : numeric-string