Skip to content

Money in minor units

Always integers, the currency table, display formatting, reconciliation, and multi-currency.

Updated 2 Sept 20263 min read
On this page

This page is for anyone who displays an amount or reconciles a ledger. One rule with no exceptions: money is always an integer. No decimals, no floats, ever.

The Money shape#

JSON
{ "amount_minor": 250000, "currency": "SYP", "decimals": 0 }
  • amount_minor is an integer in the currency's smallest unit.
  • decimals is the exponent needed for display, from the response itself, never from an ISO table: Dukkan treats Syrian pounds as zero-decimal (SYP = 0), deviating from ISO 4217. Dividing by your own 10^n produces display bugs a hundredfold off.
  • USD has two decimals: 4000 means $40.00.

Currency table#

Currency decimals Example amount_minor Displays as
SYP 0 250000 250,000 ل.س
USD 2 4000 $40.00
EUR 2 1999 €19.99
JOD 3 12500 JOD 12.500
PYG 0 150000 Gs. 150,000

Any currency outside the table is treated as two-decimal. Checkout currencies on storefronts today are SYP, USD and EUR. Read decimals from every amount individually even if you memorize this table.

Display formatting#

TypeScript
import { formatMoney, money } from "@dukkan.one/app-sdk/commerce";

formatMoney(money(250000, "SYP", 0), "ar", { numberingSystem: "arab" }); // Arabic digits
formatMoney(money(250000, "SYP", 0), "en"); // "SYP 250,000"
formatMoney(money(4000, "USD", 2), "en");   // "$40.00"

Use Decimal or integers for arithmetic; the only call that produces a fraction is the final display.

The order identity#

Text
subtotal - discount + shipping + tax = total

tax is reserved and currently zero; keep it in your reconciliation so future VAT markets do not break you. The order also carries paid and refunded as independent amounts from the payments ledger.

line_pricing: net or gross?#

Two line conventions coexist by design; the line_pricing field on every order decides:

  • "gross" (storefront and admin orders): the sum of items[].total equals subtotal; the discount lives on the order.
  • "net" (POS orders): lines are net of the promo, so the sum of lines + discount = subtotal.

Branch on line_pricing, never on source. An accounting app assuming one convention will mis-reconcile half the orders. The sandbox store contains one order of each kind.

Percentages and rounding#

When you compute a percentage of an amount (a commission, a future tax, a split), compute in integers and round once at the end to the nearest minor unit; never round each line and then sum. The parts must add up to the whole: put the rounding remainder on the last line.

Multi-currency#

Orders carry the checkout currency and an exchange-rate snapshot frozen at purchase time:

JSON
{ "exchange_rate": { "scaled": 1450000000000, "scale": 8 } }

Actual rate = scaled / 10^scale (here 14,500 pounds per dollar). Compute with integers or precise decimals, never floats. The snapshot is historical truth: never re-price with today's rates. exchange_rate is null when the order is in the store's base currency.

Webhook payloads#

Order events carry the amount as total_minor (or amount_minor in order.paid and refund.created) with currency and no decimals. When you need the exponent for display, fetch the order with GET /orders/{id}, or use the currency table as a last resort, mindful that it can change.

Refunds#

  • A refund is an independent money record with an optional line breakdown; it never mutates the original order lines — order history is immutable.
  • Refundability is gated on remaining captured funds, not on order status.
  • Returning goods to stock is separate from returning money: the restock option writes an inventory movement with reason return and requires inventory:write.
  • Custom lines (kind: "custom", created through POST /orders with no variant) have nothing to restock: refund them by amount only. See Orders created by apps.
  • payment_status is a projection of the payments ledger: paid, partially_refunded and refunded are computed from transactions, never toggled.
  • A refund is money leaving the merchant, so its Idempotency-Key must come from the refund's own identity in your system (a return id, a ticket id), never from the attempt. A crashed process that re-runs the job then replays the recorded answer instead of refunding twice; the SDK derives such a key from idempotency: { ref: [orderId, returnId] }.
  • Compute the amount from what is still refundable (refundableAmount in the SDK's commerce helpers mirrors the platform's rule) and let the platform reject a stale figure with 409 rather than clamping it yourself.