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.00000000001to 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 just500. 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)gives434. 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 constmakes TypeScript treat the keys as exact values, soCurrencybecomes"NGN" | "GHS" | ... | "XOF". Typos like"NGM"get caught. -
isCurrency()is a type guard. After it returnstrue, TypeScript knows the string is a realCurrency. - 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 > decimalsthrows."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 becomes500000. - Notice there is still no
* 100. We reusetoMinor, 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
amountin the currency's minor unit, and give the example:1099to charge 10.99 USD,10to 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"rejects19.99sent 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". -
storedis what goes into your database: an integer plus the currency code. Always both.1999alone means nothing. Is it naira? Yen? Dinar? 🤷🏽♂️ -
providerAmountis computed by the provider adapter. For NGN on Paystack they happen to match. For XOF on Paystack they don't (5000stored,500000sent). 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,<...


