Money in minor units
Always integers, the currency table, display formatting, reconciliation, and multi-currency.
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#
{ "amount_minor": 250000, "currency": "SYP", "decimals": 0 }amount_minoris an integer in the currency's smallest unit.decimalsis 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 own10^nproduces display bugs a hundredfold off.USDhas two decimals:4000means$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#
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"export function formatMoney(amountMinor, currency, decimals, locale = "en") {
const value = decimals === 0 ? amountMinor : amountMinor / 10 ** decimals;
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}
formatMoney(250000, "SYP", 0); // "SYP 250,000"
formatMoney(4000, "USD", 2); // "$40.00"from decimal import Decimal
def format_money(amount_minor: int, currency: str, decimals: int) -> str:
value = Decimal(amount_minor).scaleb(-decimals)
return f"{value:,.{decimals}f} {currency}"
format_money(250000, "SYP", 0) # "250,000 SYP"
format_money(4000, "USD", 2) # "40.00 USD"Use Decimal or integers for arithmetic; the only call that produces a fraction is the final display.
The order identity#
subtotal - discount + shipping + tax = totaltax 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 ofitems[].totalequalssubtotal; 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:
{ "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
restockoption writes an inventory movement with reasonreturnand requiresinventory:write. - Custom lines (
kind: "custom", created throughPOST /orderswith no variant) have nothing to restock: refund them by amount only. See Orders created by apps. payment_statusis a projection of the payments ledger:paid,partially_refundedandrefundedare computed from transactions, never toggled.- A refund is money leaving the merchant, so its
Idempotency-Keymust 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 fromidempotency: { ref: [orderId, returnId] }. - Compute the amount from what is still refundable (
refundableAmountin the SDK's commerce helpers mirrors the platform's rule) and let the platform reject a stale figure with409rather than clamping it yourself.