Money Conventions
This page describes how KahunaCart stores and calculates money. It applies to every price, adjustment and transaction in the store.
KahunaCart uses vendored brick/money for calculation and BIGINT minor units for storage. Nothing monetary is ever a float.
Storage
- Every monetary column is named
*_minorand holds the amount in the currency's minor unit.1999is $19.99, and500is¥500, because JPY has no minor unit. - Currency is stored once per order, in
kahunacart_orders.currencyas an ISO 4217 code. That order's items, adjustments and transactions are all in that currency. - Catalog prices are in the store's base currency, the
currencysetting. A price in another currency is either an amount typed for it or, on a currency set to convert, worked out from the base price. See Currencies. - Adjustment
amount_minoris negative for discounts and positive for charges. - Transaction amounts are always positive. Direction comes from
type, eitherpurchaseorrefund.
More than one currency
A store can sell in more than one currency. The money rules above don't change; four things to know.
- There is a base currency. It is the
currencysetting. A variant'sprice_minoris in it, and so is every admin default and every product's cost to the store (cogs_minor). The cost is typed once, in the base currency, and a line carries it unchanged on an order in any currency, so it is never read as an amount in the order's currency. - An amount in another currency is its own row. A variant's euro price is a row in
kahunacart_currency_amounts, typed by a person in euro. A typed amount always wins. On a currency set to convert, a missing amount is worked out from the base price with the store's exchange rate, and that never copies minor units across. Without conversion, a missing row means the rule doesn't apply in that currency, except that a variant with a free base price stays free unless an amount was typed. A free base price is never converted. - A minor amount never crosses currencies.
1850is 18.50 in euro and 1,850 in yen, so copying a minor amount from one currency to another is a hundredfold error. Use the order's currency andMoney::exponent($currency)to read and print an amount, andMoney::format($minor, $currency)for people. - Each order has one currency, chosen when the cart is created, and a shopper can't pay an order in another one.
See Currencies for how a store is set up for it.
Calculation
- All arithmetic happens on
Brick\Money\Moneyvalues obtained throughGrav\Plugin\KahunaCart\Money\Money::minor()and stored back with::toMinor(). - Raw integer math on minor units is allowed only for straight sums. Anything involving rates, division or allocation goes through brick/money.
- Default rounding is
HALF_UP, applied at defined points such as after a percentage or tax computation, never mid-chain. TheMoneyhelper's methods encode these points, so do not call brick with other rounding modes. - Order-level amounts spread across line items use
Money::allocate(), which wraps brick'ssplit()and never creates or loses a cent. Order discounts and order-level tax back-allocation both use it. - Tax-inclusive prices back-calculate the included tax with
Money::includedTax(). The result is stored as an adjustment withincluded = 1, so it appears on receipts without changing the total.
Why not hand-rolled integer arithmetic
Integer-cents arithmetic falls over on allocation (splitting $10.00 three ways), currency metadata (JPY has 0 decimals, BHD has 3) and compounding rate rounding. brick/money is MIT-licensed, actively maintained, PHP 8.2+, exact (built on brick/math), and widely deployed.
The one risk is a vendoring collision if another plugin ships a different brick/math version. The fix would be prefix-scoping the KahunaCart vendor namespace at build time.
What one unit of one line is worth
A per-line refund has to know what the customer actually paid for one unit. A line's total_minor is what the catalog charged, but by the time the order completed it may carry a per-line sale, a share of a coupon, and tax computed on the sum. Refunding unit_price_minor can return more than the customer paid. Refunding total_minor / qty can lose a cent when the total does not divide evenly.
Refund\RefundAllocator redistributes the order into buckets, one per line plus one for shipping, so every part of the order total sits in the bucket it belongs to:
- A line starts at its frozen
total_minor, plus any adjustment recorded against that line (item_idset). A per-line sale discount lands here. - The shipping bucket starts at the order-level
shippingadjustments. - Order-level
discountadjustments are spread across the line buckets in proportion to what is in them. Shipping gets none. A coupon spreads itself when it is written, so this step covers discounts from third-party stages and orders placed before coupons distributed. See Coupons. - Order-level
taxadjustments are spread across every bucket, shipping included.
Adjustments marked included are skipped throughout. An included tax is already inside the price the line holds, and counting it again would refund it twice.
Every spread goes through Money::allocateByRatios(). Each bucket takes its proportional floor, and the leftover units go to the buckets with the largest fractional parts, not the first ones in the list. This keeps a two-line order allocating the same way whichever line was entered first.
Each line bucket is then split into qty per-unit parts with Money::allocate(). Refunding the first k units of a line is the sum of the first k parts. A line already partly refunded starts from where the last refund stopped, so two refunds of one unit equal one refund of two units.
The buckets always sum to the order total exactly. That is what lets an order be refunded to zero line by line and reach the refunded payment status.
Two clamps apply. A line whose discounts exceed its price refunds nothing rather than a negative. A shipping refund is capped at what the shipping bucket holds, minus what earlier refunds already returned.
Store credit is signed minor units
A store-credit movement is one signed BIGINT in minor units. Positive credits the account, negative debits it. There is no direction column and no separate table for debits.
The balance is SUM(amount_minor) over the account's rows. No column holds it, so it cannot drift from the rows.
Gift-card values go through Money::allocate(). Three cards bought on one line for 10.00 are minted at 3.34, 3.33 and 3.33, so the cards add back up to the line however a discount fell across it.
A credit note's tax figure is Money::allocateByRatios() over the order's tax, weighted by what the refund covers against what it does not. Several partial refunds against one order credit exactly the order's tax between them, with the odd unit landing on one document.