Currency Formatting with Claude Code: Intl.NumberFormat Guide
Build reliable currency formatting with Intl.NumberFormat, minor units, rounding, accounting display, and tests.
Currency Formatting Is Part of Billing, Not Decoration
Showing $19.99, ¥1,980, or R$ 1.234,56 looks like a small UI task until the product supports multiple countries, refunds, invoices, CSV exports, and revenue reports. JPY normally displays with no decimal digits. USD and EUR use two. Indian English groups large numbers as 12,34,567. Brazilian Portuguese swaps the decimal and thousands separators compared with US English. Negative invoice lines may need accounting display such as ($10.00).
If you concatenate strings by hand, the first screen may pass review and the billing system will still become fragile. A safer pattern is to store money as integer minor units, do domain math on integers, and format only at the final display boundary with Intl.NumberFormat.
This guide gives Claude Code a reviewable implementation path: minor units, rounding, accounting display, multi-currency SaaS billing examples, tests for JPY/USD/EUR/BRL/INR/IDR, and prompts you can paste into Claude Code. The official references are MDN’s Intl.NumberFormat constructor, MDN’s formatToParts, and the ECMA-402 NumberFormat specification.
flowchart LR
A[DB: minor unit integer] --> B[Domain math]
B --> C[Tax, discount, refund]
C --> D[Intl.NumberFormat]
D --> E[UI, invoice, email]
Store Values Separately From Display Strings
The most important design choice is what you persist. Store an integer amount plus a currency code: $19.99 becomes 1999 USD, ¥1,980 becomes 1980 JPY, and Rp 123.456 becomes 123456 IDR for the policy used in this guide. The formatted string should be derived, not stored.
| Approach | Example | Benefit | Risk |
|---|---|---|---|
| Store formatted strings | "$19.99" | Fast for a static page | Breaks search, reports, refunds, and localization |
| Store decimal numbers | 19.99 | Easy at the start | Floating-point and currency digit rules leak everywhere |
| Store minor unit integers | 1999 | Stable math, reports, and tests | Requires conversion at input/output boundaries |
Intl.NumberFormat formats values. It does not decide exchange rates, tax law, payment-provider minor unit policy, or when an invoice should round. Tell Claude Code to keep those responsibilities separate.
Copy-Paste Runnable Implementation
Save this as currency-format-demo.mjs and run node currency-format-demo.mjs. It uses only Node’s built-in assert module and the standard Intl API.
// currency-format-demo.mjs
import assert from "node:assert/strict";
const minorUnitDigits = Object.freeze({
JPY: 0,
USD: 2,
EUR: 2,
BRL: 2,
INR: 2,
IDR: 0,
});
function assertCurrency(currency) {
if (!(currency in minorUnitDigits)) {
throw new Error(`Unsupported currency: ${currency}`);
}
}
function roundHalfAwayFromZero(value) {
return value < 0 ? -Math.round(Math.abs(value)) : Math.round(value);
}
export function moneyFromMajor(amount, currency) {
assertCurrency(currency);
if (!Number.isFinite(amount)) {
throw new Error(`Invalid amount: ${amount}`);
}
const digits = minorUnitDigits[currency];
return {
minor: roundHalfAwayFromZero(amount * 10 ** digits),
currency,
};
}
export function toMajor(money) {
assertCurrency(money.currency);
return money.minor / 10 ** minorUnitDigits[money.currency];
}
export function addMoney(left, right) {
if (left.currency !== right.currency) {
throw new Error(`Currency mismatch: ${left.currency} vs ${right.currency}`);
}
return { minor: left.minor + right.minor, currency: left.currency };
}
export function multiplyMoney(money, factor) {
if (!Number.isFinite(factor)) {
throw new Error(`Invalid factor: ${factor}`);
}
return {
minor: roundHalfAwayFromZero(money.minor * factor),
currency: money.currency,
};
}
export function formatMoney(
money,
{
locale = "en-US",
accounting = false,
currencyDisplay = "symbol",
roundingMode = "halfExpand",
} = {},
) {
assertCurrency(money.currency);
return new Intl.NumberFormat(locale, {
style: "currency",
currency: money.currency,
currencyDisplay,
currencySign: accounting ? "accounting" : "standard",
roundingMode,
}).format(toMajor(money));
}
const samples = [
["ja-JP", { minor: 123456, currency: "JPY" }],
["en-US", { minor: 123456, currency: "USD" }],
["de-DE", { minor: 123456, currency: "EUR" }],
["pt-BR", { minor: 123456, currency: "BRL" }],
["en-IN", { minor: 123456789, currency: "INR" }],
["id-ID", { minor: 123456, currency: "IDR" }],
];
for (const [locale, money] of samples) {
const formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency: money.currency,
});
const options = formatter.resolvedOptions();
const parts = formatter.formatToParts(toMajor(money));
assert.equal(options.maximumFractionDigits, minorUnitDigits[money.currency]);
assert.ok(parts.some((part) => part.type === "currency"));
console.log(`${locale} ${money.currency}: ${formatMoney(money, { locale })}`);
}
assert.equal(
addMoney(moneyFromMajor(19.99, "USD"), moneyFromMajor(5, "USD")).minor,
2499,
);
assert.equal(multiplyMoney(moneyFromMajor(1980, "JPY"), 1.1).minor, 2178);
assert.match(
formatMoney({ minor: -129900, currency: "USD" }, { locale: "en-US", accounting: true }),
/^\(\$/,
);
assert.throws(
() => addMoney(moneyFromMajor(10, "USD"), moneyFromMajor(10, "JPY")),
/Currency mismatch/,
);
console.log("currency formatting checks passed");
The main rule is visible in the function boundaries: addMoney and multiplyMoney return integer money objects, while formatMoney is the only function that returns a display string.
Use Case 1: Multi-Currency SaaS Pricing
Pricing tables should receive structured data, not preformatted labels.
type CurrencyCode = "JPY" | "USD" | "EUR" | "BRL" | "INR" | "IDR";
type PlanPrice = {
planId: "starter" | "pro" | "team";
currency: CurrencyCode;
amountMinor: number;
};
const prices: PlanPrice[] = [
{ planId: "pro", currency: "JPY", amountMinor: 1980 },
{ planId: "pro", currency: "USD", amountMinor: 1999 },
{ planId: "pro", currency: "EUR", amountMinor: 1899 },
];
This makes A/B tests, regional campaigns, and annual-plan discounts auditable. Claude Code can then update the UI without hiding billing decisions inside translated strings.
Use Case 2: Invoices, Refunds, and Accounting Display
Negative money is not always rendered with a minus sign. For US-style invoices, accounting display may be preferred:
formatMoney(
{ minor: -129900, currency: "USD" },
{ locale: "en-US", accounting: true },
);
Do not assume every locale uses parentheses. The option asks the runtime to follow locale rules. If your invoice PDF requires a fixed representation, write that product requirement down and test the generated PDF.
Use Case 3: Tax, Discounts, and Proration
Proration creates values such as 1999 * 10 / 31. Decide whether you round each invoice line or only the final total. Both can be legitimate, but mixing them causes one-cent differences between the UI, receipt email, and payment provider.
Name tests after the policy, for example rounds_prorated_line_before_invoice_total. That gives Claude Code and human reviewers a concrete rule instead of a vague “fix rounding” task.
Use Case 4: Admin Screens and CSV Exports
Human-readable tables and machine-readable exports need different columns.
| Column | Example | Purpose |
|---|---|---|
amount_minor | 1999 | Accurate reporting and audits |
currency | USD | Currency-aware aggregation |
amount_display | $19.99 | Human scanning |
This small separation supports monetization work later: MRR, LTV, refund rate, paid conversion, and ad spend analysis all need numeric money fields.
Common Failure Cases
Do not store "$19.99" in the database. You will eventually need to sort, sum, refund, translate, or audit it.
Do not confuse locale with currency. A user can read Japanese UI and pay in USD. A US finance team can review EUR invoices.
Do not assume every currency has two decimal digits. JPY and IDR format with no fraction digits in the runtimes tested here. Keep your currency table under review when you add a new payment region.
Do not treat roundingMode as your billing policy. Intl.NumberFormat rounds for display. Your application still decides when invoice math rounds.
Do not parse currency symbols with regex. Use formatToParts when you need the currency, integer, group, decimal, and fraction parts separately.
Claude Code Review Prompt
Review this repository's money formatting and billing math.
Requirements:
- Check that DB/API models do not store formatted currency strings
- Make JPY/USD/EUR/BRL/INR/IDR minor units explicit
- Use Intl.NumberFormat while keeping locale and currency separate
- Decide whether refunds and discounts need currencySign: "accounting"
- Make rounding policy visible in test names
- Add Node-runnable tests for currency formatting behavior
Related Reading and CTA
For broader localization work, read i18n implementation with Claude Code. For dates and time zones, see date and time handling. For paid plans, pair this with Stripe subscription implementation.
ClaudeCodeLab publishes practical checklists and prompt packs for implementation boundaries that AI tools often gloss over: billing, auth, security, and release review. Before asking Claude Code to rewrite a pricing page, verify that your product already separates amountMinor, currency, and locale.
I verified the demo script locally with Node.js 24. It checks fraction digits and currency parts for JPY/USD/EUR/BRL/INR/IDR, validates USD accounting display, verifies integer arithmetic, and throws on currency mismatch. Exact localized strings can vary with ICU data, so production tests should assert the policy, not only one snapshot string.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.