Ringkasan Utama
Simpan uang sebagai integer minor unit bila mata uangnya tidak punya subunit yang dipakai sungguhan dan tiap nilai tetap di bawah sekitar 2,1 miliar, seperti rupiah di point of sale. Pakai NUMERIC atau Prisma Decimal bila butuh angka desimal, banyak mata uang atau nilai lebih besar. Apa pun pilihannya, lakukan rounding sekali per komponen dan jauhkan float dari perhitungan.
Ada dua produk berbasis Postgres di meja saya yang berbeda pendapat soal apa itu satu rupiah. POS carwash menyimpan semua kolom uang sebagai Int biasa. ERP klien yang saya bangun untuk accounts receivable dan payable menyimpan kolom serupa sebagai Decimal dengan precision 15 dan scale 2. Keduanya berjalan di production, keduanya benar, dan perbedaannya bukan soal selera.
Tulisan ini membahas keputusannya, bukan alasan menolak float, yang sudah saya bahas di artikel terpisah. Saya membandingkan kedua desain dari sisi rounding, hitungan persentase, serialization dan multi-currency, memakai schema dan helper dari kedua codebase yang ditulis ulang sebagai contoh generik. Bila sebuah klaim adalah pilihan desain dan bukan hasil pengukuran, saya sebutkan. Saya tidak melakukan benchmark pada kedua desain.
Dua produk, dua keputusan penyimpanan
POS carwash menyimpan harga item order, pembayaran, kasbon dan angka payroll sebagai Int. Rupiah secara teori punya subunit, tetapi tidak ada yang membayar dengan itu, jadi minor unit dan major unit adalah unit yang sama dan tidak perlu scaling. ERP berbeda dalam dua hal: invoice-nya membawa kode mata uang, dan kolom uangnya memakai Decimal dengan precision 15 dan scale 2, sehingga ada ruang untuk nilai pecahan.
Alasan memilih integer ketimbang float sudah ditulis di kenapa kami menyimpan uang sebagai integer rupiah. Pertanyaan di sini lebih sempit dan lebih berguna: setelah float dicoret, tipe yang tepat itu hitungan integer dari minor unit, atau NUMERIC fixed-point? Jawabannya bergantung pada empat hal yang bisa Anda cek sebelum menulis migration.
- Apakah mata uangnya punya subunit yang benar-benar dipakai?
- Seberapa besar satu baris dan satu total bisa tumbuh?
- Berapa mata uang yang berbagi kolom yang sama?
- Siapa yang memakai angka itu: kode Anda sendiri, atau client milik orang lain?
Apa wujud tiap pilihan di Postgres dan Prisma
Dua schema di bawah bersifat generik, tetapi bentuknya sama dengan yang asli. Versi integer menyimpan hitungan. Versi Decimal menyimpan angka fixed-point dengan scale yang dideklarasikan, dan membawa rate yang menghasilkan pajaknya supaya perubahan master data di kemudian hari tidak menulis ulang sejarah.
// Product A: integer rupiah. Minor unit == major unit, so no scaling.
model OrderLine {
id String @id @default(uuid())
unitPrice Int // postgres integer: 4 bytes, max 2147483647
quantity Int
lineTotal Int // computed and rounded in code BEFORE it is stored
}
// Product B: fixed-point NUMERIC with a currency code beside it.
model InvoiceLine {
id String @id @default(uuid())
subtotal Decimal @db.Decimal(15, 2) // 13 integer digits + 2 decimals
taxRate Decimal @db.Decimal(5, 2) // snapshot of the rate USED
taxAmount Decimal @db.Decimal(15, 2)
totalAmount Decimal @db.Decimal(15, 2)
currency String @default("IDR")
}
// Without @db.Decimal(p, s), Prisma maps Decimal to decimal(65,30).Angkanya penting. Dokumentasi PostgreSQL menyebut integer berukuran 4 byte dengan rentang minus 2147483648 sampai plus 2147483647, dan bigint berukuran 8 byte. NUMERIC dengan precision 15 dan scale 2 menampung 13 digit sebelum titik desimal dan 2 sesudahnya. Prisma memetakan Int ke integer dan mengembalikan number biasa, memetakan Decimal ke objek decimal.js, dan memetakan BigInt ke BigInt JavaScript. Kalau Decimal tidak diberi native type, Prisma memakai default decimal(65,30), yang hampir tidak pernah Anda inginkan untuk uang.
Integer overflow bukan galat pembulatan, melainkan exception. PostgreSQL melempar error untuk nilai di luar rentang tipenya, jadi checkout yang aman bertahun-tahun bisa gagal di hari satu agregat melewati 2147483647. Batasi nilainya di layer validasi supaya kegagalannya berupa 400 yang rapi, bukan 500.
Rounding dan hitungan persentase
Rounding adalah titik di mana kedua desain benar-benar berbeda. Dengan integer, database tidak pernah membulatkan apa pun: saat nilai sampai di kolom, kode Anda sudah memutuskan jumlah unit utuhnya. Dengan NUMERIC, database bisa membulatkan, dan PostgreSQL mendokumentasikan bahwa numeric membulatkan nilai tengah menjauhi nol sedangkan double precision membulatkan ke genap terdekat di sebagian besar mesin. Perbedaan itu saja sudah cukup alasan untuk tidak membiarkan float menyentuh langkah rounding.
Hitungan persentase adalah masalah yang sama di kedua desain: kalikan basis dengan rate, lalu bulatkan. Ada tiga aturan yang saya bawa dari kedua codebase. Simpan rate di baris yang memakainya, supaya mengubah master rate tidak menulis ulang dokumen yang sudah diposting. Hitung dan bulatkan tiap komponen sendiri-sendiri lalu jumlahkan, jangan menjumlahkan bagian yang belum dibulatkan lalu membulatkan sekali di akhir. Dan sediakan tepat satu fungsi rounding yang dipanggil semua jalur, supaya layar accounts payable dan accounts receivable tidak bisa berbeda satu unit.
// Wrong: a float pipeline feeding a Decimal column.
const tax = Number(line.subtotal) * Number(line.taxRate) / 100;
const stored = Math.round(tax); // a true .5 can arrive as 5.4999999999999996
// Right (integers): one primitive, multiply first, round once per component.
export function roundUnit(x: number): number {
const eps = Math.abs(x) * Number.EPSILON + 1e-9; // guard for float artefacts
return Math.floor(x + 0.5 + eps); // half up
}
export const percentOf = (base: number, rate: number) =>
roundUnit((base * rate) / 100);
// Right (Decimal end to end): never touch Number.
import { Prisma } from "@prisma/client";
const taxD = new Prisma.Decimal(line.subtotal)
.mul(line.taxRate)
.div(100)
.toDecimalPlaces(0, Prisma.Decimal.ROUND_HALF_UP); // rm 4 = ROUND_HALF_UPKalau total yang tersimpan murni turunan dari kolom lain, database juga bisa memilikinya; generated column untuk data hasil hitung membahas kapan itu ide yang bagus.
Kalikan dulu sebelum membagi. Menghitung basis kali rate lebih dulu dan membagi seratus paling akhir menjaga nilai antara sepresisi yang diizinkan tipenya, alih-alih membulatkan pecahan terlalu dini.
Serialization: di mana Decimal merepotkan
Int keluar dari Prisma sebagai number dan masuk ke JSON tanpa diubah. Decimal keluar sebagai objek decimal.js. toJSON-nya mengembalikan string, jadi response yang naif mengirim uang sebagai string, dan setiap client harus tahu itu. decimal.js juga tidak mempertahankan nol di belakang, sehingga nilai yang tersimpan dengan dua desimal bisa menjadi string tanpa nol itu kecuali Anda memanggil toFixed. Tetapkan string dengan scale tetap sebagai kontrak API, atau konversi secara eksplisit di satu tempat di batas aplikasi.
// Int: a number, serialises untouched.
res.json({ total: order.lineTotal });
// Decimal: toJSON() yields a string, and decimal.js drops trailing zeros.
res.json({ total: invoice.totalAmount.toFixed(2) }); // always a string
// BigInt (e.g. SUM over an integer column via a raw query):
JSON.stringify({ revenue: 10n }); // TypeError: BigInt cannot be serialised
JSON.stringify({ revenue: 10n }, (_k, v) =>
typeof v === "bigint" ? v.toString() : v,
); // replacer converts at the boundaryBigInt punya jebakan sendiri. MDN mendokumentasikan bahwa JSON.stringify pada nilai BigInt apa pun melempar TypeError, dan dokumentasi Prisma menunjukkan kegagalan yang sama dengan replacer function sebagai jalan keluarnya. Ini penting bahkan di desain integer, karena sum pada kolom integer di PostgreSQL mengembalikan bigint, sehingga report berbasis raw query memberi Anda BigInt walau semua kolom tersimpan adalah Int. Saya mengubahnya jadi string di batas API, bukan menyimpan BigInt di setiap baris.
Decimal juga merepotkan pada perbandingan biasa. Helper audit diff saya harus menormalkan Decimal dan number ke bentuk yang sama sebelum bisa bilang apakah sebuah field berubah, kalau tidak, nominal yang tidak berubah muncul sebagai edit. Integer dibandingkan dengan tanda sama dengan tiga kali dan tidak butuh itu.
Multi-currency mengubah jawabannya
Integer minor unit bekerja bersih untuk rupiah karena minor unit sama dengan major unit. Begitu mata uang kedua berbagi kolom, integer telanjang jadi ambigu: apakah nilai tersimpan itu hitungan unit utuh atau per seratus? Anda butuh eksponen per mata uang, disimpan atau dicari, dan setiap jalur tampilan dan konversi harus menerapkannya. ERP menghindari itu dengan menyimpan kode mata uang di samping nominal dengan scale 2 yang tetap.
Itu pun tidak gratis: scale dua mengasumsikan setiap mata uang yang Anda terima punya dua angka desimal. Untuk sisi akuntansi konversi, tabel kurs dan jurnal selisih kurs, lihat akuntansi multi-currency di ERP. Aturan saya: sistem satu mata uang dengan unit kasar layak memakai integer, dan apa pun yang mungkin menjadi multi-currency sebaiknya mulai dari NUMERIC.
Tabel keputusan
Ini perbandingan yang akan saya berikan ke rekan satu tim. Ini perbandingan desain, bukan benchmark: dokumentasi PostgreSQL sendiri menyebut kalkulasi numeric lambat dibanding integer, tetapi saya belum mengukur selisihnya di kedua produk dan tidak akan menyebut besarnya.
| Aspek | Integer minor unit (Int) | NUMERIC / Prisma Decimal |
|---|---|---|
| Tipe di Prisma | Int, dikembalikan sebagai number biasa | Decimal dengan native precision dan scale, dikembalikan sebagai objek decimal.js |
| Rentang | Sekitar 2,1 miliar per baris untuk integer 4 byte; sum butuh bigint | Sesuai precision yang dideklarasikan; 13 digit bulat pada precision 15, scale 2 |
| Rounding | Sepenuhnya kode Anda, sebelum nilai disimpan | Kode Anda atau SQL; numeric membulatkan nilai tengah menjauhi nol |
| Hitungan persentase | Kali lalu bagi dan bulatkan di kode aplikasi | Aritmetika Decimal di kode atau numeric di SQL, rate disimpan di kolom sendiri |
| JSON dan API | Berupa number, gratis; agregat datang sebagai bigint dan perlu dikonversi ke string | Diserialisasi sebagai string; client harus mem-parse dan Anda harus menetapkan scale |
| Multi-currency | Butuh eksponen per mata uang di mana-mana | Satu scale tetap plus kode mata uang, bila semua mata uang berbagi scale |
| Perbandingan dan diff | Equality biasa | Normalkan dulu sebelum membandingkan Decimal dengan number |
| Paling cocok untuk | Satu mata uang tanpa subunit praktis, nilai terbatas, client milik sendiri | Pecahan, banyak mata uang, total besar, integrasi pihak ketiga |
Kalau kolom Anda multi-currency sekaligus besar, tabel ini bukan pilihan yang tipis. Kalau single-currency dan kecil, integer menang di kesederhanaan, dan penghematannya terasa di setiap query, serializer dan test yang tidak perlu Anda tulis.
Kegagalan yang ingin saya peringatkan: penyimpanan Decimal, aritmetika float
Membaca schema dan helper ERP berdampingan mengajarkan hal yang kurang enak. Kolomnya Decimal, DTO membatasi nilai maksimum sesuai kapasitas Decimal(15,2), namun helper pajaknya menghitung dengan number JavaScript biasa lalu membulatkan ke unit utuh. Kolom Decimal menjamin apa yang disimpan, bukan bagaimana nilainya dihitung.
Helper itu membawa epsilon guard kecil, dan komentarnya sendiri mencatat alasannya: hasil yang secara matematis tepat setengah ternyata muncul sebagai 5.4999999999999996, dan pembulatan biasa akan memotongnya. Guard itu bekerja, tetapi saya tetap lebih suka tidak membutuhkannya. Ini pengamatan desain dari membaca kode, bukan laporan insiden; saya tidak mengklaim itu pernah menghasilkan invoice yang salah.
Memilih NUMERIC tidak otomatis membuat perhitungan Anda desimal. Kalau nilai melewati Number sebelum dibulatkan, yang Anda punya adalah kolom fixed-point yang disuplai pipeline floating-point. Jaga aritmetika tetap di integer atau di objek Decimal dari ujung ke ujung.
Yang akan saya ubah, dan checklist hari Senin
Batasan dulu. Kedua desain adalah keputusan dari proyek saya sendiri, bukan survei seluruh industri. Saya tidak punya angka performa, saya belum menjalankan produk integer dengan mata uang kedua, dan epsilon guard di atas adalah hal yang akan saya ganti dengan aritmetika Decimal atau integer bila saya memulai ERP dari awal.
Pada hari Senin, dalam urutan yang akan saya kerjakan:
- Tulis mata uangnya, nilai tunggal terbesar dan agregat terbesar yang Anda perkirakan, lalu bandingkan dengan rentang tipenya.
- Pilih satu fungsi rounding dan jadikan satu-satunya cara menghasilkan nominal tersimpan; hapus semua pemanggilan primitif rounding yang lain.
- Simpan rate di samping setiap persentase hasil hitung supaya perubahan master data tidak menulis ulang baris yang sudah diposting.
- Tentukan kontrak JSON untuk uang, number atau string, dan tulis test yang gagal bila bigint atau objek Decimal sampai ke response.
- Tambahkan batas validasi di DTO yang sama dengan limit kolom, supaya overflow menjadi 400 dan bukan 500.
Pilih representasi dari mata uang dan pemakainya, lalu tegakkan dari ujung ke ujung. Integer menang bila unitnya kasar, rentangnya terbatas dan Anda memiliki client-nya. NUMERIC menang bila ada pecahan, banyak mata uang atau integrasi milik orang lain. Apa pun pilihannya, satu fungsi rounding dan tidak ada float di pipeline lebih penting daripada tipenya.

