Blog · Terbit
Yang perlu disimpan dari sebuah pembayaran di basis data sendiri, dan tiga kolom yang hampir selalu salah dipakai
Sebuah integrasi pembayaran biasanya ditulis dalam dua sore dan dipakai bertahun-tahun. Yang menentukan umurnya bukan kode pemanggil API-nya, melainkan bentuk tabel tempat hasilnya disimpan: kolom yang kurang membuat pertanyaan sederhana tidak bisa dijawab tanpa memanggil API lagi, dan kolom yang salah dipakai sebagai kunci membuat dua pesanan saling menimpa pada hari tersibuk.
Jawaban singkatnya: simpan id permintaan pembayaran sebagai kunci, tiga angka nominal secara terpisah, status sebagai salinan yang boleh basi, waktu kedaluwarsa karena tidak ada yang akan mengirimkannya, dan id setiap event pada tabel kedua dengan indeks unik. Selebihnya, terutama isi instrumen pembayarannya, lebih baik dibaca ulang saat dibutuhkan daripada disalin.
Satu kolom yang menjadi kunci, dan dua yang hanya label
Respons pembuatan permintaan pembayaran membawa id berawalan payreq_. Itulah satu-satunya pengenal yang diterbitkan sisi pembayaran, satu-satunya yang dipakai untuk membaca ulang, dan satu-satunya yang layak menjadi kolom penghubung antara pesanan dan pembayarannya.
Dua kolom lain terlihat seperti kunci dan bukan kunci. external_id dan merchant_ref adalah label milik penjual: disimpan apa adanya, dikembalikan di respons dan di muatan webhook, bisa dipakai sebagai filter, tetapi tidak pernah dijamin unik. Mengirim nomor pesanan yang sama dua kali menghasilkan dua permintaan pembayaran yang berbeda, bukan satu. Satu-satunya hal yang membuat dua permintaan create dianggap satu pembayaran adalah header Idempotency-Key, yang dibahas terpisah di tulisan tentang tagihan ganda dan Idempotency-Key.
Karena itu key yang dipakai layak ikut disimpan pada barisnya. Saat sebuah permintaan gagal di tengah jalan dan tidak jelas apakah pembayarannya sempat terbentuk, key yang tersimpan adalah yang membuat percobaan berikutnya mengambil pembayaran yang sama dan bukan membuat yang kedua.
Tiga angka, bukan satu
amount, fee, dan net adalah tiga kolom, bukan satu kolom dan sebuah rumus. Identitas gross = fee + net berlaku pada kedua mode penanggung biaya, sehingga menyimpan ketiganya membuat baris itu bisa dicocokkan dengan mutasi bank tanpa menghitung apa pun.
Menghitung ulang biaya dari tarif yang dihafal adalah kesalahan yang baru terasa jauh di kemudian hari. Tarif berlaku per metode dan per akun, dan bisa berubah, sehingga pembukuan bulan lalu ikut bergeser begitu angkanya diganti. Yang benar adalah angka yang tercetak pada pembayaran itu sendiri.
Satu hal yang sering mengejutkan: muatan webhook payment.paid membawa amount, tetapi tidak membawa fee maupun net. Integrasi yang hanya mendengarkan webhook dan tidak pernah menyimpan respons create-nya akan tahu bahwa uang masuk tanpa pernah tahu berapa yang benar-benar diterima. Dua angka itu datang dari respons create, atau dari pembacaan ulang permintaan pembayaran.
Status: kolom yang paling sering salah dimodelkan
Nilai yang mungkin ada lima: pending, succeeded, expired, canceled, dan failed. Memodelkannya sebagai tiga menghasilkan percabangan yang jatuh diam-diam pada dua nilai yang sah, dan diam-diam berarti pesanan yang tidak pernah diproses oleh siapa pun.
Kolom status di sisi sendiri adalah salinan, dan salinan boleh basi. Sisi pembayaran menampilkan status efektif: permintaan yang tenggatnya sudah lewat terbaca sebagai kedaluwarsa sejak detik tenggat itu terlampaui, bukan sejak ada proses yang menuliskannya. Baris yang tersimpan sebagai pending karena itu bisa sudah tidak berarti apa pun, dan itu bukan cacat selama pembacaan ulang dipakai sebelum sesuatu yang tidak bisa dibatalkan dikerjakan.
Jebakan yang lebih mahal ada di arah sebaliknya. Kedaluwarsa bukan akhir yang mutlak: pembayaran yang uangnya sudah diterima jaringan sebelum tenggat terlampaui masih bisa berpindah menjadi succeeded sesudahnya, karena menyatakan penjual tidak dibayar padahal uangnya ada adalah kesalahan yang lebih buruk. Akibatnya untuk sistem sendiri: baris kedaluwarsa tidak dihapus, dan sebuah payment.paid yang datang untuk pesanan yang sudah ditulis hangus harus diterima, bukan ditolak sebagai mustahil.
Kedaluwarsa tidak pernah datang sebagai webhook
Satu-satunya event yang dikirim untuk sebuah pembayaran adalah payment.paid. Kedaluwarsa, pembatalan, dan kegagalan tidak pernah didorong ke endpoint mana pun. Konsekuensinya langsung ke bentuk tabel: expires_at wajib disimpan, karena pelepasan stok, penutupan pesanan, dan pengiriman pengingat hanya bisa dijalankan oleh penjadwal milik sendiri yang membaca kolom itu.
Angka bawaannya 60 menit, dengan plafon 24 jam per akun dan penolakan 422 expiry_too_long di atas itu. Plafon itu juga alasan checkout_url layak disimpan tetapi tidak layak dianggap abadi: tautan yang sudah lewat tenggat tidak dihidupkan kembali, melainkan digantikan permintaan pembayaran baru.
Tabel kedua untuk event, dengan satu indeks unik
Pengiriman webhook bersifat at-least-once: event yang sama bisa datang lebih dari sekali, dengan id berawalan evt_ yang selalu sama, dan percobaan ulang berjalan sampai tujuh kali dalam sekitar 33 jam bila endpoint tidak menjawab 2xx. Satu tabel terpisah dengan id event sebagai kunci primer menutup seluruh kelas masalah itu dalam satu baris DDL: penyisipan kedua gagal pada indeks uniknya, dan pemrosesan dilewati.
Dedupe dilakukan pada id event, bukan pada id pembayaran. Menyimpan muatan mentahnya juga murah dan terbayar pada hari pertama ada yang tidak cocok, karena tanda tangan Kasera-Signature-V1 dihitung atas byte aslinya dan bukan atas hasil penguraian. Cara memverifikasinya dibahas di tulisan tentang memproses webhook dengan aman.
Bentuk tabel yang cukup
-- Satu baris per permintaan pembayaran.
CREATE TABLE payments (
id text PRIMARY KEY, -- payreq_... dari respons create
order_id bigint NOT NULL REFERENCES orders (id),
idem_key text NOT NULL, -- yang dikirim sebagai Idempotency-Key
livemode boolean NOT NULL,
status text NOT NULL, -- salinan, bukan sumber kebenaran
amount bigint NOT NULL, -- gross
fee bigint, -- yang benar-benar dipotong
net bigint, -- gross = fee + net
payment_method text, -- qris, va_bca ... card
checkout_url text,
expires_at timestamptz NOT NULL, -- dipegang sendiri, tidak pernah didorong
paid_at timestamptz,
created_at timestamptz NOT NULL,
UNIQUE (order_id, idem_key)
);
-- Satu baris per pengiriman webhook yang diterima.
CREATE TABLE payment_events (
id text PRIMARY KEY, -- evt_... : indeks unik yang menutup duplikat
payment_id text NOT NULL REFERENCES payments (id),
type text NOT NULL, -- payment.paid
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
raw jsonb NOT NULL
);Kolom livemode ada di sana karena mode tes dan mode sungguhan memakai kunci dan secret yang berbeda, dan satu baris uji coba yang menyelinap ke laporan penjualan adalah pekerjaan satu hari untuk membersihkannya. Memisahkannya lewat satu kolom boolean lebih murah daripada memisahkannya lewat dua basis data.
Yang sebaiknya tidak disimpan
- Isi instrumen pembayaran. String QRIS dan nomor Virtual Account ada di respons saat satu kode metode disebut, tetapi tidak berguna setelah tenggatnya lewat dan selalu bisa dibaca ulang selama masih berlaku. Menyalinnya hanya menambah kolom panjang yang cepat basi.
- Data kartu. Tidak pernah melewati sistem penjual sama sekali, jadi tidak ada yang bisa disimpan walaupun diinginkan.
- Kunci API di tabel yang sama. Kunci hidup di environment variable, bukan di baris basis data, dan rotasinya dibahas di tulisan tentang menyimpan dan merotasi kunci API.
- Status sebagai satu-satunya kebenaran. Kolom status boleh ada, tetapi keputusan yang tidak bisa dibatalkan, misalnya mengirim barang, layak didahului satu pembacaan ulang.
Membaca kembali
Semua yang di atas bermuara pada satu kebiasaan: membaca kembali lewat id yang sudah tersimpan. Sistem yang menyimpan payreq_ pada pesanannya tidak pernah perlu mencari pembayaran berdasarkan nomor pesanan, tidak pernah perlu menebak baris mana yang dimaksud saat ada dua, dan tidak pernah bergantung pada daftar transaksi untuk menemukan sesuatu yang sudah diketahui letaknya.
Apakah pembacaan berkala masih diperlukan di samping webhook, dan apa yang benar-benar dijamin masing-masing, dibahas di perbandingan webhook dan menanyakan status berkala.
Pertanyaan yang sering muncul
Apakah external_id atau merchant_ref bisa dipakai sebagai kunci utama?
Tidak. Keduanya adalah label milik penjual: disimpan, dikembalikan di respons dan di webhook, bisa difilter, tetapi tidak pernah dijamin unik oleh sisi pembayaran. Dua permintaan dengan nomor pesanan yang sama tetap menjadi dua pembayaran yang berbeda. Kunci satu-satunya adalah id permintaan pembayaran berawalan payreq_, dan satu-satunya hal yang membuat dua permintaan create dianggap satu pembayaran adalah header Idempotency-Key.
Kenapa fee dan net perlu disimpan kalau tarifnya sudah diketahui?
Karena tarif berlaku per metode dan per akun, dan bisa berubah. Menghitung ulang biaya dari tarif yang dihafal berarti pembukuan bulan lalu ikut berubah begitu tarifnya berubah. Angka yang benar adalah yang tercetak pada pembayaran itu sendiri, dan identitas gross = fee + net berlaku pada kedua mode penanggung biaya. Perlu diperhatikan bahwa muatan webhook tidak membawa fee maupun net, jadi dua angka itu berasal dari respons create atau dari pembacaan ulang permintaan pembayaran.
Apakah baris yang sudah kedaluwarsa boleh dihapus?
Sebaiknya tidak. Kedaluwarsa bukan akhir yang mutlak: pembayaran yang uangnya diterima jaringan sebelum tenggat lewat masih bisa berpindah menjadi succeeded sesudahnya. Sistem yang menghapus barisnya, atau menolak event yang datang untuk pesanan yang sudah ditulis hangus, akan menerima uang tanpa punya tempat mencatatnya.
Bagaimana cara tahu sebuah tagihan sudah kedaluwarsa kalau tidak ada webhook untuk itu?
Dari kolom expires_at yang disimpan sendiri. Satu-satunya event yang dikirim untuk pembayaran adalah payment.paid, sehingga kedaluwarsa, pembatalan, dan kegagalan tidak pernah datang sebagai pengiriman. Pelepasan stok dan penutupan pesanan dijalankan oleh penjadwal milik sendiri yang membaca expires_at, dan pembacaan ulang dipakai untuk memastikan sebelum sesuatu yang tidak bisa dibatalkan dikerjakan.