Bagaimana Saya Menjaga Satu Konvensi Uang di Semua Sistem Saya
On this page
- Pendahuluan
- A. Satu nominal, banyak lapisan
- B. Kenapa harus satu konvensi
- C. Pendekatan yang saya tolak
- D. Tiga belas hambatan yang menghalangi
- Metode
- A. Integer, karena ia selamat di setiap lapisan
- B. Sufiks yang menyatakan skala
- C. Skalanya dari ISO 8583, dan ini bukan pembulatan
- D. Persentase dalam basis point, dibulatkan di satu tempat
- E. Tipe yang tak bisa tertukar dengan angka
- F. Browser yang mengonversi, server tak pernah menebak
- G. File impor dinormalkan di server
- H. File ekspor kembali ke angka yang terbaca
- I. Field form berbicara rupiah, nilainya tetap integer
- J. Jangkauannya diketahui dan dicatat
- K. Dokumen cetak diformat dari integer
- L. Data lama dikonversi sekali, saat impor
- M. Angka yang bukan uang tetap bertipe sendiri
- Hasil dan Pembahasan
- A. Yang diwarisi module baru
- B. Harga yang dibayar
- C. Di mana konvensi ini cocok
- Simpulan
- Istilah
Pendahuluan
A. Satu nominal, banyak lapisan
Hampir setiap sistem yang saya bangun mengurus uang di salah satu bagiannya. Sistem order menjumlahkan harga item, ongkos kirim, dan diskon. Service laporan penjualan mencocokkan uang yang dicairkan marketplace dengan uang yang dibayar pembeli. Sistem kepegawaian menyimpan tabel gaji dan mencetak surat keputusan yang menyebut gaji pokok baru seorang pegawai dalam angka dan huruf. Sistem-sistem itu ditulis dalam bahasa yang berbeda — Rust untuk sebagian service, PHP untuk yang lain, TypeScript di browser — dan setiap nominal melewati beberapa lapisan dalam perjalanannya: kolom database, perhitungan domain, field JSON di respons API, field form yang diketik orang, spreadsheet hasil ekspor, dan dokumen cetak.
Tiap lapisan punya pengertian sendiri tentang angka. Database bisa menyimpan desimal fixed-point; Rust punya integer berbagai ukuran dan bilangan floating point; PHP diam-diam mengubah integer besar menjadi float; JSON cuma punya satu tipe angka yang dibaca kebanyakan client sebagai float 64-bit; JavaScript pun hanya punya float itu kecuali diminta lain. Nominal yang persis di satu lapisan bisa berhenti persis di lapisan berikutnya tanpa tanda apa pun. Kesalahannya baru terlihat berminggu-minggu kemudian, sebagai total yang meleset satu sen atau laporan yang baris-barisnya tak cocok dengan angka di kakinya sendiri.
Tantangannya adalah menjaga satu konvensi uang di semua sistem saya: setiap nominal disimpan, dihitung, dikirim, dan ditampilkan secara persis, dalam bentuk yang sama di setiap bahasa dan setiap lapisan, sehingga module mana pun yang saya bangun bisa mengandalkannya tanpa memutuskannya ulang.
Kedua bagian kalimat itu sama pentingnya. "Persis" berarti tak satu lapisan pun boleh menghilangkan atau menambahkan pecahan rupiah, kapan pun. "Bentuk yang sama di mana-mana" berarti developer — biasanya saya sendiri, beberapa bulan kemudian — yang membaca field di database, respons API, atau tes langsung tahu arti angkanya dan cara memperlakukannya, tanpa membuka dokumentasi module itu. Konvensi yang hanya diikuti satu module belum layak disebut konvensi; ia cuma kebiasaan setempat yang bisa dibantah module berikutnya.
B. Kenapa harus satu konvensi
Uang melintasi batas module lebih sering daripada hampir semua nilai lain. Order yang dibuat di satu service dilaporkan service kedua, ditagihkan service ketiga, dan ditampilkan di browser oleh yang keempat. Kalau masing-masing memilih bentuknya sendiri — satu menyimpan rupiah berkoma, satu lagi rupiah utuh, yang ketiga sen — setiap batas butuh konversi, dan setiap konversi adalah tempat faktor seratus bisa diterapkan dua kali atau malah terlewat. Akibatnya mudah dibayangkan: nominal seratus kali terlalu besar di satu layar dan benar di layar lain, karena dua module berbeda pendapat soal skala dan datanya tak menunjukkan mana yang benar.
Karena itu saya memutuskannya sekali, menuliskannya sebagai aturan yang berlaku di semua sistem yang saya bangun, dan memperlakukannya sebagai bagian dari cara saya memulai module, bukan pilihan yang dibuat ulang tiap module. Aturannya saya pinjam dari industri pembayaran, yang menghadapi persoalan serupa puluhan tahun lalu dan membakukannya dalam standar.
C. Pendekatan yang saya tolak
Pendekatan paling umum adalah floating point: kolom float atau double, float di kode, float di JSON. Saya menolaknya karena bilangan floating point biner tak bisa menyimpan sebagian besar pecahan desimal secara persis. Contoh klasiknya: 0.1 + 0.2 di floating point tidak sama dengan 0.3, tetapi 0.30000000000000004. Untuk satu nominal selisihnya tak kelihatan. Dijumlahkan dari ribuan baris order, dibandingkan kesamaannya, atau dikalikan persentase, selisih itu berubah menjadi satu sen yang salah.
Pendekatan kedua adalah tipe desimal fixed-point: DECIMAL(15,2) di database dan library desimal di kode. Di dalam database ia persis, dan saya masih memakai desimal untuk besaran yang bukan uang. Sebagai konvensi uang saya menolaknya, karena ia tak selamat di perjalanan. Driver database sering menyerahkan desimal ke kode sebagai string; JSON tak punya tipe desimal, jadi nilainya keluar dari API sebagai string yang wajib di-parse setiap client, atau sebagai angka yang dibaca browser sebagai float. Ketepatan yang berhenti di batas API bukan ketepatan yang saya butuhkan.
Yang saya pilih adalah cara dunia pembayaran kartu: nominal adalah integer, yaitu hitungan satuan terkecil mata uangnya. Integer persis di setiap database, setiap bahasa, dan setiap parser JSON yang saya pakai, jadi hanya bentuk inilah yang tetap persis melewati semua lapisan.
D. Tiga belas hambatan yang menghalangi
Gampang dirumuskan, canggung dibangun. Ada tiga belas hal di antara "simpan uang sebagai integer" dan konvensi yang diikuti setiap sistem tanpa kejutan.
Pertama, floating point tak bisa menyimpan nominal desimal secara persis. Float saya tolak, begitu pula kolom desimal, karena ia menjadi float atau string begitu melintasi JSON.
Kedua, skala integer tak terlihat. Angka 150000 bisa berarti rupiah atau seperseratus rupiah, dan mengandalkan dokumentasi untuk membedakannya saya tolak.
Ketiga, skalanya harus standar, bukan karangan saya, dan harus jelas bahwa ini bukan pembulatan. Skala buatan sendiri per module saya tolak.
Keempat, persentase menghasilkan pecahan. Diskon 2,5 persen dari nominal ganjil tak jatuh di satuan utuh, dan mengubahnya ke float demi perkalian saya tolak.
Kelima, nominal bisa tertukar dengan angka biasa di antara lapisan. Mengoper uang sebagai integer telanjang saya tolak, karena jumlah barang atau ID punya tipe yang sama.
Keenam, harus diputuskan sisi mana yang mengonversi. Membiarkan browser dan server sama-sama mengalikan "biar aman" saya tolak; justru begitulah nominal menjadi seratus kali terlalu besar.
Ketujuh, file yang diunggah orang memakai format angka lokal. Spreadsheet bisa bertuliskan 2.000,35 atau 2000.35 atau Rp 1.250,50, dan mem-parse-nya sebagai float saya tolak.
Kedelapan, file ekspor harus bisa dibaca manusia. Mengekspor integer mentah saya tolak, karena tak ada yang membaca 37500050 sebagai tiga ratus tujuh puluh lima ribu rupiah lima puluh sen.
Kesembilan, orang mengetik nominal seperti cara mereka menulisnya. Field form yang meminta seperseratus rupiah saya tolak.
Kesepuluh, jangkauan integernya. Menganggap setiap lapisan sanggup menampung integer berapa pun saya tolak, karena browser tidak sanggup.
Kesebelas, dokumen cetak menyebut nominal dalam angka dan huruf. Memformatnya lewat float saya tolak.
Kedua belas, data dari sistem lama tersimpan dalam rupiah utuh. Mengonversinya belakangan, di mana pun data itu kebetulan dibaca, saya tolak.
Ketiga belas, tidak semua angka adalah uang. Menerapkan aturan ini ke setiap nilai desimal saya tolak, karena aturannya akan kehilangan makna.
Urutannya mengikuti perjalanan hidup satu nilai. Tiga yang pertama menetapkan apa itu nominal; keempat dan kelima mengatur cara ia dihitung dan dioper; keenam sampai kesembilan mengurus tepi tempat nominal masuk dan keluar sistem; empat terakhir adalah batas dan pengecualian yang menjaga aturan tetap jujur. Definisi yang keliru membuat semua jawaban sesudahnya keliru, karena itulah ia datang pertama.
Metode
A. Integer, karena ia selamat di setiap lapisan
Inilah jawaban atas hambatan pertama: setiap nominal, di setiap sistem yang saya bangun, adalah bilangan bulat. Kolom database-nya integer 64-bit, nilai domainnya integer 64-bit, dan field JSON-nya integer. Penjumlahan, pengurangan, dan perbandingan integer selalu persis, jadi jumlah sepuluh ribu baris order sama di database, di service, dan di browser, dan dua nominal yang semestinya sama memang sama.
(
id BIGSERIAL PRIMARY KEY,
quantity INTEGER NOT NULL,
unit_price_iso8583 BIGINT NOT NULL, -- amount × 100
line_total_iso8583 BIGINT NOT NULL -- quantity × unit price, exact
);Tipe desimal tetap saya pakai untuk keunggulannya — di dalam database, untuk nilai yang bukan uang — tetapi uang sendiri tak pernah menjadi desimal atau float, di lapisan mana pun, kapan pun.
B. Sufiks yang menyatakan skala
Integer saja tak memberi tahu apa yang dihitungnya, jadi skalanya saya jadikan bagian dari nama. Setiap kolom database dan field API yang berisi uang berakhiran _iso8583: price_iso8583, shipping_fee_iso8583, total_iso8583. Siapa pun yang membaca baris tabel, log, atau respons JSON langsung tahu angkanya dalam seperseratus rupiah tanpa mencari keterangan, dan field tanpa sufiks itu, menurut aturan yang sama, bukan uang.
Sufiksnya sengaja tak lazim. Nama umum seperti amount_cents memang enak dibaca, tetapi juga mudah terlupa, dan module yang berbeda lama-lama akan memakai ejaan berbeda. Satu sufiks yang agak janggal mudah dicari di semua repository saya, dan review cukup mengajukan satu pertanyaan untuk setiap field uang: apakah berakhiran _iso8583?
C. Skalanya dari ISO 8583, dan ini bukan pembulatan
ISO 8583 adalah standar internasional untuk pesan yang dipertukarkan saat pembayaran kartu diotorisasi. Standar ini membawa nominal transaksi sebagai bilangan bulat dalam satuan terkecil mata uangnya, dan jumlah angka desimal tiap mata uang diambil dari ISO 4217, daftar baku kode mata uang. Rupiah tercatat dengan dua angka di belakang koma, jadi dalam konvensi ini nominal dikali seratus saat disimpan dan dibagi seratus saat ditampilkan: Rp2.785.700,00 disimpan sebagai 278570000.
Ini bukan pembulatan. Pembulatan membuang angka; konversi ini menyimpannya. Dua angka di belakang koma tetap ada, ditulis ke dalam integer dan bukan sesudah pemisah, dan pembagian dengan seratus mengembalikan nominal yang persis sama dengan yang masuk. Tujuannya seperti yang sudah diuraikan: aritmetika integer persis, sedangkan aritmetika floating point pada pecahan tidak.
Meminjam skala dari standar ketimbang mengarang sendiri punya manfaat praktis. Aturannya tak perlu dibela ulang di tiap project, ia cocok dengan cara payment gateway bertukar nominal, dan mata uang dengan jumlah desimal lain cukup mengikuti standar yang sama tanpa kesepakatan pribadi baru.
D. Persentase dalam basis point, dibulatkan di satu tempat
Persentase dari integer biasanya bukan integer. Saya menyatakan persentase dalam basis point — satu persen sama dengan 100 basis point, jadi 2,5 persen adalah 250 — sehingga persentasenya sendiri tetap integer. Perkaliannya berlangsung di integer 128-bit supaya nominal besar dikali tarif besar tak meluap, dan pembagian kembali ke satuan terkecil dibulatkan setengah ke atas.
pub const BASIS_POINT_DIVISOR: i64 = 10_000;
Fungsi ini satu-satunya tempat uang dibulatkan. Saya memilih half-up supaya hasilnya konsisten dengan pembulatan dua desimal yang berlaku sebelum nominal menjadi integer; mengganti aturan pembulatan sekarang akan mengubah total masa lalu. Karena pembulatan hanya terjadi di satu fungsi, aturannya terlihat, teruji, dan mustahil terterapkan dua kali tanpa sengaja.
E. Tipe yang tak bisa tertukar dengan angka
Di Rust, yang sistem tipenya bisa membawa makna, nominal dibungkus tipenya sendiri: newtype di sekeliling integer 64-bit. Fungsi yang meminta uang tak mau menerima jumlah barang atau ID, dan compiler menolak kekeliruan itu sebelum programnya sempat berjalan.
/// Money in minor units (amount × 100).
;
Di PHP, yang tak punya tipe semacam itu, saya bersandar pada sufiks dan pada cast kolom uang ke integer di model, sehingga nilai yang dibaca dari database tak pernah berupa string atau float saat kode menyentuhnya. Perlindungannya lebih lemah daripada compiler, tetapi aturan penamaan membuat kekeliruan yang tersisa kelihatan saat review.
F. Browser yang mengonversi, server tak pernah menebak
Konversi antara "yang dilihat orang" dan "yang disimpan sistem" terjadi di tepat satu tempat: antarmuka user. Ia mengalikan seratus sebelum mengirim nominal dan membagi seratus saat menampilkannya. API hanya menerima dan mengembalikan integer; ia tak pernah menerima pecahan untuk ditafsirkan.
Dengan menaruh konversi di tepi, dan hanya di sana, setiap service di belakang API bekerja dengan satu bentuk. Tak ada service yang perlu menduga apakah nominal yang masuk sudah dikonversi, dan nominal yang dikali dua kali oleh dua lapisan yang sama-sama berniat baik tak mungkin muncul.
G. File impor dinormalkan di server
File adalah pengecualian dari "browser yang mengonversi". Saat orang mengunggah spreadsheet hasil ekspor marketplace atau bank, mereka mengunggahnya apa adanya, dan nominal di dalamnya tertulis dalam format lokal apa pun yang menghasilkan file itu. Server yang menormalkannya: membuang simbol mata uang dan spasi, menganggap koma sebagai pemisah desimal bila ada, membaca titik yang diikuti tepat dua angka sebagai titik desimal, membaca titik lainnya sebagai pemisah ribuan, lalu mem-parse hasilnya dengan tipe desimal, bukan float, sebelum dikali seratus.
// "2.000,35" (id) and "2000.35" (en) both become 200035; "123.000" is thousands
let normalized = if value.contains else if matches! else ;
let amount: i64 = .round.try_into?;Aturan untuk kasus yang ambigu adalah keputusan, bukan temuan: dalam rupiah, tiga angka sesudah titik hampir selalu berarti ribuan, dan dua angka hampir selalu berarti sen. Normalizer-nya dilindungi tes yang mendaftar setiap format yang diterimanya, jadi keputusan itu tertulis di tempat yang bisa diperiksa.
H. File ekspor kembali ke angka yang terbaca
Tepi sebaliknya adalah ekspor. Spreadsheet untuk manusia mengubah setiap integer kembali menjadi angka desimal tanpa nol berbuntut: 37500000 menjadi 375000, dan 37500050 menjadi 375000.50. Konversinya memakai pembagian bulat dan sisa bagi, jadi teks di file persis sama dengan nominal tersimpan, bukan perkiraannya.
I. Field form berbicara rupiah, nilainya tetap integer
Orang mengetik nominal seperti cara mereka menulisnya: 1.486.500,50, dengan titik di antara ribuan dan koma sebelum sen. Field input uang di antarmuka saya memformat teksnya selagi diketik dan menjaga nilai yang terikat padanya tetap integer, yaitu hitungan seperseratus rupiah. Orangnya tak pernah melihat integer itu, dan kodenya tak pernah melihat teks yang sudah diformat.
/** "1.486.500,5" → 148650050; empty → null */
const parse = (value: string): number | null => {
const [whole = '', fraction = ''] = value.replace(/[^\d,]/g, '').split(',')
if (!whole && !fraction) return null
return Number(whole || '0') * 100 + Number((fraction + '00').slice(0, 2))
}Karena field-nya mem-parse digit dan tidak mengonversi float, mengetik lima puluh sen menghasilkan tepat 50, tak pernah 49.99999.
J. Jangkauannya diketahui dan dicatat
Di server, nominal adalah integer 64-bit bertanda, yang menampung lebih dari sembilan puluh kuadriliun rupiah dalam satuan terkecil — jauh melampaui nominal apa pun yang akan ditemui sistem-sistem ini. Lapisan yang lebih sempit adalah browser: angka JavaScript hanya menyimpan integer secara persis sampai 253 − 1, yang dalam satuan terkecil kira-kira sembilan puluh triliun rupiah. Batas itu masih jauh di atas satu order, satu gaji, atau satu laporan bulanan yang saya tangani, jadi saya menerimanya dan tak mengirim nominal sebagai string, lalu mencatatnya dalam konvensi supaya module masa depan yang jumlahnya lebih besar tahu di mana batasnya.
K. Dokumen cetak diformat dari integer
Dokumen resmi menyebut nominal dua kali: dalam angka dan dalam huruf. Keduanya dihasilkan dari integer dengan pembagian bulat, tak pernah lewat float, sehingga angka dan hurufnya tak mungkin berselisih satu sama lain ataupun dengan nilai tersimpan.
L. Data lama dikonversi sekali, saat impor
Sistem lama yang datanya saya migrasikan biasanya menyimpan rupiah utuh. Nominalnya dikali seratus tepat sekali, di langkah impor yang menyalinnya ke skema baru, dan tidak di tempat lain. Sesudah impor hanya ada satu bentuk di sistem baru, dan kode yang membaca tak perlu tahu bahwa sebagian baris berasal dari tempat lain.
M. Angka yang bukan uang tetap bertipe sendiri
Konvensi ini hanya untuk uang. Rasio seperti tingkat repeat order atau tingkat persetujuan, dan besaran fisik seperti berat paket, tetap berupa nilai desimal tanpa sufiks. Menjauhkan mereka dari aturan inilah yang menjaga sufiks tetap bermakna: kalau setiap angka desimal berakhiran _iso8583, sufiks itu berhenti menandai bahwa nilainya uang.
Hasil dan Pembahasan
A. Yang diwarisi module baru
Saat saya memulai module baru, pertanyaan soal uang sudah terjawab. Kolomnya integer 64-bit bersufiks, persentase dalam basis point, pembulatan di satu fungsi, browser mengonversi di tepi, impor dan ekspor punya normalizer-nya, dan antarmukanya memakai field uang yang sama. Module itu tak butuh diskusi rancangan tentang uang, dan pembaca yang mengenal konvensinya dari satu sistem bisa membaca uang di sistem mana pun yang pernah saya bangun.
Konvensi ini juga mengubah cara saya me-review kode. Cacat uang biasanya tampak sebagai pelanggaran salah satu aturan ini — float di tempat integer, field tanpa sufiks, tempat kedua yang membulatkan — jadi review menjadi checklist pendek, bukan penyelidikan.
B. Harga yang dibayar
Harganya adalah konversi di setiap tepi manusia: form, ekspor, dokumen cetak. Masing-masing butuh kode yang tak diperlukan rancangan berbasis desimal, dan kode itu harus dites. Developer yang baru mengenal konvensinya juga harus belajar bahwa field berakhiran _iso8583 berisi seratus kali nominal yang terlihat. Kedua harga itu saya anggap kecil dibanding alternatifnya, yaitu golongan cacat yang tak terlihat sampai satu total tak cocok.
C. Di mana konvensi ini cocok
Konvensi ini cocok untuk sistem apa pun yang menjumlahkan, membandingkan, dan melaporkan nominal dalam mata uang dengan jumlah desimal tetap. Ia kurang cocok untuk nilai yang memang butuh presisi lebih halus dari satuan terkecil mata uang, misalnya kurs atau harga satuan yang dikutip sampai empat desimal; nilai seperti itu tetap desimal dan baru dikonversi ke satuan terkecil di titik ketika ia menjadi nominal yang dibayar seseorang.
Simpulan
Uang di setiap sistem yang saya bangun adalah integer, hitungan seperseratus rupiah, dinamai dengan sufiks _iso8583, dihitung dengan aritmetika integer, dibulatkan setengah ke atas di satu fungsi, dikonversi hanya di tepi tempat orang membaca dan mengetiknya, dan dibatasi pada nilai yang memang uang. Skalanya berasal dari ISO 8583 dan ISO 4217, jadi bukan karangan saya, dan ini bukan pembulatan: angka di belakang koma tetap disimpan, ditulis ke dalam integer.
Nilai aturan ini justru karena ia satu aturan. Setiap module yang mengikutinya bisa dibaca, digabungkan, dan di-review bersama module lain tanpa konversi di setiap batas, dan cacat uang yang paling umum — jumlah yang tak persis, nominal seratus kali terlalu besar — tak punya tempat untuk bermula.
Istilah
- Satuan terkecil
- Satuan terkecil mata uang, misalnya seperseratus rupiah; menyimpan nominal sebagai bilangan bulat satuan ini menjaga aritmetika tetap persis.
- ISO 8583
- Standar internasional untuk pesan pembayaran kartu, yang membawa nominal sebagai bilangan bulat dari satuan terkecil mata uang.
- ISO 4217
- Daftar baku kode mata uang, yang juga menetapkan jumlah angka desimal tiap mata uang; rupiah punya dua.
- Basis point
- Seperseratus dari satu persen; menyatakan persentase dalam basis point menjaganya tetap bilangan bulat.
- Newtype
- Tipe tersendiri yang membungkus tipe lain, misalnya integer, sehingga compiler menolak mencampurnya dengan nilai biasa dari tipe yang dibungkus.
- Pembulatan setengah ke atas
- Aturan pembulatan yang membulatkan sisa tepat setengah ke nilai yang lebih besar.
Technologies covered
- PHP
- Rust
- TypeScript
- Vue
- ISO 8583