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 reconciliation bug that forced the rule
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)Prisma: Int, BigInt, or Decimal?
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.
Formatting for display without ever parsing back
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"Where the invariant is enforced
- The shared TypeScript contract types every money field as number, and the Prisma schema types every stored money column as Int — no Float scalar exists in the money path at all.
- Tax and totals are computed server-side in the settlement service, rounded to whole rupiah exactly once, so the client can never submit a drifted float total.
- Payment settlement is idempotent by key, so a retried request never double-writes an amount and never introduces a second rounding pass.
- The cash ledger is a derived union view, never a written table, so totals are always recomputed from integer source rows rather than a cached float that could rot.
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.

