Skip to content

Powered by Grav CMS + Helios Theme

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 *_minor and holds the amount in the currency's minor unit. 1999 is $19.99, and 500 is ¥500, because JPY has no minor unit.
  • Currency is stored once per order, in kahunacart_orders.currency as 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 currency setting. 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_minor is negative for discounts and positive for charges.
  • Transaction amounts are always positive. Direction comes from type, either purchase or refund.

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 currency setting. A variant's price_minor is 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. 1850 is 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 and Money::exponent($currency) to read and print an amount, and Money::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\Money values obtained through Grav\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. The Money helper'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's split() 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 with included = 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:

  1. A line starts at its frozen total_minor, plus any adjustment recorded against that line (item_id set). A per-line sale discount lands here.
  2. The shipping bucket starts at the order-level shipping adjustments.
  3. Order-level discount adjustments 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.
  4. Order-level tax adjustments 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.