Terug naar Blog

Regels voor valuta-afronding voor ontwikkelaars: decimalen, subeenheden en veilig omrekenen

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

Een klant in Tokio koopt een abonnement van $19,99. Je code vermenigvuldigt met de USD/JPY-koers, krijgt 2942,82785, schrijft dat naar de database en stuurt het naar je betaalprovider. De provider weigert het — of erger, accepteert het en incasseert honderd keer te veel. De yen heeft geen decimalen, en je code heeft er nooit naar gevraagd.

Valuta-afronding is zo'n probleem dat triviaal lijkt tot het productie bereikt. Het is geen opmaakkwestie, het is een correctheidskwestie. Elke valuta heeft haar eigen aantal decimalen, drijvendekomma-rekenwerk verminkt bedragen in stilte, en op het moment dat je tussen valuta's omrekent introduceer je een afrondingsbeslissing die je bewust moet nemen. Deze gids behandelt de regels die er echt toe doen: hoeveel decimalen elke valuta heeft, waarom je bedragen als gehele getallen opslaat, welke afrondingsmodus je kiest, en hoe je een FX-omrekening afrondt zodat je grootboek aan het eind van de maand nog klopt.

Subeenheden: hoeveel decimalen heeft elke valuta?

De subeenheid van een valuta is de kleinste verhandelbare onderverdeling. Voor de Amerikaanse dollar is dat de cent, dus USD heeft twee decimalen en $19,99 is 1999 cent. Dit is de weergave die betaalproviders verwachten, en die je database zou moeten gebruiken.

ISO 4217 — dezelfde standaard die je driletterige codes als USD en JPY geeft — kent elke valuta ook een subeenheidsexponent toe. De meeste ontwikkelaars nemen aan dat die exponent altijd 2 is. Dat is niet zo, en die aanname is de duurste bug in deze categorie.

Valuta's zonder decimalen

Deze valuta's hebben geen subeenheid in omloop, dus het bedrag dat je verstuurt is het hele aantal eenheden:

  • JPY — Japanse yen
  • KRW — Zuid-Koreaanse won
  • VND — Vietnamese dong
  • CLP — Chileense peso
  • ISK — IJslandse kroon
  • XAF / XOF / XPF — CFA- en CFP-frank
  • UGX — Oegandese shilling
  • PYG — Paraguayaanse guaraní
  • RWF, GNF, KMF, DJF, VUV — en diverse andere kleingeldvaluta's

Behandel je JPY als een valuta met twee decimalen en vermenigvuldig je met 100 vóór verzending naar de provider, dan heb je je klant zojuist honderd keer het bedoelde bedrag in rekening gebracht.

Valuta's met drie decimalen

Zeven valuta's verdelen zich in duizendsten in plaats van honderdsten:

  • KWD — Koeweitse dinar (1000 fils)
  • BHD — Bahreinse dinar (1000 fils)
  • OMR — Omaanse rial (1000 baisa)
  • JOD — Jordaanse dinar (1000 fils)
  • TND — Tunesische dinar (1000 millimes)
  • IQD — Iraakse dinar (1000 fils)
  • LYD — Libische dinar (1000 dirham)

Hier gaat de fout de andere kant op: behandel KWD als tweedecimalig en je berekent een tiende van wat je bedoelde. Een factuur van KWD 12,500 wordt KWD 1,250.

Er zijn zelfs vierdecimalige posten in ISO 4217 — de Chileense unidad de fomento (CLF) en de Uruguayaanse unidad previsional (UYW). Dat zijn geïndexeerde rekeneenheden in plaats van contant geld, maar als je systeem willekeurige ISO-codes accepteert, moet het ze overleven.

Als de standaard en je provider uiteenlopen

Dit is de valkuil waar teams in stappen die verder alles goed deden. Betaalproviders wijken soms om operationele redenen af van ISO 4217. Adyen documenteert bijvoorbeeld dat CLP, CVE, IDR en ISK in zijn API een ander aantal decimalen hebben dan de standaard voorschrijft — ISK heeft volgens ISO 4217 nul decimalen, maar moet met twee decimalen aan Adyen worden aangeleverd.

De regel: je afrondingstabel is een eigenschap van het systeem waarmee je praat, geen universele constante. Houd één tabel per integratie aan, vul die vanuit ISO 4217, en overschrijf per provider waar hun documentatie dat vraagt. Codeer nooit 100 hard.

Sla geld nooit op als float

Vóór elke afrondingsdiscussie: het fundament. Binaire drijvende komma kan de meeste decimale breuken niet exact weergeven:

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

Die staartcijfers zijn niet cosmetisch. Haal ze op het verkeerde moment door Math.round() en je krijgt een bedrag dat één subeenheid afwijkt — genoeg om de reconciliatie te laten mislukken.

Twee regels dekken bijna elk geval:

  1. Sla bedragen op als gehele getallen in subeenheden. Een kolom amount_minor BIGINT plus een kolom currency CHAR(3). { amount_minor: 1999, currency: "USD" } is ondubbelzinnig en komt overeen met wat Stripe, Adyen en de meeste providers toch al verwachten.
  2. Reken met gehele getallen of een decimaaltype. Pythons decimal.Decimal, Java's BigDecimal, PostgreSQL's NUMERIC, of een JavaScript-geldbibliotheek die integer-rekenwerk inkapselt. Reserveer floats voor de wisselkoers zelf, en dan nog alleen tot aan de vermenigvuldiging.

Ontwerp je deze laag vanaf nul, dan gaat onze gids over multi-currency grootboekontwerp dieper in op de schemakeuzes.

De omrekenpijplijn: subeenheden erin, subeenheden eruit

Valuta-omrekening heeft precies vier stappen, en afronding hoort bij stap drie — één keer, aan het eind.

  1. Reken het bronbedrag om van subeenheden naar een decimale waarde.
  2. Vermenigvuldig met de wisselkoers op volle precisie.
  3. Rond af op de subeenheidsexponent van de doelvaluta.
  4. Reken terug naar gehele subeenheden.

Zo ziet dat er in JavaScript uit, waarbij de valutatabel het werk doet:

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

Let op wat de functie niet doet: ze rondt de koers nooit af, rondt nooit een tussenwaarde af, en gaat nooit uit van twee decimalen. Math.round is hier half-up voor positieve getallen — prima voor een checkout, maar lees de volgende sectie voordat je het gebruikt voor iets gereguleerds.

Een afrondingsmodus kiezen

"Afronden op twee decimalen" is geen specificatie. Er zijn minstens vijf verdedigbare manieren om een gelijkspel te breken, en financiële systemen geven erom welke je kiest.

Modus2,5 →3,5 →−2,5 →Typisch gebruik
Half up34−3Consumentenprijzen, checkouttotalen
Half even (bankiersafronding)24−2Boekhouding, rente, belasting, rapportage
Half down23−2Zeldzaam; soms in legacy financiële code
Ceiling (omhoog)34−2Kosten die je nooit te laag mag innen
Floor (omlaag / afkappen)23−3Uitbetalingen die je nooit te hoog mag doen
Half up is wat de meeste mensen met "afronden" bedoelen en wat Math.round() doet bij positieve getallen. Het is intuïtief en passend voor een prijs die een klant zo meteen ziet.

Half even, ook bankiersafronding genoemd, stuurt exacte helften naar het dichtstbijzijnde even cijfer. Over veel transacties heft het de systematische opwaartse vertekening op die half-up introduceert, en daarom is het de standaard in boekhoudsystemen, in Pythons decimal-module en in IEEE 754 zelf. Aggregeer je duizenden omgerekende bedragen tot een omzetrapport, dan blaast half-up het totaal stilletjes op; half-even niet.

Ceiling en floor bestaan voor asymmetrisch risico. Een marktplaats die aan verkopers uitbetaalt kan elke uitbetaling naar beneden afronden om nooit meer uit te keren dan zij aanhoudt; het verschil komt op een afrondingsrekening.

Python maakt de keuze expliciet, wat de juiste ergonomie is:

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

De koers als string aan Decimal geven doet ertoe. Decimal(0.9241) erft de fout van de float; Decimal("0.9241") niet.

Drie afrondingsbugs die echt geld kosten

1. De koers afronden vóór het vermenigvuldigen

Wisselkoersen dragen doorgaans vier tot zes significante decimalen, en die afkappen is niet onschuldig. Neem USD/JPY op 147,2150 en een overboeking van $10.000:

  • Volledige koers: 10000 × 147.2150 = ¥1.472.150
  • Koers afgerond op twee decimalen (147,21): 10000 × 147.21 = ¥1.472.100

Een verschil van ¥50 op één transactie, puur door de koers te formatteren vóór gebruik. Sla de koers op met de precisie die je provider teruggeeft, rond alleen het resulterende bedrag af, en bewaar de exact gebruikte koers bij de transactie voor audit. Onze gids over waar wisselkoers-API's hun data vandaan halen legt uit waarom die precisie überhaupt betekenis heeft.

2. Twee keer afronden bij een omrekening met meerdere stappen

Route je USD → EUR → JPY en rond je af bij de EUR-stap, dan heb je precisie weggegooid die de tweede vermenigvuldiging vervolgens uitvergroot. Omrekening van $12,34 met USD/EUR op 0,9241 en EUR/JPY op 159,3063:

  • Direct: 12.34 × 147.2150 = 1816.63¥1.817
  • Via een afgeronde EUR-stap: 12.34 × 0.9241 = 11.4034 → afgerond op €11,40 → 11.40 × 159.3063 = 1816.09¥1.816

Eén yen, door één overbodige afrondingsstap. In een uitbetalingsrun van vijftigduizend transacties is dat een reconciliatieticket. Gebruik een direct paar waar dat beschikbaar is; moet je trianguleren, houd dan de tussenwaarde op volle precisie. Zie kruiskoersen uitgelegd voor de mechaniek.

3. Regels die niet optellen tot het totaal

Rond elke factuurregel afzonderlijk af en de delen tellen niet altijd op tot het afgeronde geheel. Het klassieke geval is een verdeling:

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

De oplossing is allocatie, geen afronding. Rond het totaal één keer af en verdeel het daarna over de delen, waarbij je de rest per subeenheid uitdeelt:

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

Pas hetzelfde patroon toe na een FX-omrekening: reken het factuurtotaal om en rond dat af, en alloceer dat totaal daarna over de regels. De regels sluiten dan altijd aan, omdat ze zijn afgeleid van het totaal in plaats van onafhankelijk berekend. Dit telt het zwaarst bij multi-currency facturatie en bij SaaS-billing met pro rata, waar een afwijking van één cent op een pdf belandt die de klant ziet.

Contantafronding is een aparte regel

De subeenheid van een valuta vertelt je het kleinste bedrag dat kan worden geboekt. Ze vertelt niet altijd het kleinste bedrag dat contant kan worden betaald. Verschillende landen trokken hun kleinste munten in en ronden contante betalingen aan de kassa af:

  • Zwitserland — contant wordt afgerond op 0,05 CHF
  • Canada — de muntstuk van één cent verdween in 2013; contant wordt afgerond op 5 cent
  • Zweden — contant wordt afgerond op de dichtstbijzijnde hele kroon
  • Nederland — contant wordt afgerond op 5 cent

Cruciaal: dit geldt voor de contante betaling, niet voor de factuur. Een Zwitserse factuur van CHF 12,32 wordt nog steeds als 12,32 geboekt; alleen de contante afwikkeling rondt af naar 12,30, en het verschil van 0,02 wordt als afrondingsverschil geboekt. Bouw je kassasoftware, modelleer contantafronding dan als een aparte, latere stap op de betaling — bak het nooit in het opgeslagen bedrag, anders lopen je elektronische en contante transacties uiteen.

Opmaak is de laatste stap, niet de berekening

Is het rekenwerk klaar, geef de weergave dan door aan een locale-bewuste formatter. Intl.NumberFormat kent al het aantal decimalen, de symboolpositie en de scheidingstekens van elke valuta:

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

Twee praktische noten. Ten eerste zijn Intl.NumberFormat-instanties duur om te construeren — cache er één per locale-valutapaar in plaats van er per rij één te bouwen. Ten tweede is de deling door 10 ** exp op de laatste regel de enige plek waar een float een geldwaarde zou mogen aanraken, en alleen omdat het resultaat meteen een string wordt.

Alles samenbrengen met de Finexly API

Haal de koers op volle precisie op, reken één keer om, rond één keer af, en bewaar de gebruikte koers:

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 }

rate en rate_timestamp op de transactieregel bewaren is wat een geschil zes maanden later beantwoordbaar maakt. Alle endpoint- en parameterdetails staan in de Finexly API-documentatie, en cache je koersen tussen requests, dan behandelen onze notities over caching en foutafhandeling de afwegingen rond versheid.

Een testchecklist

Geldbugs verschuilen zich in de gevallen waar niemand tests voor schrijft. Dek minimaal af:

  1. Een doel zonder decimalen — reken om naar JPY of KRW en controleer dat het resultaat geen fractioneel deel heeft.
  2. Een doel met drie decimalen — reken om naar KWD of BHD en controleer dat de drie decimalen behouden blijven.
  3. Exacte helften — controleer je gekozen afrondingsmodus in beide richtingen, inclusief negatieve waarden.
  4. Retourdrift — reken USD → EUR → USD om en controleer dat het resultaat binnen één subeenheid ligt, niet dat het gelijk is.
  5. Allocatie-invariant — controleer dat verdeelde delen altijd exact optellen tot het totaal, voor 1 tot 100 delen.
  6. Onbekende valutacodes — controleer dat de code een fout gooit in plaats van stilletjes op twee decimalen terug te vallen.
  7. Zeer grote bedragen — controleer dat er geen precisieverlies is voorbij Number.MAX_SAFE_INTEGER in JavaScript; gebruik BigInt als je IDR of VND op schaal verwerkt.

Veelgestelde vragen

Hoeveel decimalen heeft elke valuta?

De meeste hebben er twee. Ongeveer twee dozijn hebben er geen — waaronder JPY, KRW, VND, CLP en ISK — en zeven hebben er drie: KWD, BHD, OMR, JOD, TND, IQD en LYD. ISO 4217 is de gezaghebbende bron, maar controleer ook de tabel van je betaalprovider, want sommige wijken om operationele redenen af.

Moet ik wisselkoersen of omgerekende bedragen afronden?

Alleen omgerekende bedragen. Houd de koers op de volle precisie die je provider teruggeeft, vermenigvuldig, en rond het resultaat daarna één keer af op de subeenheid van de doelvaluta. Een koers afronden vóór het vermenigvuldigen introduceert een fout die evenredig is aan de transactiegrootte.

Wat is het verschil tussen half-up en bankiersafronding?

Half-up stuurt een exacte helft altijd van nul af (2,5 → 3). Bankiersafronding — helft naar even — stuurt hem naar het dichtstbijzijnde even cijfer (2,5 → 2, 3,5 → 4), wat de systematische opwaartse vertekening wegneemt wanneer je veel bedragen aggregeert. Gebruik half-up voor prijzen die klanten zien, half-even voor boekhouding en rapportage.

Waarom tellen mijn omgerekende regels niet op tot het omgerekende totaal?

Omdat elke regel afzonderlijk is afgerond en de fouten zich opstapelen. Rond het totaal één keer af en alloceer dat totaal daarna over de regels met een grootste-rest-verdeling. De delen tellen dan per constructie op tot het geheel.

Kan ik geld niet gewoon als float met twee decimalen opslaan?

Nee. Binaire drijvende komma kan waarden als 0,1 niet exact weergeven, dus fouten stapelen zich op bij optellingen en vermenigvuldigingen en kantelen uiteindelijk een afrondingsbeslissing. Sla gehele getallen in subeenheden op, of gebruik een exact decimaaltype. Dit is geen theoretische zorg — het is de meest voorkomende oorzaak van reconciliatiefouten van één cent.

Haal koersen op volle precisie

Correct afronden begint met een koers die je kunt vertrouwen en precisie die je niet hebt weggegooid. Haal je gratis Finexly API-sleutel — geen creditcard nodig. Begin met 1.000 gratis requests per maand over 170+ valuta's, controleer je berekening met de valutaomrekenaar, en bekijk de prijsplannen als je volume groeit.

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 →