DEV Community

Cover image for Stop Multiplying by 100 - Handle Money Amounts Safely in TypeScript for Beginners
FredAbod
FredAbod

Posted on

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 ๐Ÿ˜ญ

shocked

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Run it later (after setup) with npm run bug and you'll see:

1998.9999999999998
115015.00000000001
434
50000
Enter fullscreen mode Exit fullscreen mode

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.

facepalm

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode
  • 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)}`;
}
Enter fullscreen mode Exit fullscreen mode

Let's walk through toMinor("1150.15", 2):

  1. The regex splits it: whole = "1150", fraction = "15".
  2. "15".padEnd(2, "0") is still "15".
  3. Glue them: "1150" + "15" = "115015".
  4. 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);
}
Enter fullscreen mode Exit fullscreen mode
  • 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]);
}
Enter fullscreen mode Exit fullscreen mode
  • 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;
  }
});
Enter fullscreen mode Exit fullscreen mode
  • 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);
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

That's it. Build and start:

npm run build
npm start
Enter fullscreen mode Exit fullscreen mode
Server running on http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

celebration

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"}'
Enter fullscreen mode Exit fullscreen mode
{
  "stored": {
    "amountMinor": 115015,
    "currency": "NGN"
  },
  "provider": "paystack",
  "providerAmount": 115015,
  "display": "NGN 1150.15"
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode
{
  "stored": {
    "amountMinor": 500,
    "currency": "JPY"
  },
  "provider": "stripe",
  "providerAmount": 500,
  "display": "JPY 500"
}
Enter fullscreen mode Exit fullscreen mode

ยฅ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"}'
Enter fullscreen mode Exit fullscreen mode
{
  "stored": {
    "amountMinor": 5000,
    "currency": "XOF"
  },
  "provider": "paystack",
  "providerAmount": 500000,
  "display": "XOF 5000"
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode
{
  "error": "\"10.999\" has 3 decimal places, this currency allows 2"
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode
{
  "error": "amount must be a string like \"19.99\""
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode
{
  "error": "Stripe does not support KWD in this app"
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode
npm run build
npm test
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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; // ๐Ÿ™ˆ
Enter fullscreen mode Exit fullscreen mode
  • 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. ๐Ÿ˜

mind blown

Recap (say it with me)

  1. Payment providers count in coins (minor units), not notes. ๐Ÿช™
  2. Each provider has its own rule. * 100 is a coincidence, not a rule.
  3. Money arrives as a string, never a float.
  4. toMinor() glues digits; it doesn't multiply.
  5. Store { amountMinor, currency } together, always.
  6. One small adapter per provider.
  7. 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:

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 ๐Ÿค—

wave goodbye

Top comments (0)