Uang & pembulatan
Uang di NPY Engine selalu berupa bilangan bulat dalam satuan terkecil mata uang yang bersangkutan. Tidak ada bilangan pecahan di jalur uang — tidak di API, tidak di basis data, tidak di kode layanan mana pun. Halaman ini menjelaskan konsekuensinya bagi integrasi kamu.
Satuan terkecil dan eksponen
Setiap mata uang punya eksponen: jumlah angka di belakang koma pada penulisan lazimnya. Nilai amount_minor adalah nominal dikali 10eksponen.
| Mata uang | Eksponen | Satuan terkecil | Contoh |
|---|---|---|---|
| IDR | 0 | 1 rupiah | 150000 = Rp150.000 |
| USD | 2 | 1 sen | 150000 = USD 1.500,00 |
| EUR | 2 | 1 sen | 2599 = EUR 25,99 |
| SGD | 2 | 1 sen | 4550 = SGD 45,50 |
| JPY | 0 | 1 yen | 1200 = JPY 1.200 |
Setiap respons yang memuat nominal juga memuat bidang exponent, sehingga sistem kamu tidak perlu menebak.
{
"amount_minor": 150000,
"currency": "IDR",
"exponent": 0
}
Kenapa jangan memakai bilangan pecahan
Tipe float dan double memakai basis dua. Sebagian besar pecahan desimal tidak dapat diwakili dengan tepat, sehingga muncul galat pembulatan yang menumpuk diam-diam.
>>> 0.1 + 0.2
0.30000000000000004
>>> 1_000_000 * 0.07 # biaya 7% dari Rp1.000.000
70000.00000000001
>>> sum(0.01 for _ in range(100))
1.0000000000000007
Selisih sekecil itu tidak terlihat pada satu transaksi, tetapi pada jutaan baris jurnal ia membuat neraca tidak seimbang dan rekonsiliasi tidak pernah menutup. Karena itu jalur uang di NPY Engine memakai bilangan bulat 64-bit: BIGINT di basis data, long di Java, int di Python, int64 di Go.
Catatan untuk JavaScript
JavaScript hanya punya satu tipe angka dan bilangan bulat aman maksimalnya adalah 9.007.199.254.740.991. Nilai itu masih jauh di atas nominal transaksi wajar, jadi Number aman untuk satu pembayaran. Tetapi untuk penjumlahan besar — total settlement satu tahun, misalnya — gunakan BigInt. Yang tidak boleh dilakukan adalah membagi dengan 100 lalu menyimpan hasilnya.
// Salah: presisi hilang begitu dibagi
const rupiah = payment.amount_minor / 100; // IDR tidak dibagi 100!
const total = items.reduce((a, i) => a + i.price / 100, 0);
// Benar: tetap bilangan bulat, pembagian hanya saat menampilkan
const totalMinor = items.reduce((a, i) => a + i.price_minor, 0);
element.textContent = formatMoney(totalMinor, 'IDR');
Aturan pembulatan
Biaya dan bagi hasil dihitung dalam bilangan bulat, lalu dibulatkan ke satuan terkecil dengan setengah dibulatkan ke atas (half-up). Perhitungan dilakukan pada nominal minor, bukan pada nilai desimal.
# Biaya 1,4% dari Rp150.000, dibulatkan ke rupiah terdekat (half-up)
gross_minor = 150_000
fee_bps = 140 # basis poin: 1,4% = 140 bps
fee_minor = (gross_minor * fee_bps + 5_000) // 10_000
# = (150000 * 140 + 5000) // 10000 = 2100
net_minor = gross_minor - fee_minor # 147_900
- Tarif disimpan dalam basis poin (1 bps = 0,01%) agar tetap berupa bilangan bulat.
- Pembulatan dilakukan satu kali, pada hasil akhir. Membulatkan di setiap langkah menambah galat.
- Setelah pembulatan, net_minor selalu dihitung sebagai gross_minor − fee_minor sehingga ketiganya pasti konsisten.
Sisa yang tidak habis dibagi
Ketika satu nominal harus dipecah — misalnya refund parsial atau bagi hasil ke beberapa pihak — sisa pembagian tidak boleh dibuang. Bagikan bagian bulat lebih dulu, lalu distribusikan sisanya satu satuan per penerima menurut urutan yang tetap.
def split_minor(total_minor: int, weights: list[int]) -> list[int]:
"""Bagi total ke beberapa pihak tanpa kehilangan satu satuan pun."""
total_weight = sum(weights)
shares = [total_minor * w // total_weight for w in weights]
remainder = total_minor - sum(shares) # 0..len(weights)-1
for i in range(remainder): # urutan tetap, dapat diulang
shares[i] += 1
assert sum(shares) == total_minor # invarian: tidak ada yang hilang
return shares
split_minor(100_000, [1, 1, 1]) # [33334, 33333, 33333]
Penukaran mata uang
Penukaran tidak pernah dicatat sebagai satu entri. Kaki keluar dan kaki masuk dicatat terpisah melalui akun posisi FX, masing-masing dalam satuan terkecil mata uangnya sendiri. Kurs disimpan sebagai data acuan, bukan sebagai faktor pengali yang dipakai ulang untuk menghitung saldo.
{
"operation": "exchange",
"from": { "currency": "USD", "amount_minor": 10000 }, // USD 100,00
"to": { "currency": "IDR", "amount_minor": 1628500 }, // Rp1.628.500
"rate": "16285.00",
"fee_minor": 12500,
"fee_currency": "IDR"
}
Nilai rate dikirim sebagai teks, bukan angka, supaya tidak ada pustaka JSON yang diam-diam mengubahnya menjadi bilangan pecahan biner.
Menampilkan nominal
Konversi dari satuan terkecil ke teks hanya dilakukan di lapisan tampilan, tepat sebelum ditampilkan ke pengguna. Jangan pernah menyimpan atau mengirim ulang hasil konversi itu.
const EXPONENT = { IDR: 0, JPY: 0, USD: 2, EUR: 2, SGD: 2 };
export function formatMoney(minor, currency, locale = 'id-ID') {
const exp = EXPONENT[currency];
if (exp === undefined) throw new Error(`Mata uang tidak dikenal: ${currency}`);
// Intl menerima satuan terkecil bila diberi tahu jumlah desimalnya.
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: exp,
maximumFractionDigits: exp
}).format(minor / 10 ** exp);
}
formatMoney(150000, 'IDR'); // "Rp150.000"
formatMoney(150000, 'USD'); // "US$1.500,00"
Daftar periksa integrasi
- Kolom nominal di basis data kamu bertipe bilangan bulat 64-bit, bukan decimal apalagi float.
- Tidak ada angka 100 yang dituliskan tetap di kode konversi mata uang.
- Total keranjang dijumlahkan dalam satuan terkecil, bukan dari nilai yang sudah diformat.
- Pembulatan hanya terjadi satu kali dan hasilnya disimpan, bukan dihitung ulang setiap kali dibaca.
- Refund parsial dijumlahkan dan dibandingkan dengan nominal asli dalam satuan terkecil.
- Pengujian mencakup nominal ganjil yang tidak habis dibagi, seperti Rp333 dan USD 0,01.

