WritingPayments

An amount without its currency exponent is not an amount

Store every amount as integer minor units with its currency and exponent, convert once at each edge, and send every rounding remainder to a named line.

A customer in Kuwait City pays 12.500 KWD for an order at 09:14. The checkout service holds the price as the JSON number 12.5, the gateway adapter multiplies by 100 because that is what it does for GBP and EUR, and the authorisation request leaves carrying 1250. The gateway reads 1250 in the minor unit of the Kuwaiti dinar, which has three decimal places, and authorises 1.250 KWD. Nobody raises an error, and the warehouse ships goods worth ten times what was charged.

My claim is that an amount is not a number. It is three fields: an integer in minor units, a currency code, and the exponent that integer was written in. Any function that receives fewer than all three should refuse to run, and any operation that cannot return an exact integer must say which line absorbs the remainder. A decimal column does not get you there, because it still lets 12.5 and 12.500 look like the same value while the gateway treats them as different ones.

An amount is three fields

The core type has no decimal anywhere. minor is a bigint, currency is an ISO 4217 code, and exponent is the number of decimal places the minor unit implies: 2 for GBP, 0 for JPY, 3 for KWD. I store the exponent on the row rather than look it up, for two reasons. The currency table changes when a currency is redenominated, and a row written under the old exponent must still mean what it meant. And a few gateways disagree with that table for a handful of currencies, so the exponent the core uses and the one an adapter sends can differ, and the difference has to be visible in the data.

TypeScript
// simplified
type Amount = { minor: bigint; currency: string; exponent: number };

const EXPONENTS: Record<string, number> = {
  GBP: 2, EUR: 2, USD: 2, JPY: 0, KWD: 3,
};

export function parseAmount(text: string, currency: string): Amount {
  const exponent = EXPONENTS[currency];
  if (exponent === undefined) throw new Error(`unknown currency ${currency}`);
  const match = /^(\d+)(?:\.(\d+))?$/.exec(text);
  if (!match) throw new Error(`malformed amount ${text}`);
  const [, whole, frac = ''] = match;
  if (frac.length > exponent) {
    throw new Error(`${currency} allows ${exponent} decimals, got ${frac.length}`);
  }
  const minor = BigInt(whole + frac.padEnd(exponent, '0'));
  return { minor, currency, exponent };
}

export function add(a: Amount, b: Amount): Amount {
  if (a.currency !== b.currency || a.exponent !== b.exponent) {
    throw new Error('cannot add amounts of different currency or exponent');
  }
  return { ...a, minor: a.minor + b.minor };
}

add refuses mixed currencies inside the type, not in a validation layer above it. A 12.500 KWD line and a 1.25 GBP line never meet in arithmetic, because every operation compares currency and exponent first, and a mismatch is an exception rather than a silently wrong sum.

Decimal text lives at two edges only

Decimal text exists in two places: the public API, where a merchant sends "amount": "12.500", and the gateway adapters. Between those edges every amount is an Amount. The API edge parses with the core table and rejects more decimals than the exponent allows, so "12.5000" for KWD fails validation instead of being truncated, and "1000.50" for JPY fails because yen has no fraction. The adapter edge formats with its own table, which for most currencies equals the core table and for a few does not.

I have built routing across 15+ gateways and 200+ currencies behind one API, and the rule I apply is that no function in the core takes a bare number as an amount, and no adapter trusts the exponent it was handed. Each adapter holds its own exponent per currency and compares it with the exponent field on the amount. If they differ, the adapter rescales by the difference and records the rescale on the attempt; if the rescale would lose precision, such as 12.505 KWD to a gateway that takes two decimals, it refuses before any network call.

flowchart TD
  M[Merchant API call] --> P[Parse with core table]
  P --> C[Amount with exponent]:::accent
  C --> L[Ledger and splits]
  C --> A[Gateway adapter]
  A --> X[Compare adapter exponent]
  X --> G[Gateway request]
  X --> R[Refuse on precision loss]
Decimal text is parsed once on the way in and formatted once on the way out

Every remainder has an owner

Integers remove floating-point drift but not division. A 2.9% fee on 25.00 GBP is 72.5 minor units, and a three-way split of 10.00 GBP is 333.33 each. The exact result does not exist in the currency, so the question is who gets the odd penny, and I make that owner a parameter of every operation that divides.

For splits I use largest remainder: divide, floor every share, then hand the leftover minor units one at a time to the shares with the largest fractional parts, ties broken by position in the list. 10.00 GBP three ways gives 333, 333 and 334, and the 334 goes to the first payee because the rule says so. For fees the owner is the gateway's rounding rule, because the gateway will take what its rule says regardless of what your ledger computed. Half-up and half-even disagree exactly at .5, which 72.5 is, so a ledger that rounds half-up expects 73 while a gateway that rounds half-even takes 72, and reconciliation opens an exception on every capture that lands on a half.

Take a 25.00 GBP capture with a gateway fee of 2.9% plus 20 pence, the net split 85/15 between a seller and the platform, and the seller paid out in EUR. Every figure is an example.

OperationExact result in minor unitsRounding ruleWho absorbs the remainder
Gateway fee, 2.9% plus 2092.5The gateway's rule, half-even here, stored per adapterThe fee line: 92 charged, 2408 net
Seller share, 85% of 24082046.8Largest remainder across the two sharesThe seller takes 2047, the platform 361
Payout in EUR at 1.17342401.9498Half-up on the stored rate, rate id on the rowAn FX difference line, never the seller

A rate has four or more decimals, which is more precision than either currency, so the product is never an integer. The converted row carries the rate id it used, and the rounded-away fraction is posted to an FX difference line, so a month of payouts can be explained to the pence. Round once, when a line is posted, never between steps of a chain.

The KWD order, step by step

The checkout service stores the cart total as the JSON number 12.5. The exponent is already gone: 12.5, 12.50 and 12.500 KWD are now the same value. The payment service calls a shared toMinor(12.5) that returns Math.round(12.5 * 100), which is 1250. The helper has no currency parameter. The adapter sends { amount: 1250, currency: "KWD" }. The gateway's table says KWD has exponent 3, so it reads 1.250 KWD and approves.

The webhook comes back with amount 1250 and currency KWD. The payment service compares it with the 1250 it sent and marks the payment authorised for the full order. The ledger renders the row as 12.500 KWD because it, too, divides by 100 on display. The order is fulfilled that afternoon. The customer's statement shows 1.250 KWD. Three days later the settlement file arrives with 1.250 KWD against the gateway reference; reconciliation matches on the reference, compares the amount second, and opens an exception for 11.250 KWD. The parcel was delivered the day before.

flowchart TD
  K[Cart total as a float] --> H[Multiply by 100 blindly]
  H --> N[1250 with no exponent]:::accent
  N --> G[Gateway applies exponent 3]
  G --> Z[Authorised 1250 fils]
  Z --> W[Webhook amount matches]
  W --> F[Order fulfilled in full]
  Z --> S[Settlement shows 1250 fils]
  S --> E[Exception after 3 days]
Where the KWD authorisation lost its exponent and where the loss surfaced

With parseAmount("12.500", "KWD") at the API edge the amount leaves checkout as 12500 minor units at exponent 3. The adapter compares exponent 3 with its own table for KWD, finds them equal, and sends 12500. The webhook's 12500 matches. Had the adapter's table said this gateway takes KWD at exponent 2, the adapter would have rescaled 12500 to 1250 exactly, recorded the rescale on the attempt, and sent 1250 with the correct meaning. Only an amount that does not rescale exactly, such as 12505 at exponent 3 to exponent 2, is refused.

  1. Parse once at the API edge

    Build an Amount from a decimal string and a currency; reject more decimals than the exponent allows.
  2. Carry all three fields

    Store minor, currency and exponent together; every operation compares the last two before touching the first.
  3. Divide with a named owner

    Every split, fee and conversion names the line that absorbs the remainder and rounds once at posting.
  4. Compare at the adapter edge

    Each adapter keeps its own exponent table, rescales when exact and refuses when a digit would be lost.

Where the rule breaks

The rule is weakest where an amount has to become something else. Display is one such place: a customer in Germany sees 12.500 KWD rendered with their locale's separators, and a formatted string must never be parsed back, because the comma that separates thousands in one locale is the decimal point in another. Formatting is a third edge that only flows outward.

Rates are the second. An FX rate, a percentage fee and a tax rate all carry more precision than any currency, and the integer discipline does not choose a rounding rule for them; it only forces you to pick one and name the line that takes the difference.

The third is a currency whose exponent changes. A redenomination means rows written last year carry exponent 2 and rows written today carry exponent 0, and add will refuse to combine them, which is the behaviour you want. The migration is a rescale of the old rows with the ratio recorded, not an edit of the exponent column, and it runs once, under a lock, with the ledger closed.

Build the Amount type and parseAmount first, and change the public API to accept amounts as strings. Then search the codebase for every * 100 and / 100: each one is a place where an amount has lost its exponent, and each is where the next 1.250 KWD order will come from.

Written by Md Nasim Anjum, senior full-stack engineer in Manchester. He builds payment orchestration, KYC and KYB compliance platforms and conversational AI.

Get in touchAll writingRSS

More writing