Jawabannya: Kunci Itu Milik Faktanya

Kuncinya pindah dari pesan ke pembayaran, dan dua jalur itu butuh kebijakan yang berbeda waktu menemukan keanehan. Angka tiap variannya ada di sini.

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

TEXT
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

  1. 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?
  2. Kalau sebuah charge bisa punya dua fakta berbeda (refund setelah pembayaran), kunci apa yang benar, dan kenapa charge_id sendirian nggak cukup?
  3. 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?
  4. Kalau tabel riwayat run dihapus sama sekali, apa yang hilang dari sistem ini? Jawab dulu sebelum menghapusnya.

Biar makin jago ngoding.

Yuk gabung bareng temen-temen developer lainnya buat dapet update teknologi, tips, dan tutorial santai tiap minggu.

Biar Nggak Kudet

Update teknologi, tips ngoding, dan insight santai langsung ke email kamu tiap minggu.

Santai, anti spam. Bisa berhenti langganan kapan aja.

Baca Selanjutnya

Lihat semua

© 2026 Inva.dev.