Dalam integrasi pembayaran yang melibatkan tahapan multi-RPC ke sistem Core ERP, keberhasilan sebuah transaksi tidak berhenti saat status di database lokal berubah menjadi SUCCESS atau saat nomor faktur (Invoice) berhasil diterbitkan.
Agar pembukuan akuntansi di Core ERP benar-benar sah dan berimbang (balanced), sistem harus menyelesaikan tiga tahapan RPC berurutan:
invoice_number (misal: INV/2026/00123).ledger_id dengan status awal unpaid.ledger_id tersebut dengan mode jurnal yang sesuai (direct atau non_direct) $\rightarrow$ mengubah status ledger menjadi paid dan melunasi Invoice terkait.Ketika ribuan transaksi diproses setiap hari, gangguan jaringan sesaat atau timeout di sisi Core ERP antara Tahap 1, Tahap 2, dan Tahap 3 dapat meninggalkan dua jenis anomali parsial (partial failure states) di database kita:
invoice sudah ada dan ledger_id sudah tercatat di DB lokal), namun Tahap 3 gagal dieksekusi sehingga status pelunasan masih tertinggal di UNPAID.invoice sudah terbit di ERP dan tercatat di DB lokal), tetapi proses terputus saat Tahap 2 berlangsung sehingga ledger_id di database lokal masih bernilai NULL!Jika kedua anomali ini tidak diselesaikan secara otomatis, laporan keuangan di Core ERP akan menampilkan ratusan faktur berstatus Unpaid padahal pelanggan sudah membayar lunas.
Dalam artikel keempat dari seri portofolio engineering ini, saya akan membedah bagaimana saya merancang modul Two-Phase Self-Healing Reconciliation untuk mendeteksi dan menyembuhkan kedua anomali tersebut secara otonom tanpa menyebabkan duplikasi jurnal.
Jika kita menjalankan worker rekonsiliasi secara sembarangan pada transaksi yang baru saja dibayar 10 detik yang lalu, worker tersebut akan bertabrakan dengan proses webhook callback asli yang masih sedang berjalan!
Oleh karena itu, pada proses batch rekonsiliasi, saya menerapkan konsep Sliding Time Window dengan Dynamic Safety Buffer:
paid_at) sudah lewat minimal 60 menit yang lalu. Transaksi yang baru saja lunas di bawah 60 menit dibiarkan selesai melalui jalur normal terlebih dahulu.ledger_id yang Hilang dengan Mekanisme Anti-DuplikasiKasus paling berbahaya pada Anomali Tipe 2 (transaksi sudah SUCCESS, nomor invoice sudah ada, tetapi ledger_id === null) adalah: kita tidak tahu pasti apakah entri ledger sebenarnya sudah sempat terbuat di Core ERP (namun gagal disimpan ke DB lokal karena timeout) atau memang belum terbuat sama sekali.
Jika kita langsung memanggil fungsi pembuatan ledger tanpa memeriksa kondisi di Core ERP terlebih dahulu, kita berisiko membuat dua entri riwayat pembayaran untuk satu invoice yang sama!
Berikut adalah algoritma pemulihan 3 langkah yang saya terapkan:
ledger_id di database lokal kita, lalu teruskan ke tahap pelunasan jika statusnya masih unpaid.Record is Paid): Jika sebelum ledger sempat dibuat ternyata staf finance sudah melunasi invoice tersebut secara manual di dashboard ERP, panggilan pengecekan invoice akan mengembalikan pesan 'Record is Paid'. Sistem menangkap kondisi spesifik ini dan langsung menyinkronkan status lokal menjadi PAID!// Prototype Konseptual: Fase 1 — Anti-Duplication Recovery & Auto-Link
async function recoverUnlinkedLedger(tx: TransactionRecord) {
// 1. Cek ke Core ERP apakah entri ledger untuk nomor invoice ini sebenarnya sudah terbuat
const existingLedger = await erpClient.findLedgerByInvoiceNo(
tx.invoiceNumber,
);
if (existingLedger) {
// KASUS A: Sudah ada di ERP -> Lakukan Auto-Link ke DB lokal (cegah duplikasi!)
await db.transactions.update(tx.id, { ledgerId: existingLedger.id });
if (existingLedger.status === 'paid') {
return await db.transactions.update(tx.id, { ledgerStatus: 'PAID' });
}
return await settleLedgerInErp({ ...tx, ledgerId: existingLedger.id });
}
// KASUS B: Belum ada di ERP -> Buat baru dengan proteksi jika sudah lunas manual
try {
const invoiceDetail = await erpClient.getInvoiceDetail(tx.invoiceNumber);
const newLedgerId = await erpClient.createLedgerEntry(tx, invoiceDetail);
return await settleLedgerInErp({ ...tx, ledgerId: newLedgerId });
} catch (err: any) {
if (String(err.message).includes('Record is Paid')) {
// Invoice sudah dilunasi manual oleh tim finance di ERP
return await db.transactions.update(tx.id, { ledgerStatus: 'PAID' });
}
throw err;
}
}
Pada Fase 2, kita menangani transaksi yang sudah memiliki ledger_id namun status pelunasannya masih UNPAID.
Ada dua detail teknis krusial di tahap ini:
Di sistem akuntansi Core ERP, cara penjurnalan dana masuk berbeda tergantung pada kanal pembayaran yang dipilih pelanggan:
direct.non_direct.['ok'])Saat memanggil method RPC pelunasan di Core ERP, panggilan protokol RPC bisa saja mengembalikan status HTTP 200 OK (tidak melempar network exception), tetapi gagal memposting jurnal di sisi aplikasi karena aturan akuntansi internal.
Oleh karena itu, kita tidak boleh langsung mengupdate ledger_status = 'PAID' hanya karena fungsi RPC tidak error. Kita wajib memeriksa isi payload kembaliannya: apakah berupa array yang mengandung konfirmasi 'ok'!
// Prototype Konseptual: Fase 2 — Dynamic Journal Mode & Strict RPC Validation
function resolveJournalMode(channel?: string): 'direct' | 'non_direct' {
const ch = (channel || '').toUpperCase();
return ch.includes('DIRECT_BANK') || ch.includes('CREDIT')
? 'direct'
: 'non_direct';
}
async function settleLedgerInErp(tx: TransactionRecord) {
const remoteLedger = await erpClient.getLedgerById(tx.ledgerId);
// Jika di ERP sudah lunas, cukup sinkronkan status lokal
if (remoteLedger.status === 'paid') {
return await db.transactions.update(tx.id, { ledgerStatus: 'PAID' });
}
// Eksekusi pelunasan dengan mode jurnal yang sesuai & bungkus dengan auto-retry
const journalMode = resolveJournalMode(tx.selectedChannel);
const rpcResponse = await withRpcRetry(() =>
erpClient.markLedgerAsPaid(tx.ledgerId, {
paidAt: tx.paidAt.toISOString(),
journalMode,
}),
);
// Validasi ketat: hanya ubah ke PAID jika ERP mengembalikan konfirmasi eksplisit ['ok']
const isConfirmed =
Array.isArray(rpcResponse) &&
rpcResponse.some((val) => String(val).toLowerCase() === 'ok');
if (isConfirmed) {
await db.transactions.update(tx.id, { ledgerStatus: 'PAID' });
}
}
paid_at) memastikan cron job rekonsiliasi tidak pernah berebut resource (lock contention) dengan transaksi real-time yang sedang berlangsung.['ok']) sebelum mengubah status finansial di database lokal.Pada Part 5 (artikel penutup seri portofolio ini), kita akan membedah arsitektur keamanan dan reliabilitas ekosistem partner: Multi-Tenant Dynamic HMAC Signature dengan Backward Compatibility, Smart Resend Callback Engine, serta Proteksi Formula Injection & Sanitasi Observability Log.