Poin Penting
Klaimnya harus tentang pembayaran (charge_id), bukan tentang pesannya (event_id).
- Satu jalur punya tabel klaimnya sendiri = dua kebenaran; yang harus disatukan adalah kuncinya, bukan alatnya.
- Webhook membalas jawaban pengiriman pertama; job melewati yang cocok dan melaporkan yang berbeda, bukan menimpanya.
- Menghapus id run dari kunci juga menghapus kebutuhan akan kursor: mengulang satu hari penuh jadi aman.
Hasil pengukuran
Semua angka di sini dari run pengukuran, bukan perkiraan: satu database bersih untuk tiap varian. Pemeriksaannya 24.
| varian | merah | catatan |
|---|---|---|
| kondisi awal | 14 dari 24 | kunci jalur webhook (event_id) dan kunci job (recon:<run>:<charge>) nggak pernah bertemu |
klaim pindah ke charge_id, tapi job tetap punya tabel klaimnya sendiri |
13 dari 24 | bentuk gagalnya berubah: sekarang job-nya kena unique di ledger, jadi balasannya 500 |
| nominal yang sudah tercatat ditimpa laporan | 3 dari 24 | barisnya tetap satu, tapi nominal yang benar sudah hilang tanpa jejak |
| klaim per pembayaran di kedua jalur, dua kebijakan, tanpa kursor | 0 dari 24 | — |
Angka konkret dari make demo di kondisi awal (enam pembayaran seharga 220.000): 12 baris untuk 6
pembayaran, saldo 665.000. Di branch solusi: satu baris per pembayaran, saldo tepat.
1. Setiap bagian benar sendiri-sendiri. Itu masalahnya
Jalur webhook itu jawaban lab sebelumnya, dan tetap benar — untuk pertanyaan "apakah pesan ini sudah pernah saya terima?"
Jalur rekonsiliasi juga benar untuk pertanyaan yang dia jawab: "apakah run ini sudah memproses
baris laporan ini?" Karena id run selalu baru, jawabannya selalu "belum" — dan di situlah bugnya.
Job yang dijalankan dua kali melaporkan inserted=6 dua kali, dengan bangga.
Yang nggak pernah ada di sistem ini: satu tempat yang bisa menjawab "apakah pembayaran ini sudah pernah saya catat?" Dua jalur, dua tabel, dua kebenaran.
2. Kuncinya pindah dari pesan ke faktanya
sebelum: processed_events(event_id PK) satu pesan satu klaim
sesudah: payment_claims(charge_id PK) satu pembayaran satu klaim
charge_id adalah kunci yang sama untuk kedua jalur, karena keduanya sedang membicarakan
pembayaran yang sama. event_id tetap disimpan — di ledger dan di baris klaimnya — tapi perannya
berubah: dia jejak, bukan penentu.
Ikutannya di database: unique di ledger_entries pindah dari event_id ke charge_id, karena
yang nggak boleh dua kali itu pembayarannya. Di produksi ini migrasi yang harus dikerjakan
hati-hati: duplikat lamanya dibereskan dulu — dan itu keputusan bisnis, bukan keputusan SQL —
baru constraint-nya dipasang.
3. Dua jalur, dua kebijakan
| klaim sudah ada, isinya sama | klaim sudah ada, nominal berbeda | |
|---|---|---|
| webhook | balas jawaban pengiriman pertama (200) | 409 |
| job rekonsiliasi | lewati (skipped) |
laporkan di mismatched, jangan tulis apa pun |
Alasannya beda penunggunya. Di ujung jalur webhook ada koneksi HTTP yang sedang menunggu jawaban: dia berhak dapat jawaban yang sama, dan kalau isinya bertentangan, 409 adalah jawaban yang benar. Di ujung jalur rekonsiliasi ada job yang sedang menyisir satu hari laporan: kalau satu baris jelek membuat seluruh hari gagal, job-nya nggak akan pernah selesai — dan besok pagi baris yang sama akan menggagalkannya lagi.
4. Kursor itu gejala, bukan kebutuhan
Begitu klaimnya per pembayaran, nggak ada lagi yang perlu dicatat tentang "sudah sampai mana". Menjalankan ulang satu hari penuh aman, dua run bersamaan aman (yang kalah klaim melewati baris itu), dan run yang cuma sempat dua baris bisa dilanjutkan oleh run berikutnya. Itu sebabnya tiga pemeriksaan tentang run yang diulang, tumpang tindih, dan kepotong jadi hijau tanpa satu baris kode tambahan.
Kalimat "kunci yang dimiliki faktanya, bukan pesannya" itu saya baru ngerti setelah kena dua kali. Yang kedua lebih mahal, karena waktu itu saya sudah merasa sistemnya aman.
Jebakan yang sengaja dibiarkan
Memberi jalur kedua tabelnya sendiri. Kelihatan paling rapi: kode jalur webhook nggak disentuh, job punya tabelnya sendiri, nggak ada yang bertabrakan. Yang terjadi terukur: 13 dari 24 masih merah, dan bentuk gagalnya berubah — sekarang job-nya kena unique constraint di ledger, jadi balasannya 500.
Menganggap laporan settlement sebagai kebenaran. Varian "yang tercatat ditimpa laporan": barisnya tetap satu, tapi nominal yang benar-benar terjadi sudah hilang tanpa jejak, dan saldonya salah seribu. Kalau kamu memutuskan laporan memang lebih benar, keputusan itu boleh — tapi harus disengaja, dan selisihnya tetap dicatat.
Menggagalkan job supaya "kelihatan". Job yang melempar error di baris pertama laporan yang jelek berhenti di situ selamanya, dan laporan hari itu nggak pernah selesai.
Pertanyaan buat kamu
- Job ini jalan tiap pagi untuk hari kemarin. Kalau gateway baru mengirim laporan hari itu tiga hari kemudian, apa yang harus berubah — kode job-nya, atau jadwalnya?
- Kalau sebuah charge bisa punya dua fakta berbeda (refund setelah pembayaran), kunci apa yang
benar, dan kenapa
charge_idsendirian nggak cukup? - Webhook membalas 409 untuk nominal yang bertentangan, job melaporkannya. Apa yang lebih baik untuk pengguna: baris penyesuaian otomatis, atau laporan harian yang harus dilihat manusia?
- Kalau tabel riwayat run dihapus sama sekali, apa yang hilang dari sistem ini? Jawab dulu sebelum menghapusnya.