# Money values in MENA apps: minor units, rounding and invoice QA

Money bugs usually look harmless in a pull request. A developer changes a decimal to an integer, adds `Intl.NumberFormat`, or rounds tax on each line so the invoice totals match the screen. The code passes. Weeks later, a BHD payment is off by a factor of ten or a Saudi e-invoice fails validation by one halala.

The problem is that "currency precision" is not one rule. A production system has separate contracts for the currency, display, payment API, calculation, invoice format and reconciliation. They often agree. They do not have to.

This guide uses AED, SAR, QAR, BHD, KWD and OMR to show where those contracts separate and how to test them.

## Start with the ISO exponent, then stop

The ISO 4217 maintenance agency publishes the currency list used by financial systems. Its [current List One XML](https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml), published on 1 January 2026, assigns two minor units to AED, SAR and QAR. BHD, KWD and OMR have three.

| Currency | ISO minor units | Major unit to integer |
|---|---:|---:|
| AED | 2 | AED 10.25 → 1025 |
| SAR | 2 | SAR 10.25 → 1025 |
| QAR | 2 | QAR 10.25 → 1025 |
| BHD | 3 | BHD 10.250 → 10250 |
| KWD | 3 | KWD 10.250 → 10250 |
| OMR | 3 | OMR 10.250 → 10250 |

That exponent is useful metadata. It is not a complete money model. It does not tell you how many decimal places a unit price needs, how an invoice validator rounds tax, what a payment provider expects, or how a cash register handles physical change.

Keep a versioned currency registry in your application, but let each integration override it where its contract differs.

```ts
const ISO_EXPONENT = {
  AED: 2,
  SAR: 2,
  QAR: 2,
  BHD: 3,
  KWD: 3,
  OMR: 3,
} as const;

type Currency = keyof typeof ISO_EXPONENT;

type Money = {
  currency: Currency;
  minor: bigint;
};
```

`bigint` is a safe representation for settled amounts when every value shares one known exponent. It is less convenient for unit prices, tax rates and foreign-exchange calculations, which can need more precision than the final currency amount.

## Formatting is a presentation step

Unicode's [CLDR currency data](https://unicode.org/reports/tr35/tr35-numbers.html#Supplemental_Currency_Data) defines the decimal digits normally used for formatting. It also models cash digits and cash rounding separately. CLDR says its currency digits are based on ISO 4217, but it can deviate where customary practice provides strong evidence.

JavaScript follows the same separation. In [ECMA-402](https://tc39.es/ecma402/#sec-currencydigits), `CurrencyDigits` returns the runtime's currency fraction-digit value and falls back to two when information is unavailable. [`Intl.NumberFormat`](https://tc39.es/ecma402/#sec-intl.numberformat) then uses that value as the default for currency formatting and rounds the output to the selected precision.

```ts
new Intl.NumberFormat("ar-BH", {
  style: "currency",
  currency: "BHD",
}).format(10.25);

new Intl.NumberFormat("en-AE", {
  style: "currency",
  currency: "AED",
}).format(10.25);
```

The returned strings are for people. Do not parse them back into amounts. Locale output can contain Arabic digits, different grouping separators, non-breaking spaces and bidirectional marks. Store the amount and currency separately, then format at the edge.

Pin fraction-digit options when a screen has a specific job. A unit-price editor may show more precision than a checkout total. An audit view may need trailing zeroes that a compact product card can omit. Those are UI decisions, not changes to the ledger value.

## Payment APIs own their encoding rules

Many payment APIs accept an integer amount in minor units. The assumption fails when teams treat "many" as "all" or copy one provider's rules into a shared helper.

[Stripe documents](https://docs.stripe.com/currencies#minor-units) minor-unit amounts and lists currency or operation-specific exceptions. Availability also depends on the account country and payment method. [Adyen says](https://docs.adyen.com/development-resources/currency-codes/) most of its APIs expect minor units, while its Terminal API is an exception. Its table lists AED, SAR and QAR with two decimals, and BHD, KWD and OMR with three. Adyen's example encodes BHD 10 as `10000`.

Create an adapter per provider and operation:

```ts
type AmountContract = {
  encoding: "minor-integer" | "major-decimal";
  exponent: number;
};

function encodeAmount(
  amount: string,
  contract: AmountContract,
): bigint | string {
  if (contract.encoding === "major-decimal") return amount;
  return decimalToScaledInteger(amount, contract.exponent);
}
```

`decimalToScaledInteger` should reject excess precision unless the product has an explicit rounding policy. It should never multiply a JavaScript `number` and hope the binary result lands on the right integer.

Record the adapter version with the payment request. That makes a provider-rule change traceable when reconciliation finds an old transaction encoded under a previous contract.

## Calculation precision is not invoice precision

A blanket `DECIMAL(18,2)` column is attractive in an AED or SAR system. It is also too blunt. Quantities, unit prices, percentages and exchange rates may need more decimal places than the payable total.

Saudi Arabia's official [Electronic Invoice XML Implementation Standard](https://zatca.gov.sa/ar/E-Invoicing/SystemsDevelopers/Documents/20230519_ZATCA_Electronic_Invoice_XML_Implementation_Standard_%20vF.pdf), linked from [ZATCA's developer specifications](https://zatca.gov.sa/en/E-Invoicing/SystemsDevelopers/Pages/E-Invoice-specifications.aspx), makes the distinction explicit. Version 1.2 allows ordinary `Amount` values to have up to two fractional digits, while unit-price amounts have no decimal-place restriction. The standard specifies half-up rounding, rounds document totals to two decimals, and says to round final calculation results instead of intermediate ones. Its examples include `123.4949 → 123.49` and `123.4951 → 123.50`.

That means a line calculation should preserve its working precision:

```text
quantity × unit price
− line discount
+ line charges
= unrounded line result
→ round where the invoice rule requires it
```

Rounding the unit price on input can change every later value. Rounding tax independently on every line can also make the category tax total differ from the document-level calculation required by the format.

Use a decimal arithmetic library or another representation with an explicit scale and rounding mode. Name the operation that rounds. A generic `roundMoney()` helper hides the question that matters: which boundary requires rounding?

## UAE e-invoice rules add field-level constraints

The current [PINT-AE Billing specification](https://docs.peppol.eu/poac/ae/pint-ae/) is version 1.0.4, released on 3 June 2026. Its validation rules are more useful to an implementer than a vague statement that AED has two decimals.

Fatal rule [IBR-091](https://docs.peppol.eu/poac/ae/pint-ae/trn-invoice/rule/ibr-091/) limits the invoice amount due to two decimal places. Rules [IBR-123](https://docs.peppol.eu/poac/ae/pint-ae/trn-invoice/rule/ibr-123/), [IBR-124](https://docs.peppol.eu/poac/ae/pint-ae/trn-invoice/rule/ibr-124/) and [IBR-125](https://docs.peppol.eu/poac/ae/pint-ae/trn-invoice/rule/ibr-125/) apply the same maximum to total excluding tax, total tax and total including tax.

Other fields have different contracts. [IBR-002-AE](https://docs.peppol.eu/poac/ae/pint-ae/trn-invoice/rule/ibr-002-ae/) permits exchange rates with up to six decimal places. [IBR-CO-16](https://docs.peppol.eu/poac/ae/pint-ae/trn-invoice/rule/ibr-co-16/) calculates amount due as total including tax minus prepaid amount plus the payable rounding amount.

A schema that stores every numeric field with scale two cannot represent those requirements cleanly. Model the fields by purpose:

- settled amounts with the currency exponent;
- quantities and unit prices with product-defined working precision;
- tax rates as exact decimals;
- exchange rates with the format's permitted scale;
- serialized invoice totals with the mandated scale and rounding rule.

PINT-AE is a technical conformance specification, not a substitute for UAE tax advice. Pin the version used by your validator and review updates before changing production rules.

## Build tests around boundaries, not helpers

Unit tests for a formatter or decimal class are useful, but most money failures happen between systems. Test the complete path.

Start with fixture currencies on both sides of the Gulf precision split: AED or SAR at two decimals, and BHD or KWD at three. Include values just below and above a rounding boundary, large totals, zero, refunds and partial payments.

For each fixture, assert all six contracts:

1. The ledger stores the original currency and exact value.
2. Locale formatting is correct in Arabic and English without becoming an input to calculations.
3. The provider adapter produces the documented request amount.
4. Calculations preserve working precision until the named rounding boundary.
5. The invoice passes the pinned ZATCA or PINT-AE rules.
6. Reconciliation reproduces the provider settlement and the invoice amount due.

Add property tests for round trips where the contract permits them. `decode(encode(value))` should return the original amount for every accepted provider value. Rejected excess precision should stay rejected, not be silently rounded.

Keep one golden invoice per tax category and currency. Recompute its line totals, tax breakdown, prepayments, rounding adjustment and amount due independently of the code that generated it. Then run the official Schematron or validator used by the target invoice format.

## Put the contracts in the data model

A money field should answer four questions without reading application code: which currency, what exact value, what scale, and which rule produced the rounded result.

Do not save display strings as money. Do not let a payment SDK define the ledger schema. Do not make tax invoice rules a side effect of the frontend formatter.

The most reliable architecture keeps a high-precision calculation layer, explicit rounding operations, currency-aware settled amounts and narrow adapters at every external boundary. That is more code than `Math.round(value * 100)`. It is also much cheaper than explaining why a correct-looking invoice and a successful payment disagree.

![Six money contracts for MENA applications, covering ISO currency exponents, locale display, payment API encoding, calculation precision, Saudi and UAE invoice rules, and reconciliation QA.](https://cdn.hashnode.com/uploads/covers/60ecf4a0fc37a15ec15655e8/055e0a93-7a1f-4cc6-af98-ba46be2da7d9.png)

*Sources: SIX ISO 4217 maintenance agency; Unicode CLDR; ECMA-402; Stripe and Adyen documentation; ZATCA Electronic Invoice XML Implementation Standard v1.2; PINT-AE Billing 1.0.4. Graphic: SultanByte editorial artwork.*

*Cover credit: SultanByte editorial artwork based on the cited standards and technical specifications.*

