Stop Multiplying by 100 - Handle Money Amounts Safely in TypeScript for Beginners
So you added payments to your app. The checkout button works. Paystack opens. Your test card goes through. You wrote amount * 100 somewhere and moved on with your life, feeling like a fintech engineer. 😎 Then one day: A customer pays ₦1,150.15 and your code sends 115015.00000000001 to the provider. 👀 A refund is one kobo short and finance wants a "quick call". Your company expands to Japan and a ¥500 order is charged as ¥50,000. 😭😭 Nobody hacked you. Nobody broke the provider. It was you. It was always you 😭 In this article we will fix money handling properly, step by step, in Express and TypeScript. No * 100 anywhere. Not even one. 😁 The coin-counting cashier 🪙 Picture a cashier at a busy market stall. You hand her a ₦1,000 note. She does not write "1,000 naira" in her book. She writes it in kobo, the smallest coin: 100000. Why? Because coins are whole numbers. You can't have half a coin. Whole numbers never get "almost right". That's exactly how payment providers work. Every provider is a cashier who only counts coins. But here is the twist: every cashier has her own counting rule. Paystack's cashier wants every amount in the subunit of the currency. NGN 100 is sent as 10000. Stripe's cashier wants the currency's minor unit. USD 10.99 is 1099. But Japanese yen has no smaller coin (Stripe calls it a "zero-decimal currency"), so ¥500 is just 500. Multiply it by 100 and you have charged someone ¥50,000. Some currencies have three decimal places. The Kuwaiti dinar (KWD) has three decimal places, so KWD 1.250 is 1250. And my favourite: Paystack's docs say the West African CFA franc (XOF) has no subunit, but you must still multiply by 100. Same currency, different cashier, different rule. 🙃 So amount * 100 is not a rule. It's a coincidence that happens to work for naira and dollars. Here's the plan for today: Customer types "1150.15" │ ▼ ┌──────────────────┐ │ toMinor() │ string → whole number of coins (no floats!) └──────────────────┘ │ ├──► Our notebook (DB): { amountMinor: 115015, currency: "NGN" } │ └──► Provider adapter: paystackAmount() / stripeAmount() (each cashier's own rule) Prerequisites Node.js 22 (I ran everything on Node 22) Basic Express knowledge (routes, req.body, res.json) Basic TypeScript (types, functions) curl or Postman No Paystack or Stripe account needed. We will build the exact amount each provider expects and stop right before the network call, so you can run everything locally without API keys. In a real app you'd pass that number to the provider's API. Let's Dive Right In 😁😁 Step 0: Watch it break first — src/float-bug.ts Before we fix anything, let's see the bug with our own eyes. This file does one thing: embarrass JavaScript. // Run this once and watch your confidence leave your body. console.log(19.99 * 100); // 1998.9999999999998 console.log(1150.15 * 100); // 115015.00000000001 console.log(Math.floor(4.35 * 100)); // 434, one kobo gone console.log(500 * 100); // 50000, fine for NGN, wrong for JPY on Stripe Run it later (after setup) with npm run bug and you'll see: 1998.9999999999998 115015.00000000001 434 50000 Why? JavaScript numbers are floating point. 19.99 can't be stored exactly in binary, so it's stored as something very close to 19.99. Multiply it and the tiny error shows up. Math.round() would "fix" 19.99, but now you are rounding money silently and hoping. Hope is not a payment strategy. Math.floor(4.35 * 100) gives 434. One kobo just vanished. Do that across thousands of transactions and someone will notice. Probably finance. Probably on a Friday. 😅 The real fix: never let money be a float in the first place. Treat the amount as a string, split it at the dot, and glue the digits together. Step 1: Set up the project mkdir money-minor-units && cd money-minor-units npm init -y npm i express@5 npm i -D typescript @types/express @types/node mkdir -p src/money src/providers src/routes I used Express 5.3.0 and TypeScript 7.0.2. Now the package.json. The important bits are "type": "module" and the scripts: { "name": "money-minor-units", "version": "1.0.0", "description": "", "main": "dist/server.js", "scripts": { "build": "tsc", "start": "node dist/server.js", "bug": "node dist/float-bug.js", "test": "node --test \"dist/**/*.test.js\"" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "express": "^5.3.0" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^26.6.6", "typescript": "^7.0.2" }, "type": "module" } And tsconfig.json, with strict on, because we are adults now: { "compilerOptions": { "target": "ES2022", "module": "nodenext", "moduleResolution": "nodenext", "rootDir": "src", "outDir": "dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"] } Step 2: The cashier's cheat sheet — src/money/currencies.ts This file does one thing: it remembers how many decimal places each currency has. // How many digits come after the decimal point for each currency. // NGN 2 (kobo), JPY 0 (no subunit), KWD 3. export const CURRENCY_DECIMALS = { NGN: 2, GHS: 2, KES: 2, ZAR: 2, USD: 2, EUR: 2, GBP: 2, JPY: 0, KWD: 3, XOF: 0, } as const; export type Currency = keyof typeof CURRENCY_DECIMALS; export function isCurrency(code: string): code is Currency { return Object.hasOwn(CURRENCY_DECIMALS, code); } as const makes TypeScript treat the keys as exact values, so Currency becomes "NGN" | "GHS" | ... | "XOF". Typos like "NGM" get caught. isCurrency() is a type guard. After it returns true, TypeScript knows the string is a real Currency. Only add a currency after you've checked it. Don't guess. Guessing is how ¥500 becomes ¥50,000. Step 3: Counting coins without floats — src/money/toMinor.ts This is the heart of the article. Read it slowly. 👀 export class MoneyError extends Error {} // "19.99" + 2 decimals -> 1999 // "500" + 0 decimals -> 500 // "1.25" + 3 decimals -> 1250 export function toMinor(amount: string, decimals: number): number { const match = /^(\d+)(?:\.(\d+))?$/.exec(amount.trim()); if (!match) { throw new MoneyError(`"${amount}" is not a valid amount`); } const whole = match[1]!; const fraction = match[2] ?? ""; if (fraction.length > decimals) { throw new MoneyError( `"${amount}" has ${fraction.length} decimal places, this currency allows ${decimals}` ); } const minor = Number(whole + fraction.padEnd(decimals, "0")); if (!Number.isSafeInteger(minor)) { throw new MoneyError(`"${amount}" is too large`); } return minor; } // 1999 + 2 decimals -> "19.99" export function fromMinor(minor: number, decimals: number): string { if (decimals === 0) return String(minor); const digits = String(minor).padStart(decimals + 1, "0"); return `${digits.slice(0, -decimals)}.${digits.slice(-decimals)}`; } Let's walk through toMinor("1150.15", 2): The regex splits it: whole = "1150", fraction = "15". "15".padEnd(2, "0") is still "15". Glue them: "1150" + "15" = "115015". Number("115015") = 115015. A clean whole number. No multiplication ever happened. 🎉 And for toMinor("1.25", 3) (KWD): "25".padEnd(3, "0") becomes "250", so we get "1" + "250" = 1250. Three important lines, don't skip them: fraction.length > decimals throws. "10.999" dollars is not a real amount, so we reject it instead of quietly rounding. Silent rounding is how kobo disappear. The regex only allows digits and one dot. So "-5", "1e3" and "" are all rejected. (Refunds should be their own operation, not a negative charge.) Number.isSafeInteger() makes sure the number is small enough for JavaScript to store exactly. fromMinor() goes the other way for display: 1999 with 2 decimals becomes "19.99", and 5 becomes "0.05" thanks to padStart. Step 4: Paystack's cashier — src/providers/paystack.ts Each provider gets its own small file. This one knows Paystack's rule and nothing else. import { MoneyError, toMinor } from "../money/toMinor.js"; const PAYSTACK_CURRENCIES = ["NGN", "GHS", "KES", "ZAR", "USD", "XOF"] as const; type PaystackCurrency = (typeof PAYSTACK_CURRENCIES)[number]; function isPaystackCurrency(code: string): code is PaystackCurrency { return (PAYSTACK_CURRENCIES as readonly string[]).includes(code); } // Paystack wants every amount in the subunit, i.e. x100. // XOF has no subunit, but Paystack still wants x100, // so we only allow whole XOF amounts. export function paystackAmount(amount: string, currency: string): number { if (!isPaystackCurrency(currency)) { throw new MoneyError(`Paystack does not support ${currency} in this app`); } if (currency === "XOF" && amount.includes(".")) { throw new MoneyError("XOF amounts must be whole numbers"); } return toMinor(amount, 2); } Paystack's API docs say amounts must be sent "in the subunit of that currency", so NGN 100 goes out as 10000. For XOF, Paystack's docs say there is no subunit but you must still multiply by 100, and that this doesn't mean the amount can have a fractional part. So we accept only whole XOF and send toMinor(amount, 2): XOF 5000 becomes 500000. Notice there is still no * 100. We reuse toMinor, which glues strings. Step 5: Stripe's cashier — src/providers/stripe.ts Same idea, different rule. import { CURRENCY_DECIMALS } from "../money/currencies.js"; import { MoneyError, toMinor } from "../money/toMinor.js"; // Only the currencies this app has checked against Stripe's docs. const STRIPE_CURRENCIES = ["USD", "EUR", "GBP", "JPY"] as const; type StripeCurrency = (typeof STRIPE_CURRENCIES)[number]; function isStripeCurrency(code: string): code is StripeCurrency { return (STRIPE_CURRENCIES as readonly string[]).includes(code); } // Stripe wants the currency's minor unit: // USD 10.99 -> 1099, JPY 500 -> 500 (zero-decimal). export function stripeAmount(amount: string, currency: string): number { if (!isStripeCurrency(currency)) { throw new MoneyError(`Stripe does not support ${currency} in this app`); } return toMinor(amount, CURRENCY_DECIMALS[currency]); } Stripe's docs say all API requests expect amount in the currency's minor unit, and give the example: 1099 to charge 10.99 USD, 10 to charge 10 JPY. So for Stripe we use the real decimals from our cheat sheet: USD has 2, JPY has 0. I only listed four currencies on purpose. Stripe has special cases for some currencies (for example ISK and UGX, which its docs say must be sent with two extra zeros for backward compatibility). If you add a currency, read Stripe's currencies page for it first. Slap your own hand if you skip that. Gently. 😅 Step 6: The checkout counter — src/routes/checkout.ts This is where everything meets. The route does two separate things: decide what we store, and decide what the provider gets. import { Router } from "express"; import { CURRENCY_DECIMALS, isCurrency } from "../money/currencies.js"; import { MoneyError, fromMinor, toMinor } from "../money/toMinor.js"; import { paystackAmount } from "../providers/paystack.js"; import { stripeAmount } from "../providers/stripe.js"; export const checkoutRouter = Router(); checkoutRouter.post("/checkout", (req, res) => { const { amount, currency, provider } = req.body ?? {}; if (typeof amount !== "string") { res.status(400).json({ error: 'amount must be a string like "19.99"' }); return; } if (typeof currency !== "string" || !isCurrency(currency)) { res.status(400).json({ error: "unknown currency" }); return; } try { // 1. What WE store: integer minor units + currency code. const decimals = CURRENCY_DECIMALS[currency]; const amountMinor = toMinor(amount, decimals); // 2. What the PROVIDER wants: their own rules. let providerAmount: number; if (provider === "paystack") { providerAmount = paystackAmount(amount, currency); } else if (provider === "stripe") { providerAmount = stripeAmount(amount, currency); } else { res.status(400).json({ error: "provider must be paystack or stripe" }); return; } // In real life you would call the provider here with providerAmount. // This tutorial stops before the network call (no real API keys). res.json({ stored: { amountMinor, currency }, provider, providerAmount, display: `${currency} ${fromMinor(amountMinor, decimals)}`, }); } catch (err) { if (err instanceof MoneyError) { res.status(400).json({ error: err.message }); return; } throw err; } }); typeof amount !== "string" rejects 19.99 sent as a JSON number. Once it's a number, it's already a float, and the damage is done before your code even runs. Ask your frontend to send "19.99". stored is what goes into your database: an integer plus the currency code. Always both. 1999 alone means nothing. Is it naira? Yen? Dinar? 🤷🏽♂️ providerAmount is computed by the provider adapter. For NGN on Paystack they happen to match. For XOF on Paystack they don't (5000 stored, 500000 sent). That's exactly why we keep them apart. Step 7: Wire it up — src/app.ts and src/server.ts This file does one thing: create the app. import express from "express"; import { checkoutRouter } from "./routes/checkout.js"; export const app = express(); app.use(express.json()); app.use(checkoutRouter); And this one does one thing: start it. import { app } from "./app.js"; const PORT = Number(process.env.PORT ?? 3000); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); }); That's it. Build and start: npm run build npm start Server running on http://localhost:3000 Step 8: Let's poke it with curl 🧪 A normal naira payment, the one that used to give us 115015.00000000001: curl -X POST http://localhost:3000/checkout \ -H "Content-Type: application/json" \ -d '{"amount":"1150.15","currency":"NGN","provider":"paystack"}' { "stored": { "amountMinor": 115015, "currency": "NGN" }, "provider": "paystack", "providerAmount": 115015, "display": "NGN 1150.15" } Clean 115015. No float drama. 🥰 The Japanese customer, on Stripe: curl -X POST http://localhost:3000/checkout \ -H "Content-Type: application/json" \ -d '{"amount":"500","currency":"JPY","provider":"stripe"}' { "stored": { "amountMinor": 500, "currency": "JPY" }, "provider": "stripe", "providerAmount": 500, "display": "JPY 500" } ¥500 is 500. Not 50,000. The customer keeps their money and you keep your job. 😋 XOF on Paystack, where the stored amount and provider amount are different: curl -X POST http://localhost:3000/checkout \ -H "Content-Type: application/json" \ -d '{"amount":"5000","currency":"XOF","provider":"paystack"}' { "stored": { "amountMinor": 5000, "currency": "XOF" }, "provider": "paystack", "providerAmount": 500000, "display": "XOF 5000" } Now the bad inputs. Too many decimals: curl -X POST http://localhost:3000/checkout \ -H "Content-Type: application/json" \ -d '{"amount":"10.999","currency":"USD","provider":"stripe"}' { "error": "\"10.999\" has 3 decimal places, this currency allows 2" } Amount sent as a number instead of a string: curl -X POST http://localhost:3000/checkout \ -H "Content-Type: application/json" \ -d '{"amount":19.99,"currency":"NGN","provider":"paystack"}' { "error": "amount must be a string like \"19.99\"" } A currency we never checked for that provider: curl -X POST http://localhost:3000/checkout \ -H "Content-Type: application/json" \ -d '{"amount":"1.25","currency":"KWD","provider":"stripe"}' { "error": "Stripe does not support KWD in this app" } Every wrong input gets a clear 400 instead of a wrong charge. That's the whole point. Step 9: Tests, because money deserves tests — src/money/toMinor.test.ts We use Node's built-in test runner, so there's nothing extra to install. import { test } from "node:test"; import assert from "node:assert/strict"; import { toMinor, fromMinor, MoneyError } from "./toMinor.js"; import { paystackAmount } from "../providers/paystack.js"; import { stripeAmount } from "../providers/stripe.js"; test("NGN 19.99 becomes 1999 kobo, not 1998.9999999999998", () => { assert.equal(toMinor("19.99", 2), 1999); }); test("NGN 4.35 becomes 435, no kobo goes missing", () => { assert.equal(toMinor("4.35", 2), 435); }); test("JPY 500 stays 500", () => { assert.equal(toMinor("500", 0), 500); }); test("KWD 1.25 becomes 1250", () => { assert.equal(toMinor("1.25", 3), 1250); }); test("too many decimals is rejected, not silently rounded", () => { assert.throws(() => toMinor("10.999", 2), MoneyError); assert.throws(() => toMinor("500.5", 0), MoneyError); }); test("garbage is rejected", () => { assert.throws(() => toMinor("-5", 2), MoneyError); assert.throws(() => toMinor("1e3", 2), MoneyError); assert.throws(() => toMinor("", 2), MoneyError); }); test("fromMinor goes back the other way", () => { assert.equal(fromMinor(1999, 2), "19.99"); assert.equal(fromMinor(5, 2), "0.05"); assert.equal(fromMinor(1250, 3), "1.250"); assert.equal(fromMinor(500, 0), "500"); }); test("provider rules", () => { assert.equal(paystackAmount("100", "NGN"), 10000); assert.equal(paystackAmount("5000", "XOF"), 500000); assert.throws(() => paystackAmount("50.5", "XOF"), MoneyError); assert.equal(stripeAmount("10.99", "USD"), 1099); assert.equal(stripeAmount("500", "JPY"), 500); }); npm run build npm test Trimmed output: ok 1 - NGN 19.99 becomes 1999 kobo, not 1998.9999999999998 ok 2 - NGN 4.35 becomes 435, no kobo goes missing ok 3 - JPY 500 stays 500 ok 4 - KWD 1.25 becomes 1250 ok 5 - too many decimals is rejected, not silently rounded ok 6 - garbage is rejected ok 7 - fromMinor goes back the other way ok 8 - provider rules # tests 8 # pass 8 # fail 0 All green, and these tests actually check exact values. If an AI agent (or a tired you at 1am) ever "fixes" a failing money test by changing assert.equal to something looser, reject that change. 😤 Before vs after Before: const amount = req.body.amount * 100; // 🙈 Floats sneak in: 1150.15 * 100 is 115015.00000000001. Math.floor silently loses kobo. Wrong for zero-decimal currencies like JPY on Stripe. Wrong for three-decimal currencies like KWD. One line, spread across 14 files, that nobody wants to touch. After: The amount arrives as a string and never becomes a float. toMinor() turns it into a whole number by gluing digits, not multiplying. The database stores { amountMinor, currency }, always together. Each provider has its own small adapter with its own rule. Bad input fails loudly with a 400, before any money moves. Why we do it this way At Prooval I built payment flows on Stripe, Paystack and Fincra. Once you have more than one provider, you stop thinking "multiply by 100" and start thinking "what does this provider expect for this currency?". Keeping one shared toMinor() plus one tiny adapter per provider means adding a new provider is one new file, not a search-and-replace across the codebase. A few extra tips for when you go further: Store integers in the database, with a separate currency column. Never a float column for money. Check the provider's minimum amounts. Paystack's docs list a minimum per currency (₦50.00 for NGN at the time of writing), so validate that before you call the API. Do maths on integers only. Adding two minor amounts is safe. Splitting 1000 kobo three ways is not as simple as it looks, but that's a story for another article. 😁 Recap (say it with me) Payment providers count in coins (minor units), not notes. 🪙 Each provider has its own rule. * 100 is a coincidence, not a rule. Money arrives as a string, never a float. toMinor() glues digits; it doesn't multiply. Store { amountMinor, currency } together, always. One small adapter per provider. Reject bad amounts loudly. Never round silently. GitHub repo: https://github.com/FredAbod/stop-multiplying-by-100-money-amounts-typescript-code Sources I checked while writing this: Stripe: Supported currencies (minor units, zero-decimal currencies, special cases) Paystack API: Supported currencies and subunits, XOF note Paystack API: Initialize Transaction Adyen: Currency codes and minor units I hope this was helpful. Don't forget to drop a like and follow 😋😋 Thank you for taking the time to read 🥰🥰 See you on the next one 🤗
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to