NawaPayNawaPay Docs

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 uangEksponenSatuan terkecilContoh
IDR01 rupiah150000 = Rp150.000
USD21 sen150000 = USD 1.500,00
EUR21 sen2599 = EUR 25,99
SGD21 sen4550 = SGD 45,50
JPY01 yen1200 = JPY 1.200
Kesalahan paling sering: mengalikan nominal IDR dengan 100. IDR memakai eksponen 0, jadi Rp150.000 dikirim sebagai 150000, bukan 15000000. Jangan pernah menuliskan angka 100 secara tetap di kode — bacalah eksponen dari daftar mata uang.

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"
Pembagian pada contoh di atas hanya terjadi di dalam fungsi tampilan, pada nilai yang jauh di bawah batas presisi aman, dan hasilnya langsung menjadi teks. Nilai minor yang asli tetap utuh.

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.

NawaPay berada dalam tahap pengembangan. Seluruh contoh pada dokumentasi ini merujuk pada lingkungan sandbox; sistem belum berizin sebagai penyelenggara jasa pembayaran dan belum memproses transaksi uang sungguhan.

© 2026 NawaPay · docs.nawapay.co.id