Tillbaka till bloggen

Currency Rounding Rules for Developers: Decimal Places, Minor Units, and Safe Conversion Math

V
Vlado Grigirov
August 21, 2026
Currency API Exchange Rates Currency Rounding Minor Units ISO 4217 Finexly Developer Guide

A customer in Tokyo buys a $19.99 subscription. Your code multiplies by the USD/JPY rate, gets 2942.82785, writes it to the database, and sends it to your payment processor. The processor rejects it — or worse, accepts it and charges 100 times too much. Yen has no decimal places, and your code never asked.

Currency rounding is one of those problems that looks trivial until it reaches production. It is not a formatting concern; it is a correctness concern. Every currency has its own number of decimal places, floating-point math quietly corrupts money, and the moment you convert between currencies you introduce a rounding decision that has to be made deliberately. This guide covers the rules that actually matter: how many decimals each currency has, why you should store amounts as integers, which rounding mode to pick, and how to round an FX conversion so your ledger still balances at the end of the month.

Minor Units: How Many Decimal Places Does Each Currency Have?

The minor unit of a currency is its smallest transactable subdivision. For the US dollar it is the cent, so USD has two decimal places and $19.99 is 1999 cents. This is the representation payment processors expect, and it is the representation your database should use.

ISO 4217 — the same standard that gives you three-letter codes like USD and JPY — also assigns each currency a minor unit exponent. Most developers assume that exponent is always 2. It is not, and that assumption is the single most expensive bug in this category.

Zero-Decimal Currencies

These currencies have no subunit in circulation, so the amount you send is the whole number of units:

  • JPY — Japanese yen
  • KRW — South Korean won
  • VND — Vietnamese dong
  • CLP — Chilean peso
  • ISK — Icelandic króna
  • XAF / XOF / XPF — CFA and CFP francs
  • UGX — Ugandan shilling
  • PYG — Paraguayan guaraní
  • RWF, GNF, KMF, DJF, VUV — and several other small-denomination currencies

If you treat JPY as a two-decimal currency and multiply by 100 before sending to a processor, you have just charged your customer 100× the intended amount.

Three-Decimal Currencies

Seven currencies subdivide into thousandths rather than hundredths:

  • KWD — Kuwaiti dinar (1000 fils)
  • BHD — Bahraini dinar (1000 fils)
  • OMR — Omani rial (1000 baisa)
  • JOD — Jordanian dinar (1000 fils)
  • TND — Tunisian dinar (1000 millimes)
  • IQD — Iraqi dinar (1000 fils)
  • LYD — Libyan dinar (1000 dirhams)

Here the failure runs the other way: treat KWD as two decimals and you charge one tenth of what you meant to. A KWD 12.500 invoice becomes KWD 1.250.

There are even four-decimal entries in ISO 4217 — the Chilean unidad de fomento (CLF) and the Uruguayan unidad previsional (UYW). These are indexed accounting units rather than cash, but if your system accepts arbitrary ISO codes, it needs to survive them.

When the Standard and Your Processor Disagree

This is the trap that catches teams who did everything else right. Payment providers sometimes deviate from ISO 4217 for operational reasons. Adyen, for example, documents that CLP, CVE, IDR and ISK take a different number of decimals in its API than the standard specifies — ISK is zero-decimal under ISO 4217 but must be submitted with two decimals to Adyen.

The rule: your rounding table is a property of the system you are talking to, not a universal constant. Keep one table per integration, seed it from ISO 4217, and override per provider where their documentation says to. Never hardcode 100.

Never Store Money as a Float

Before any rounding discussion, the foundation. Binary floating point cannot represent most decimal fractions exactly:

0.1 + 0.2              // 0.30000000000000004
1.005 * 100            // 100.49999999999999
19.99 * 147.2150       // 2942.8278499999997

Those trailing digits are not cosmetic. Feed them through Math.round() at the wrong moment and you get an amount that is off by one minor unit, which is enough to fail reconciliation.

Two rules cover almost every case:

  1. Store amounts as integers in minor units. An amount_minor BIGINT column plus a currency CHAR(3) column. { amount_minor: 1999, currency: "USD" } is unambiguous and matches what Stripe, Adyen and most processors already expect.
  2. Do arithmetic in integers or a decimal type. Python's decimal.Decimal, Java's BigDecimal, PostgreSQL's NUMERIC, or a JavaScript money library that wraps integer arithmetic. Reserve floats for the exchange rate itself, and even then only up to the point of multiplication.

If you are designing this layer from scratch, our guide to multi-currency ledger design covers the schema decisions in more depth.

The Conversion Pipeline: Minor Units In, Minor Units Out

Currency conversion has exactly four steps, and rounding belongs in step three — once, at the end.

  1. Convert the source amount from minor units to a decimal value.
  2. Multiply by the full-precision exchange rate.
  3. Round to the target currency's minor unit exponent.
  4. Convert back to integer minor units.

Here it is in JavaScript, with the per-currency table doing the work:

// Minor unit exponents. Seed from ISO 4217, override per payment provider.
const MINOR_UNITS = {
  USD: 2, EUR: 2, GBP: 2, CHF: 2, CAD: 2, AUD: 2, CNY: 2, INR: 2,
  JPY: 0, KRW: 0, VND: 0, CLP: 0, ISK: 0, XAF: 0, XOF: 0, XPF: 0,
  KWD: 3, BHD: 3, OMR: 3, JOD: 3, TND: 3, IQD: 3, LYD: 3,
};

function exponentFor(currency) {
  const e = MINOR_UNITS[currency];
  if (e === undefined) throw new Error(`Unknown minor unit for ${currency}`);
  return e;
}

/**
 * Convert an integer minor-unit amount from one currency to another.
 * Returns an integer in the target currency's minor units.
 */
function convertMinor(amountMinor, from, to, rate) {
  const fromExp = exponentFor(from);
  const toExp = exponentFor(to);

  const decimalAmount = amountMinor / 10 ** fromExp;   // 1999 -> 19.99
  const converted = decimalAmount * rate;              // full precision, no rounding yet
  return Math.round(converted * 10 ** toExp);          // single rounding step
}

convertMinor(1999, "USD", "JPY", 147.2150);   // 2943      (¥2,943)
convertMinor(1999, "USD", "KWD", 0.30590);    // 6115      (KWD 6.115)
convertMinor(1999, "USD", "EUR", 0.9241);     // 1847      (€18.47)

Note what the function does not do: it never rounds the rate, never rounds an intermediate value, and never assumes two decimals. Math.round here is half-up on positives — fine for a checkout, but read the next section before using it for anything regulated.

Choosing a Rounding Mode

"Round to two decimals" is not a specification. There are at least five defensible ways to break a tie, and financial systems care which one you pick.

Mode2.5 →3.5 →−2.5 →Typical use
Half up34−3Consumer pricing, checkout totals
Half even (banker's)24−2Accounting, interest, tax, reporting
Half down23−2Rare; occasionally in legacy finance code
Ceiling (up)34−2Fees you must never under-collect
Floor (down / truncate)23−3Payouts you must never over-pay
Half up is what most people mean by "rounding" and what Math.round() gives you for positive numbers. It is intuitive and appropriate for a price a customer is about to see.

Half even, also called banker's rounding, sends exact halves to the nearest even digit. Over many transactions it cancels out the systematic upward bias that half-up introduces, which is why it is the default in accounting systems, in Python's decimal module, and in IEEE 754 itself. If you are aggregating thousands of converted amounts into a revenue report, half-up will quietly inflate the total; half-even will not.

Ceiling and floor exist for asymmetric risk. A marketplace paying out to sellers may floor every payout so it can never distribute more than it holds; the difference lands in a rounding account.

Python makes the choice explicit, which is the right ergonomic:

from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP

MINOR_UNITS = {"USD": 2, "EUR": 2, "JPY": 0, "KWD": 3}

def convert_minor(amount_minor: int, src: str, dst: str,
                  rate: str, mode=ROUND_HALF_EVEN) -> int:
    """Convert integer minor units to integer minor units, exactly once."""
    src_exp, dst_exp = MINOR_UNITS[src], MINOR_UNITS[dst]

    amount = Decimal(amount_minor) / (Decimal(10) ** src_exp)
    converted = amount * Decimal(rate)          # rate passed as a string, not a float

    quantum = Decimal(1).scaleb(-dst_exp)       # 0.01, 1, or 0.001
    rounded = converted.quantize(quantum, rounding=mode)
    return int(rounded.scaleb(dst_exp))

convert_minor(1999, "USD", "JPY", "147.2150")   # 2943
convert_minor(1999, "USD", "KWD", "0.30590")    # 6115

Passing the rate as a string into Decimal matters. Decimal(0.9241) inherits the float's error; Decimal("0.9241") does not.

Three Rounding Bugs That Cost Real Money

1. Rounding the Rate Before You Multiply

Exchange rates routinely carry four to six significant decimals, and truncating them is not harmless. Take USD/JPY at 147.2150 and a $10,000 transfer:

  • Full rate: 10000 × 147.2150 = ¥1,472,150
  • Rate rounded to two decimals (147.21): 10000 × 147.21 = ¥1,472,100

A ¥50 discrepancy on a single transaction, purely from formatting the rate before using it. Store the rate at the precision your provider returns it, round only the resulting amount, and persist the exact rate you used alongside the transaction for audit. Our guide on where exchange rate APIs get their data explains why that precision is meaningful in the first place.

2. Rounding Twice on a Multi-Leg Conversion

If you route USD → EUR → JPY and round at the EUR step, you have thrown away precision that the second multiplication then amplifies. Converting $12.34 with USD/EUR at 0.9241 and EUR/JPY at 159.3063:

  • Direct: 12.34 × 147.2150 = 1816.63¥1,817
  • Via a rounded EUR leg: 12.34 × 0.9241 = 11.4034 → rounded to €11.40 → 11.40 × 159.3063 = 1816.09¥1,816

One yen, from one unnecessary rounding step. On a payout run of fifty thousand transactions, that is a reconciliation ticket. Wherever a direct pair is available, use it; when you must triangulate, keep the intermediate at full precision. See cross exchange rates explained for the mechanics.

3. Line Items That Don't Add Up to the Total

Round each line of an invoice independently and the parts will not always sum to the rounded whole. The classic case is a split:

$10.00 split three ways
  10.00 / 3 = 3.3333...
  → 3.33 + 3.33 + 3.33 = 9.99   ✗ one cent missing

The fix is allocation, not rounding. Round the total once, then distribute it across the parts, handing the remainder out one minor unit at a time:

/**
 * Split an integer minor-unit total into `n` parts whose sum is exactly the total.
 * Remainder units are distributed to the earliest parts (largest-remainder method).
 */
function allocate(totalMinor, ratios) {
  const sum = ratios.reduce((a, b) => a + b, 0);
  const shares = ratios.map(r => Math.floor((totalMinor * r) / sum));
  let remainder = totalMinor - shares.reduce((a, b) => a + b, 0);

  for (let i = 0; remainder > 0; i = (i + 1) % shares.length, remainder--) {
    shares[i] += 1;
  }
  return shares;
}

allocate(1000, [1, 1, 1]);   // [334, 333, 333]        → sums to exactly 1000
allocate(9247, [3, 2, 1]);   // [4624, 3082, 1541]     → sums to exactly 9247

Apply the same pattern after an FX conversion: convert and round the invoice total, then allocate that total across the lines. The lines will always reconcile, because they were derived from the total rather than computed independently. This matters most in multi-currency invoicing and in SaaS billing with proration, where a one-cent drift shows up on a customer-facing PDF.

Cash Rounding Is a Separate Rule

A currency's minor unit tells you the smallest amount that can be recorded. It does not always tell you the smallest amount that can be paid in cash. Several countries withdrew their smallest coins and round cash payments at the till:

  • Switzerland — cash rounds to the nearest 0.05 CHF
  • Canada — the one-cent coin was withdrawn in 2013; cash rounds to the nearest 5 cents
  • Sweden — cash rounds to the nearest whole krona
  • Netherlands — cash rounds to the nearest 5 cents

Crucially, this applies to the cash tender, not the invoice. A Swiss invoice for CHF 12.32 is still recorded as 12.32; only a cash settlement rounds to 12.30, and the 0.02 difference is booked as a rounding adjustment. If you are building point-of-sale software, model cash rounding as a separate, later step applied to the payment — never bake it into the stored amount, or your electronic and cash transactions will disagree.

Formatting Is the Last Step, Not the Calculation

Once the arithmetic is done, hand display off to a locale-aware formatter. Intl.NumberFormat already knows every currency's decimal count, symbol placement, and separators:

function formatMinor(amountMinor, currency, locale = "en-US") {
  const exp = exponentFor(currency);
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
  }).format(amountMinor / 10 ** exp);
}

formatMinor(1999, "USD");            // "$19.99"
formatMinor(2943, "JPY", "ja-JP");   // "¥2,943"
formatMinor(6115, "KWD");            // "KWD 6.115"
formatMinor(1847, "EUR", "de-DE");   // "18,47 €"

Two practical notes. First, Intl.NumberFormat instances are expensive to construct — cache one per locale-currency pair rather than building one per row. Second, the division by 10 ** exp on the last line is the only place a float should touch a monetary value, and only because the result is immediately turned into a string.

Putting It Together with the Finexly API

Fetch the rate at full precision, convert once, round once, and store the rate you used:

curl "https://api.finexly.com/v1/latest?base=USD&symbols=JPY,KWD,EUR" \
  -H "Authorization: Bearer YOUR_API_KEY"
{
  "success": true,
  "base": "USD",
  "timestamp": 1755244800,
  "rates": {
    "JPY": 147.2150,
    "KWD": 0.30590,
    "EUR": 0.9241
  }
}
async function quote(amountMinor, from, to) {
  const res = await fetch(
    `https://api.finexly.com/v1/latest?base=${from}&symbols=${to}`,
    { headers: { Authorization: `Bearer ${process.env.FINEXLY_API_KEY}` } }
  );
  const data = await res.json();
  const rate = data.rates[to];

  return {
    amount_minor: convertMinor(amountMinor, from, to, rate),
    currency: to,
    rate,                              // persist the exact rate used
    rate_timestamp: data.timestamp,    // and when it was captured
  };
}

await quote(1999, "USD", "JPY");
// { amount_minor: 2943, currency: "JPY", rate: 147.215, rate_timestamp: 1755244800 }

Storing rate and rate_timestamp on the transaction row is what makes a dispute answerable six months later. Full endpoint and parameter details are in the Finexly API documentation, and if you are caching rates between requests, our notes on caching and error handling cover the staleness trade-offs.

A Testing Checklist

Money bugs hide in the cases nobody writes tests for. At minimum, cover:

  1. A zero-decimal target — convert into JPY or KRW and assert the result has no fractional part.
  2. A three-decimal target — convert into KWD or BHD and assert three decimals survive.
  3. Exact halves — assert your chosen rounding mode, in both directions, including negatives.
  4. Round-trip drift — convert USD → EUR → USD and assert the result is within one minor unit, not equal.
  5. Allocation invariance — assert that split parts always sum to exactly the total, for 1 to 100 parts.
  6. Unknown currency codes — assert the code throws rather than silently defaulting to two decimals.
  7. Very large amounts — assert no precision loss beyond Number.MAX_SAFE_INTEGER in JavaScript; use BigInt if you handle IDR or VND at scale.

Frequently Asked Questions

How many decimal places does each currency have?

Most currencies have two. Roughly two dozen have none — including JPY, KRW, VND, CLP and ISK — and seven have three: KWD, BHD, OMR, JOD, TND, IQD and LYD. ISO 4217 is the authoritative source, but check your payment provider's table too, since some deviate for operational reasons.

Should I round exchange rates or converted amounts?

Converted amounts only. Keep the rate at the full precision your provider returns, multiply, then round the result once to the target currency's minor unit. Rounding a rate before multiplying introduces an error proportional to the transaction size.

What is the difference between half-up and banker's rounding?

Half-up always sends an exact half away from zero (2.5 → 3). Banker's rounding — round half to even — sends it to the nearest even digit (2.5 → 2, 3.5 → 4), which removes the systematic upward bias when you aggregate many amounts. Use half-up for prices shown to customers, half-even for accounting and reporting.

Why don't my converted line items add up to the converted total?

Because each line was rounded independently, and the errors accumulate. Round the total once, then allocate that total across the lines using a largest-remainder split. The parts will then sum to the whole by construction.

Can I just store money as a float with two decimals?

No. Binary floating point cannot represent values like 0.1 exactly, so errors accumulate across additions and multiplications and eventually flip a rounding decision. Store integers in minor units, or use an exact decimal type. This is not a theoretical concern — it is the most common root cause of off-by-one-cent reconciliation failures.

Get Rates at Full Precision

Correct rounding starts with a rate you can trust and precision you did not throw away. Get your free Finexly API key — no credit card required. Start with 1,000 free requests per month across 170+ currencies, try the currency converter to sanity-check your math, and review the pricing plans when your volume grows.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →

Dela den här artikeln