Mengapa Kami Menyimpan Uang sebagai Bilangan Bulat Rupiah, Bukan Float

Foto oleh Anis Eka via Openverse (CC BY 2.0)
Tipe floating-point memakai representasi biner IEEE-754, yang tidak bisa menyimpan sebagian besar pecahan desimal secara eksak. Galat kecil menumpuk di banyak perhitungan hingga total tidak lagi seimbang. Bilangan bulat itu eksak, jadi menyimpan uang dalam satuan terkecil mata uang sebagai bilangan bulat menghilangkan kejutan pembulatan sepenuhnya.
Rupiah secara teknis punya subunit bernama sen (seperseratus), tetapi inflasi memensiunkan semua koin dan uang kertas sen puluhan tahun lalu, jadi tak seorang pun membayar dengan pecahan rupiah dalam praktik. Artinya satu rupiah sudah merupakan satuan terkecil. Berbeda dengan sistem AS yang menyimpan dolar sebagai bilangan bulat sen, sistem rupiah menyimpan rupiah langsung sebagai bilangan bulat tanpa faktor penskalaan.
Untuk rupiah tanpa sub-satuan, Int adalah pilihan paling sederhana karena diserialkan ke JSON dengan bersih dan mencakup nilai hingga sekitar 2,1 miliar, yang melampaui baris mana pun. Gunakan BigInt hanya untuk agregat besar seperti pendapatan seumur hidup, di mana SUM Postgres sudah mengembalikan bigint. Decimal terbaik saat mata uang punya satuan pecahan yang harus dipertahankan, tetapi berlebihan untuk rupiah bulat.
Prisma mengembalikan kolom BigInt sebagai nilai BigInt JavaScript, dan memanggil JSON.stringify pada BigInt melempar TypeError. Anda harus mengubahnya menjadi string atau number di batas API sebelum mengembalikannya. Ini alasan kuat untuk menyimpan uang sebagai Int dan hanya menangani BigInt saat SUM Postgres menghasilkannya.
Format hanya di tepi paling ujung aplikasi, memakai pemformat sadar-lokal seperti Intl.NumberFormat dengan lokal id-ID di TypeScript atau NumberFormat.currency di Dart. Atur digit pecahan ke nol karena rupiah tidak menampilkan desimal. Jangan pernah mengurai string terformat kembali menjadi angka — bilangan bulat berjalan dari database ke pemanggilan render tanpa disentuh.

Foto oleh Anis Eka via Openverse (CC BY 2.0)
Ringkasan Utama
Simpan uang sebagai bilangan bulat rupiah, jangan pernah float atau double. Rupiah tidak punya sen dalam praktiknya, jadi satu IDR sudah merupakan satuan terkecil — tanpa penskalaan. Float membulatkan dalam biner dan bergeser pecahan rupiah di ratusan baris pesanan, yang merusak rekonsiliasi kas akhir hari. Bilangan bulat itu eksak.
Di JID Carwash ERP, setiap nilai uang di database adalah bilangan bulat rupiah biasa. Tidak ada kolom sen, tidak ada skala desimal, dan sama sekali tidak ada float atau double di jalur uang mana pun. Ini bukan preferensi gaya — ini invarian paling penting di seluruh basis kode, dan ada karena selisih tiga rupiah pernah membuat kasir tidak bisa pulang.
Indonesia membuat keputusan ini mudah. Rupiah secara teknis punya subunit bernama sen (seperseratus), tetapi inflasi puluhan tahun sudah lama memensiunkan setiap koin dan uang kertas sen. Hal terkecil yang benar-benar dibayar orang adalah satu rupiah. Pesanan cuci mobil diberi harga dalam ribuan penuh — cuci dasar lima belas ribu rupiah, bukan lima belas ribu lima puluh sen. Jadi sementara sistem AS menyimpan dolar sebagai bilangan bulat sen, kami menyimpan rupiah sebagai bilangan bulat rupiah. Satuan minor dan satuan mayor adalah satuan yang sama.
Versi pertama POS menghitung baris pajak PPN sebelas persen di peramban sebagai harga dikali nol koma satu satu, menyimpan hasilnya sebagai number JavaScript, lalu menjumlahkan angka-angka itu menjadi total pesanan. API menyimpan apa pun yang dikirim klien. Semuanya terlihat baik di setiap demo. Lalu sebuah cabang beroperasi sehari penuh — sekitar dua ratus pesanan — dan penutupan sesi kas menolak untuk seimbang. Uang tunai fisik di laci sudah benar, tetapi total sistem meleset beberapa rupiah, dan gerbang rekonsiliasi kami memblokir penutupan apa pun ketika kas terhitung dan kas yang diharapkan tidak cocok.
Penyebabnya adalah floating point IEEE-754 biasa. Dalam biner, sebagian besar pecahan desimal tidak bisa direpresentasikan secara eksak, jadi mengalikan harga dengan nol koma satu satu menghasilkan nilai sedikit di atas atau di bawah jawaban sebenarnya. Satu pesanan meleset pecahan rupiah yang tak akan pernah disadari siapa pun. Dua ratus pesanan mengakumulasi galat itu sampai melewati satu rupiah penuh, dan hari itu tidak seimbang. Seorang kasir tidak bisa pulang karena biner tidak bisa merepresentasikan sepersepuluh dengan bersih.
// 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)Setelah representasi diputuskan, pertanyaan skema adalah scalar Prisma mana yang dipakai. Ada tiga kandidat nyata dengan trade-off berbeda. Decimal memetakan ke numeric Postgres dan itulah yang direkomendasikan sebagian besar panduan mata uang, tetapi Prisma merepresentasikannya lewat pustaka Decimal.js, jadi setiap pembacaan memberi Anda objek Decimal yang harus dikonversi sebelum aritmetika — gesekan yang tidak kami inginkan ketika nilainya selalu rupiah bulat. BigInt memetakan ke bigint Postgres dan tidak pernah overflow, tetapi Prisma mengembalikannya sebagai BigInt JavaScript, yang melempar error saat Anda memanggil JSON.stringify padanya. Int memetakan ke integer empat byte Postgres dan diserialkan ke JSON secara gratis.
Kami memilih Int untuk setiap field uang yang disimpan — harga baris pesanan, jumlah pembayaran, kasbon, angka penggajian. Alasannya adalah rentang. Integer bertanda empat byte mentok tepat di atas dua koma satu miliar, dan tidak ada satu pun baris cuci mobil, pembayaran, atau kasbon karyawan yang mendekati dua miliar rupiah. Tempat BigInt berperan adalah agregasi: pendapatan seumur hidup sebuah cabang yang dijumlahkan lintas tahun bisa melewati plafon Int. Postgres sudah menyelesaikan ini — SUM atas kolom integer mengembalikan bigint secara otomatis — jadi agregasi laporan kami menerima bigint dari database dan kami mengubahnya menjadi string di batas API alih-alih menyimpan BigInt di setiap baris.
| Tipe | Postgres / rentang | Cocok untuk JID Carwash |
|---|---|---|
| Float / Double | Biner IEEE-754, tidak eksak | Tidak pernah — bug rekonsiliasi |
| Int | 4 byte, hingga ~2,1 miliar | Default untuk semua baris uang tersimpan |
| BigInt | 8 byte, praktis tak terbatas | Hanya untuk agregat SUM di laporan |
| Decimal | numeric, eksak, Decimal.js di JS | Berlebihan saat tak ada satuan sub-rupiah |
Jika Anda memilih BigInt di Prisma, ingat bahwa ia tidak selamat dari JSON.stringify — menserialkan BigInt melempar TypeError. Anda harus mengubahnya menjadi string atau number di batas API. Ini saja sudah alasan bagus untuk menyimpan uang sebagai Int dan hanya bertemu BigInt saat Postgres memberikannya dari SUM.
Bilangan bulat tersimpan bersih, tetapi pengguna ingin melihat ribuan yang dikelompokkan dengan awalan rupiah. Aturan yang kami tegakkan bersifat satu arah: format hanya di tepi paling ujung, untuk tampilan, dan jangan pernah mengurai string terformat kembali menjadi angka. Angka tetap bilangan bulat dari database sampai ke pemanggilan render; string cantik adalah jalan buntu yang tidak dibaca balik oleh kode mana pun. Baik POS Next.js maupun aplikasi lapangan Flutter memakai pemformat lokal platform sendiri agar pengelompokan sesuai konvensi Indonesia.
// 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"Kesimpulan praktisnya lebih kecil dari kedengarannya: pilih satuan terkecil nyata mata uang, simpan sebagai bilangan bulat, lakukan semua aritmetika pada bilangan bulat di sisi server, bulatkan tepat sekali, dan format hanya untuk tampilan. Dalam sistem rupiah, satuan itu adalah satu rupiah, jadi tidak ada bahkan faktor penskalaan yang bisa salah.