Sistem pembayaran otomatis sering terlihat sederhana dari sisi pengguna: buat pesanan, pindai QRIS, lalu tunggu produk digital muncul. Di belakang layar, alurnya tidak sesederhana tiga langkah tersebut. Notifikasi pembayaran dapat datang dua kali, browser dapat kembali aktif bersamaan dengan webhook, jaringan pengguna dapat terputus, dan dua proses backend bisa mencoba mengalokasikan produk yang sama pada waktu hampir bersamaan.

Artikel ini membahas pola yang digunakan penulis saat membangun alur pembayaran dan distribusi kredensial pada layanan akses software untuk teknisi HP. Konteks produknya dapat dilihat pada panduan akses UnlockTool. Tautan tersebut disertakan secara transparan sebagai studi kasus, bukan sebagai afiliasi.

Fokus tulisan ini bukan pada satu penyedia pembayaran tertentu. Polanya dapat dipakai pada berbagai sistem yang menjual lisensi, voucher, token, akun sementara, atau produk digital lain yang harus dikirim segera setelah pembayaran terverifikasi.

Mengapa webhook saja belum cukup?

Webhook adalah notifikasi dari payment gateway menuju server kita. Ketika pembayaran berhasil, gateway mengirim data seperti nomor pesanan, nominal, status, dan tanda tangan. Masalahnya, webhook tetap merupakan input dari jaringan luar.

Beberapa kondisi yang perlu diasumsikan sejak awal:

  1. Webhook bisa terlambat atau tidak sampai.

  2. Gateway dapat mengulang webhook karena respons server sebelumnya tidak diterima.

  3. Pengguna dapat membuka kembali halaman pembayaran ketika webhook sedang diproses.

  4. Status PAID tidak cukup tanpa mencocokkan nomor pesanan, nominal, dan signature.

  5. Proses setelah pembayaran, misalnya alokasi akun, dapat gagal walaupun pembayaran sudah sah.

Karena itu, webhook sebaiknya diperlakukan sebagai pemicu verifikasi, bukan satu-satunya sumber kebenaran.

Mulai dari state machine yang jelas

Kesalahan paling umum adalah memakai satu kolom status untuk semua proses. Padahal pembayaran dan pengiriman produk merupakan dua hal berbeda. Pesanan dapat sudah dibayar tetapi produknya belum dialokasikan.

Pisahkan setidaknya dua state:

payment_status:
  pending -> accepted
  pending -> cancelled
  pending -> rejected

fulfillment_status:
  unallocated -> allocating -> allocated
  unallocated -> allocating -> failed

Pemisahan ini membuat masalah lebih mudah didiagnosis. Ketika pembeli belum menerima produk, kita dapat segera melihat apakah pembayaran belum diterima atau proses fulfillment yang gagal.

State juga membatasi transisi yang sah. Pesanan cancelled tidak boleh kembali menjadi pending hanya karena ada request lama. Produk tidak boleh ditampilkan sebelum fulfillment_status benar-benar allocated.

Verifikasi tiga identitas transaksi

Sebelum menerima pembayaran, cocokkan minimal tiga hal:

  • order_id harus sama dengan pesanan lokal;

  • total_amount harus sama dengan nominal yang diharapkan;

  • signature harus cocok dengan nilai yang tersimpan ketika tagihan dibuat.

Contoh handler yang disederhanakan:

async function handleWebhook(payload: WebhookPayload) {
  const order = await findOrder(payload.orderId);
  if (!order) return response(404, "order_not_found");

  if (!safeEqual(order.gatewaySignature, payload.signature)) {
    return response(401, "signature_mismatch");
  }

  if (order.expectedAmount !== payload.totalAmount) {
    return response(400, "amount_mismatch");
  }

  if (!isPaidStatus(payload.status)) {
    return response(200, "ignored");
  }

  return acceptPayment(order, payload);
}

Gunakan perbandingan signature yang aman dan jangan menganggap dua nilai kosong sebagai pasangan yang valid. Normalisasi tipe data juga penting karena sebagian gateway mengirim nominal sebagai string, misalnya "9972.00", sedangkan database menyimpannya sebagai integer.

Jadikan penerimaan pembayaran idempotent

Idempotent berarti request yang sama boleh dijalankan berulang tanpa menghasilkan efek samping ganda. Pada sistem pembayaran, webhook pertama boleh mengubah status menjadi accepted, sedangkan webhook kedua cukup menerima jawaban already_accepted.

Gunakan transaksi database dan kunci row:

BEGIN;

SELECT p.id, p.status, p.order_id
FROM payment p
WHERE p.id = $1
FOR UPDATE;

-- Hentikan dengan hasil already_accepted bila status sudah accepted.

UPDATE payment
SET status = 'accepted', verified_at = now()
WHERE id = $1 AND status = 'pending';

UPDATE orders
SET payment_status = 'accepted',
    fulfillment_status = CASE
      WHEN fulfillment_status = 'unallocated' THEN 'allocating'
      ELSE fulfillment_status
    END
WHERE id = $2;

COMMIT;

FOR UPDATE memastikan dua proses tidak sama-sama membaca status pending lalu menerima pembayaran dua kali. Syarat WHERE status = 'pending' menjadi lapisan pertahanan tambahan.

Simpan pula event audit seperti system.accepted. Catatan event jauh lebih berguna daripada hanya melihat nilai status terakhir ketika terjadi komplain.

Tambahkan rekonsiliasi saat halaman diperiksa

Webhook dapat gagal, jadi halaman status pesanan sebaiknya mampu melakukan rekonsiliasi. Ketika frontend meminta status dan pesanan masih pending, backend dapat menanyakan detail transaksi ke gateway.

Namun jangan memanggil API gateway pada setiap polling browser. Gunakan lock singkat di Redis atau penyimpanan serupa:

const lockKey = `payment:reconcile:${publicId}`;
const allowed = await redis.set(lockKey, "1", {
  nx: true,
  ex: 10,
});

if (allowed) {
  await reconcilePayment(publicId);
}

Dengan TTL sepuluh detik, lima tab browser yang memeriksa pesanan yang sama tidak akan menghasilkan lima request bersamaan ke gateway. Jika status gateway sudah PAID, proses tetap masuk ke fungsi acceptPayment() yang idempotent. Jalur webhook dan jalur polling bertemu pada satu pintu penerimaan yang sama.

Cegah alokasi ganda dengan unique constraint

Setelah pembayaran diterima, sistem harus memilih produk digital yang tersedia. Risiko baru muncul: webhook dan rekonsiliasi dapat sama-sama memanggil fungsi alokasi.

Jangan hanya mengandalkan pengecekan di kode aplikasi. Buat aturan unik di database, misalnya satu allocation untuk satu order:

INSERT INTO rental_allocation (
  order_id,
  account_username,
  due_at,
  allocated_at
)
VALUES ($1, $2, $3, now())
ON CONFLICT (order_id)
DO UPDATE SET
  account_username = EXCLUDED.account_username,
  due_at = EXCLUDED.due_at,
  allocated_at = now();

Pada sistem dengan stok terbatas, pemilihan stok juga perlu dilakukan atomik. Pola FOR UPDATE SKIP LOCKED dapat dipakai agar dua worker tidak mengambil baris stok yang sama:

SELECT id
FROM account_stock
WHERE status = 'available'
ORDER BY priority DESC, available_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1;

Setelah satu worker mengunci kandidat, worker lain langsung melewati baris tersebut dan memilih kandidat berikutnya.

Jangan biarkan notifikasi menggagalkan transaksi utama

Email dan Telegram berguna, tetapi keduanya bukan bagian inti dari keberhasilan pembayaran. Jika email sedang lambat, status order tidak boleh kembali gagal.

Urutan yang lebih aman:

  1. Verifikasi pembayaran.

  2. Simpan status pembayaran dalam transaksi database.

  3. Alokasikan produk dan simpan hasilnya.

  4. Tampilkan produk kepada pengguna.

  5. Kirim email atau notifikasi admin secara terpisah.

Notifikasi dapat dijalankan melalui queue atau blok try/catch yang terisolasi. Simpan hasil kirim ke tabel notification_log agar kegagalan dapat diulang tanpa mengulang pembayaran dan alokasi.

Pulihkan halaman setelah pengguna kembali dari e-wallet

Pada Android, pengguna sering berpindah dari browser ke DANA, mobile banking, atau aplikasi lain. Browser dapat masuk ke background, tab dapat dibuang oleh sistem, atau halaman dibuka ulang dari awal.

Simpan referensi order terakhir di localStorage, bukan data sensitif:

localStorage.setItem(
  "last-order",
  JSON.stringify({
    publicId: order.publicId,
    savedAt: Date.now(),
    expiresAt: order.expiresAt,
  }),
);

Batasi masa hidup data, misalnya 24 jam. Hapus data ketika pesanan cancelled, expired, atau pengguna sudah menutup pengingatnya.

Frontend juga sebaiknya langsung memeriksa status ketika browser kembali aktif:

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") refreshOrder();
});

window.addEventListener("focus", refreshOrder);
window.addEventListener("pageshow", refreshOrder);

Gunakan flag requestInFlight agar event focus, pageshow, dan interval polling tidak membuat request paralel. Dengan pola ini, akun dapat muncul beberapa saat setelah pembayaran tanpa meminta pengguna menekan tombol konfirmasi.

Skenario test yang sering terlupakan

Unit test normalisasi response gateway saja belum cukup. Tambahkan pengujian untuk kondisi berikut:

  1. Webhook PAID dikirim dua kali.

  2. Webhook dan rekonsiliasi berjalan bersamaan.

  3. Signature kosong dibandingkan dengan signature kosong.

  4. Nominal benar tetapi order_id salah.

  5. Status pembayaran diterima tetapi stok sedang kosong.

  6. Alokasi berhasil tetapi email gagal.

  7. Pengguna kembali dari e-wallet ketika polling sebelumnya belum selesai.

  8. Tab ditutup lalu halaman dibuka kembali dalam 24 jam.

  9. Pesanan sudah kedaluwarsa tetapi data lokal masih tersimpan.

Untuk concurrency test, jalankan dua promise yang memanggil fungsi penerimaan pada payment ID yang sama. Hasil yang benar adalah satu accepted dan satu already_accepted, dengan satu allocation aktif.

Observability yang benar-benar membantu

Log sebaiknya memakai event yang dapat dicari, misalnya:

payment.webhook.signature_mismatch
payment.reconcile.pending
payment.accepted
allocation.completed
allocation.no_stock
notification.email_failed

Sertakan publicId, tetapi jangan pernah mencetak API key, password, QR payload lengkap, atau kredensial produk. Dengan event yang konsisten, kita dapat menyusun timeline satu order tanpa membaca seluruh log aplikasi.

Metrik minimum yang layak dipantau:

  • waktu dari pembayaran sampai status accepted;

  • waktu dari accepted sampai allocated;

  • jumlah webhook duplikat;

  • jumlah rekonsiliasi yang menemukan pembayaran sukses;

  • persentase alokasi gagal karena stok kosong;

  • jumlah notifikasi gagal tanpa mengganggu transaksi utama.

Checklist sebelum go-live

Sebelum sistem pembayaran otomatis dibuka untuk pengguna, pastikan:

  • state pembayaran dan fulfillment dipisahkan;

  • nomor pesanan, nominal, dan signature diverifikasi;

  • penerimaan pembayaran memakai transaksi dan row lock;

  • webhook duplikat menghasilkan already_accepted;

  • rekonsiliasi dibatasi lock singkat;

  • allocation memiliki unique constraint;

  • notifikasi tidak dapat membatalkan transaksi utama;

  • browser dapat memulihkan order terakhir tanpa menyimpan kredensial;

  • seluruh perubahan status memiliki event audit;

  • test mencakup request bersamaan, bukan hanya happy path.

Penutup

Masalah pembayaran digital biasanya bukan karena satu fungsi yang salah, melainkan karena beberapa proses yang benar berjalan pada waktu yang sama. Webhook, polling, rekonsiliasi, alokasi, dan notifikasi perlu dirancang sebagai rangkaian state yang dapat diulang dengan aman.

Prinsip utamanya sederhana: verifikasi input eksternal, pusatkan penerimaan pembayaran pada fungsi idempotent, gunakan database sebagai penjaga konsistensi, dan anggap browser pengguna dapat hilang kapan saja. Dengan fondasi tersebut, pengalaman pengguna dapat terasa otomatis tanpa mengorbankan keamanan data dan ketepatan alokasi.

Referensi

Profil Penulis

William Surya Darma mengelola layanan digital untuk kebutuhan teknisi HP dan mengembangkan alur pembayaran, pemulihan checkout, serta distribusi kredensial otomatis. Ia tertarik pada sistem backend yang sederhana bagi pengguna tetapi tetap tahan terhadap retry, koneksi terputus, dan proses paralel.