Pencairan adalah satu-satunya tempat di produk keuangan di mana bug berarti uang beneran hilang dan nggak bisa ditarik balik. Semua yang ada di sekitarnya pengen jalanin transfer yang sama dua kali: retry, timeout, worker, webhook.
Tiga cara pencairan jalan dua kali
- Penyedia pembayaran timeout. Lu retry, padahal request pertama ternyata sudah berhasil.
- Dua worker ambil job yang sama di detik yang sama.
- Penyedia kirim webhook yang sama dua kali, dan tiap webhook memicu proses lanjutan.
Nggak ada yang aneh dari ketiganya. Semuanya muncul di bulan pertama sistem pencairan mana pun, jadi pengamannya harus dibangun dari awal, bukan ditambal belakangan.
Kunci, kasih nama, tanya dulu
Job-nya unik per pencairan, barisnya dikunci selama diproses, dan tiap transfer dikirim dengan gateway_partner_ref yang unik. Worker kedua cuma menemukan statusnya sudah berubah, lalu pergi.
<?php
class SendPayout implements ShouldQueue, ShouldBeUnique
{
public function uniqueId(): string
{
return "payout:{$this->payoutId}";
}
public function handle(): void
{
DB::transaction(function () {
$p = Payout::lockForUpdate()->findOrFail($this->payoutId);
if ($p->status !== 'pending') {
return; // someone else already moved it
}
$this->provider->send($p->gateway_partner_ref, $p);
});
}
}Langkah terakhir yang paling penting. Setelah timeout, sistem nggak asal kirim ulang. Dia tanya penyedia apakah referensi itu sudah ada, dan cuma kirim lagi kalau belum.
“Sebelum coba ulang, tanya dulu ke penyedia apakah percobaan pertama sudah berhasil.”
Webhook itu retry dari sisi seberang
Ide yang sama berlaku sebaliknya. Setiap webhook masuk dideduplikasi sebelum menyentuh saldo, jadi pesan yang datang dua kali jadi no-op, bukan kredit kedua.
Kalau cuma satu yang diingat: bikin percobaan kedua jadi membosankan. Kode pencairan terbaik adalah kode yang, kalau dijalanin dua kali, nggak ngapa-ngapain di kali kedua.