Why We Store Money as Integer Rupiah, Never Float

Photo by Anis Eka via Openverse (CC BY 2.0)
Floating-point types use binary IEEE-754 representation, which cannot store most decimal fractions exactly. Tiny errors accumulate across many calculations until totals no longer balance. Integers are exact, so storing money in the currency's smallest unit as an integer eliminates rounding surprises entirely.
The rupiah technically has a subunit called sen (one hundredth), but inflation retired all sen coins and notes decades ago, so nobody pays with fractions of a rupiah in practice. That means one rupiah is already the smallest unit. Unlike a US system that stores dollars as an integer count of cents, a rupiah system stores rupiah directly as integers with no scaling factor.
For rupiah with no sub-units, Int is the simplest choice because it serializes to JSON cleanly and covers values up to about 2.1 billion, which exceeds any single line item. Use BigInt only for large aggregates like lifetime revenue, where Postgres SUM already returns a bigint. Decimal is best when a currency has fractional units you must preserve, but it is overkill for whole rupiah.
Prisma returns BigInt columns as JavaScript BigInt values, and calling JSON.stringify on a BigInt throws a TypeError. You must convert it to a string or number at the API boundary before returning it. This is a strong reason to keep stored money as Int and only handle BigInt where a Postgres SUM produces one.
Format only at the very edge of the application, using a locale-aware formatter such as Intl.NumberFormat with the id-ID locale in TypeScript or NumberFormat.currency in Dart. Set the fraction digits to zero because rupiah shows no decimals. Never parse a formatted string back into a number — the integer travels from the database to the render call untouched.

Photo by Anis Eka via Openverse (CC BY 2.0)
Key Takeaway
Store money as integer rupiah, never float or double. The Indonesian rupiah has no cents in practice, so one IDR is already the smallest unit — no scaling needed. Floats round in binary and drift by fractions of a rupiah across hundreds of order lines, which breaks end-of-day cash reconciliation. Integers are exact.
On JID Carwash ERP, every money value in the database is a plain integer number of rupiah. There is no cents column, no decimal scale, and absolutely no float or double anywhere in the money path. This is not a stylistic preference — it is the single most load-bearing invariant in the whole codebase, and it exists because a three-rupiah discrepancy once stopped a cashier from going home.
Indonesia makes this decision easy. The rupiah technically has a subunit called sen (one hundredth), but decades of inflation retired every sen coin and note long ago. The smallest thing anyone actually pays with is one rupiah. A carwash order is priced in whole thousands — a basic wash is fifteen thousand rupiah, not fifteen thousand and fifty sen. So while a US system stores dollars as an integer count of cents, we store rupiah as an integer count of rupiah. The minor unit and the major unit are the same unit.
The first version of the POS computed the eleven percent PPN tax line in the browser as price multiplied by zero point one one, kept the result as a JavaScript number, and summed those numbers into the order total. The API stored whatever the client sent. It looked fine in every demo. Then a branch ran a full day — roughly two hundred orders — and the cash-session close refused to balance. The physical cash in the drawer was correct, but the system total was off by a few rupiah, and our reconciliation gate blocks any close where counted cash and expected cash disagree.
The cause was ordinary IEEE-754 floating point. In binary, most decimal fractions cannot be represented exactly, so multiplying a price by zero point one one produces a value a hair above or below the true answer. One order is off by a fraction of a rupiah nobody would ever notice. Two hundred orders accumulate that error until it crosses a whole rupiah, and the day does not balance. A cashier cannot go home because binary cannot represent one tenth cleanly.
// The bug: float arithmetic in the browser
const price = 15000;
const taxRate = 0.11;
console.log(price * taxRate); // 1650.0000000000002
console.log(0.1 + 0.2); // 0.30000000000000004
// Summed across ~200 order lines, the fractional drift
// crossed a whole rupiah and the cash-session close failed.
// The fix: integer rupiah, tax rounded once, server-authoritative
function ppnLine(baseIdr: number): number {
// 11% PPN, banker-agnostic round to whole rupiah, integer in/out
return Math.round((baseIdr * 11) / 100);
}
ppnLine(15000); // 1650 (exact, deterministic, no float in storage)With the representation decided, the schema question is which Prisma scalar to use. There are three real candidates and they trade off differently. Decimal maps to Postgres numeric and is what most currency guides recommend, but Prisma represents it through the Decimal.js library, so every read hands you a Decimal object you must convert before arithmetic — friction we did not want when the value is always a whole rupiah anyway. BigInt maps to Postgres bigint and never overflows, but Prisma returns it as a JavaScript BigInt, which throws the moment you call JSON.stringify on it. Int maps to a four-byte Postgres integer and serializes to JSON for free.
We landed on Int for every stored money field — order line prices, payment amounts, kasbon advances, payroll figures. The reason is range. A four-byte signed integer tops out just above two point one billion, and no single carwash line item, payment, or employee cash advance comes anywhere near two billion rupiah. Where BigInt earns its place is aggregation: a branch's lifetime revenue summed across years can exceed the Int ceiling. Postgres already solves this — SUM over an integer column returns bigint automatically — so our report aggregations receive a bigint from the database and we convert it to a string at the API boundary rather than storing BigInt in every row.
| Type | Postgres / range | Fit for JID Carwash |
|---|---|---|
| Float / Double | Binary IEEE-754, inexact | Never — the reconciliation bug |
| Int | 4 bytes, up to ~2.1 billion | Default for all stored money rows |
| BigInt | 8 bytes, effectively unbounded | Only for SUM aggregates in reports |
| Decimal | numeric, exact, Decimal.js in JS | Overkill when there are no sub-rupiah units |
If you ever choose BigInt in Prisma, remember it does not survive JSON.stringify — serializing a BigInt throws a TypeError. You must convert it to a string or a number at the API boundary. This alone is a good reason to keep stored money as Int and only meet BigInt where Postgres hands you one from a SUM.
Integers store cleanly but users want to see grouped thousands with a rupiah prefix. The rule we enforce is one-directional: format only at the very edge, for display, and never parse a formatted string back into a number. The number stays an integer from the database all the way to the render call; the pretty string is a dead end that no code reads back. Both the Next.js POS and the Flutter field app use the platform's own locale formatter so grouping matches Indonesian conventions.
// TypeScript (POS): format at the edge only
export function formatIdr(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0, // rupiah shows no decimals
}).format(amount);
}
formatIdr(15000); // "Rp 15.000"
// Dart (Flutter field app): same contract, same result
import 'package:intl/intl.dart';
final rupiah = NumberFormat.currency(
locale: 'id_ID', symbol: 'Rp ', decimalDigits: 0,
);
rupiah.format(15000); // "Rp 15.000"The practical takeaway is smaller than it sounds: pick the currency's real smallest unit, store it as an integer, do all arithmetic on integers server-side, round exactly once, and format only for display. In a rupiah system that unit is one rupiah, so there is not even a scaling factor to get wrong.