---
title: "Money"
description: "Decimal-string Money, bigint minor units, rounding, and 1.0 AmountInput in @paykernel/core."
---

> Documentation Index
> Fetch the complete documentation index at: https://paykernel-docs.abshahin.workers.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Money

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.

```ts
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):

```ts
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)
```

:::caution
Persist `exponent` together with `amount` + `currency` when present. Stripping it causes silent 10×/100× rescale: bare `toMinorUnits` falls back to ISO (for example `"20.12"` OMR without `exponent: 2` → ISO scale 3 → wrong minors after re-parse). Runtime re-pins stored `exponent` when options omit scale; empty `exponentOverrides: {}` does **not** drop it.
:::

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

1. Parse major-unit **decimal strings** (or stringify deprecated `number` inputs carefully).
2. Resolve the minor-unit **exponent**:
   - explicit `options.exponent`, else
   - stored `Money.exponent` when present (re-pin path), else
   - `options.exponentOverrides` via `getCurrencyExponent`, 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**.
3. Scale with **bigint** only — never `amount * 10 ** n` as a float result path.
4. Default **rounding is `reject`**: excess fractional digits throw `InvalidRequestError`.

| 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 |

```ts
// 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` |

```ts
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 }); // 10n
```

Invalid 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

```ts
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:

```ts
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` → `MoneyAmountError` kind `excess_precision`.
- `allowZero: false` and amount `0` → kind `zero`.
- `allowNegative: false` and a negative amount → kind `negative`.
- `params.currency` disagrees with `amount.currency` → `InvalidRequestError` / `currency_mismatch`.
- Corrupt stored `exponent` (non-integer, negative, > 18) → kind `invalid_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 `bigint` on public payment results (results are `Money` with decimal strings).
- Does not accept `number` amount inputs on payment APIs in 1.0.

See [outcomes](/guides/outcomes), [migrate to 1.0](/guides/migrate-to-1-0), and [`@paykernel/core`](/packages/core).

Source: https://paykernel-docs.abshahin.workers.dev/guides/money/index.mdx
