JavaScript number is IEEE-754. 0.1 + 0.2 is not exactly 0.3, and amount * 100 is not a safe major-to-minor conversion. @paykernel/core requires a JSON-friendly Money object and bigint conversion.
import {
money,
toMinorUnits,
fromMinorUnits,
formatMoney,
normalizeAmountInput,
} from "@paykernel/core";
// Preferred: decimal string + ISO currency
const amount = money("10.50", "SAR");
// => { amount: "10.50", currency: "SAR" } (frozen, JSON-serializable)
JSON.stringify(amount); // {"amount":"10.50","currency":"SAR"}
toMinorUnits(amount); // 1050n (bigint minor units / halalas)
toMinorUnits("10", "JPY"); // 10n
toMinorUnits("1.234", "KWD"); // 1234n
fromMinorUnits(1050n, "SAR"); // { amount: "10.50", currency: "SAR" }
formatMoney(amount); // "10.50 SAR"Payment APIs accept only Money (AmountInput = Money). Passing a number to createPayment / capture / refund / checkout throws MoneyAmountError / InvalidRequestError. money(10.5, "SAR") still constructs Money for tests; do not pass number into payment APIs.
Types
| Type | Role |
|---|---|
DecimalString |
Clean decimal text ("10.50", "100", "-1.250") |
Money |
{ readonly amount: DecimalString; readonly currency: string; readonly exponent?: number } |
MinorAmount |
bigint integer minor units (internal / provider integer APIs) |
AmountInput |
Money — create/capture/refund/checkout input in 1.0 (use money() to build) |
MoneyRoundingMode |
'reject' | 'half_up' | 'half_even' | 'floor' | 'ceil' | 'trunc' |
CurrencyExponentOverrides |
Readonly<Record<string, number>> merchant/provider maps |
Public Money never carries bigint. Minor units stay internal so JSON.stringify works without custom replacers. When you need to store minors, persist minor.toString().
Money.exponent
Optional exponent is the resolved minor-unit scale used to canonicalize amount. Runtime attaches it when the scale differs from bare ISO for that currency (for example merchant OMR as 2 decimals while ISO OMR is 3; Stripe MGA as 0 while ISO MGA is 2):
const omrMerchant = money("20.12", "OMR", { exponentOverrides: { OMR: 2 } });
// => { amount: "20.12", currency: "OMR", exponent: 2 }
toMinorUnits(omrMerchant); // 2012n — re-pins stored exponent (no overrides needed)Invalid Money.exponent values (non-integer, negative, > 18) fail closed (MoneyAmountError kind invalid_exponent) — they are never ignored in favor of ISO.
ISO-default money omits the field so JSON stays {"amount":"10.50","currency":"SAR"}.
Conversion rules
- Parse major-unit decimal strings (or stringify deprecated
numberinputs carefully). - Resolve the minor-unit exponent:
- explicit
options.exponent, else - stored
Money.exponentwhen present (re-pin path), else options.exponentOverridesviagetCurrencyExponent, else- ISO 4217 tables in
getCurrencyExponent(0 / 2 / 3 / 4 for CLF/UYW funds codes). Two-decimal tables include active JMD, XCG (replaces ANG), and XAD.
- explicit
- Scale with bigint only — never
amount * 10 ** nas a float result path. - Default rounding is
reject: excess fractional digits throwInvalidRequestError.
| Input | Currency | Result |
|---|---|---|
money("10.50", "SAR") |
SAR (exp 2) | OK → "10.50" |
money("10.999", "SAR") |
SAR | throws (reject) |
money("10.999", "SAR", { rounding: "half_up" }) |
SAR | "11.00" |
money("10.5", "JPY") |
JPY (exp 0) | throws |
money("1.234", "KWD") |
KWD (exp 3) | OK |
money("1.2345", "KWD") |
KWD | throws |
money("1.2345", "CLF") |
CLF (exp 4, ISO funds) | OK |
money("1.2345", "UYW") |
UYW (exp 4, ISO funds) | OK |
Canonical Money.amount is minor-aligned: padded to the currency exponent (for example "10.5" → "10.50" for SAR; zero-decimal has no fractional part).
Rounding
| Mode | Behavior |
|---|---|
reject (default) |
Throw on excess fractional digits |
half_up |
Round half away from zero on the first discarded digit ≥ 5 |
half_even |
Banker’s rounding (ties to even) |
floor |
Toward −∞ |
ceil |
Toward +∞ |
trunc |
Toward 0 (drop excess digits) |
Sign and zero
| Option | Default | Use |
|---|---|---|
allowNegative |
false |
Marketplace reverse splits (for example Moyasar) |
allowZero |
false |
Providers that allow 0 minor units |
// Marketplace reverse split
money("-5.00", "SAR", { allowNegative: true });
// Explicit zero
money("0", "USD", { allowZero: true });Negative amounts must stay scoped to flows that intentionally support them (splits). Top-level charges remain non-negative by default.
Provider exponent overrides
ISO lookup lives in getCurrencyExponent. Provider deviations stay gateway-local and are passed into money helpers explicitly so differences are never hidden:
| Provider | Example divergence | How to express |
|---|---|---|
| Stripe | ISK / UGX treated as two-decimal specials; MGA zero-decimal vs ISO 2 | Gateway-local tables → pass exponent / overrides into shared helpers |
| PayPal | HUF / JPY / TWD zero-decimal list | Same |
| Paymob | Merchant currencyExponentOverrides (for example { OMR: 2 }) |
MoneyParseOptions.exponentOverrides |
import { toMinorUnits, getCurrencyExponent } from "@paykernel/core";
// ISO default for OMR is 3
getCurrencyExponent("OMR"); // 3
// Merchant / account override
toMinorUnits("20.12", "OMR", { exponentOverrides: { OMR: 2 } }); // 2012n
// Explicit exponent (wins over ISO and overrides map)
toMinorUnits("10", "USD", { exponent: 0 }); // 10nInvalid override values (non-integer, negative, or > 18) throw — they are never silently ignored when the key is present. Same 0–18 bound as money({ exponent }) / stored Money.exponent.
Zod create/capture/refund amount schemas keep optional Money.exponent so a gateway parse cannot strip a merchant scale.
1.0: payment APIs take Money only
import { money } from "@paykernel/core";
await client.createPayment({
amount: money("10.50", "SAR"),
currency: "SAR",
callbackUrl: "https://example.com/cb",
});currency on create params is still string. If amount.currency (normalized) disagrees with params.currency (case-insensitive), InvalidRequestError is thrown.
Gateway code should normalize at the boundary:
import { normalizeAmountInput, toMinorUnits, minorAmountToNumber } from "@paykernel/core";
const m = normalizeAmountInput(params.amount, params.currency);
const minor = toMinorUnits(m); // bigint
// Provider APIs that need a JSON number (safe range only):
const stripeCents = minorAmountToNumber(minor);Result money fields (GatewayPaymentResult.amount, capturedAmount, refundedAmount, fee, WebhookEvent.amount) are Money | undefined. Do not treat them as exact decimal storage; prefer webhooks / minor units for ledgering. moneyToMajorNumber is display-only (float risk — do not use for ledgering).
Values outside Number.MAX_SAFE_INTEGER must use decimal strings / bigint minors. Float noise (0.1 + 0.2) fails default reject precision checks.
Helpers
| Function | Purpose |
|---|---|
money(amount, currency, options?) |
Build canonical Money (accepts string or number for construction; payment APIs require Money) |
isMoney(value) |
Type guard (present exponent must be integer 0–18) |
toMinorUnits(...) |
Major → bigint minor |
fromMinorUnits(minor, currency, options?) |
Minor → Money |
normalizeAmountInput(input, currency, options?) |
Money → Money (currency match required; number throws in 1.0) |
validateMoney(value, options?) |
Re-parse unknown → canonical Money |
formatMoney(m) |
"10.50 SAR" |
minorAmountToNumber(minor) |
Safe bigint → number (throws if unsafe) |
moneyToMajorNumber(m) |
Display-only major number (float risk — not for ledgering) |
MoneyAmountError |
Structured amount failure (kind for remapping) |
getCurrencyExponent(code, overrides?) |
ISO (+ overrides) exponent (override > 18 throws) |
isKnownCurrencyCode(code) |
True when the code is in the SDK ISO tables |
normalizeCurrencyCode(code) |
Trim + uppercase |
Invalid amounts throw MoneyAmountError (extends InvalidRequestError) with a stable kind (excess_precision, zero, negative, unsafe_range, currency_mismatch, invalid_format, invalid_exponent, …). Custom adapters should branch on kind, not English messages. Messages never include secrets.
Failure path
- Excess fractional digits with default
reject→MoneyAmountErrorkindexcess_precision. allowZero: falseand amount0→ kindzero.allowNegative: falseand a negative amount → kindnegative.params.currencydisagrees withamount.currency→InvalidRequestError/currency_mismatch.- Corrupt stored
exponent(non-integer, negative, > 18) → kindinvalid_exponent(never silently fall back to ISO).
What this does not do
- Does not collapse Stripe / PayPal / Paymob special currency tables into ISO-only lookup.
- Does not put
biginton public payment results (results areMoneywith decimal strings). - Does not accept
numberamount inputs on payment APIs in 1.0.
See outcomes, migrate to 1.0, and @paykernel/core.