Ringkasan Utama
Untuk mencegah POS menagih dua kali, wajibkan idempotency key buatan klien di setiap panggilan penyelesaian, simpan di bawah unique constraint Postgres, dan kembalikan hasil tersimpan sebelumnya setiap kali key yang sama tiba dua kali. Database, bukan kode aplikasi, adalah penjaga terakhir: insert duplikat gagal secara atomik alih-alih menagih lagi.
Akhir pekan pertama POS JID Carwash tayang, seorang kasir menekan Bayar dua kali di tablet yang lambat dan pelanggan tertagih 45.000 IDR untuk satu cucian lalu 45.000 IDR lagi sesaat kemudian. Tidak ada yang sadar sampai hitungan sesi kas kurang 45.000 dari struk saat tutup. Satu duplikat itu berubah jadi satu jam adu argumen soal laci kas. Itulah bug yang memaksa idempotency masuk ke settlement.service.ts, dan itu sebabnya saya kini memperlakukan setiap penulisan pembayaran sebagai operasi yang harus berjalan paling banyak satu kali, tak peduli berapa kali jaringan atau manusia memicunya.
POS cucian mobil adalah lingkungan terburuk untuk hal ini. Tablet berjalan di data seluler bersama di pinggir area cuci, request timeout lalu di-retry, dan kasir yang dikejar antrean menekan cepat dan menekan dua kali. Uang di ERP ini adalah integer IDR dan tidak pernah float, jadi duplikat bukan galat pembulatan yang bisa diabaikan — itu adalah jumlah rupiah utuh tambahan yang tercatat atas pelanggan nyata. Satu-satunya perbaikan tahan lama adalah membuat endpoint pembayaran aman dipanggil berulang.
Meminjam desain idempotency Stripe
Saya tidak menemukan ini. Blog teknik Stripe menjabarkan bentuk kanoniknya: klien membuat key unik untuk satu operasi logis dan mengirimnya di header, server memproses request pertama kali dan menyimpan hasilnya, dan setiap request berikutnya yang membawa key sama menerima hasil tersimpan alih-alih menjalankan ulang logika. Stripe bersandar pada unique constraint database sebagai jaring pengaman terakhir, mengedaluwarsakan key setelah 24 jam, dan menyarankan UUID V4 atau string acak berentropi tinggi lain agar dua operasi berbeda tak pernah bertabrakan. Saya menyalin bentuk itu hampir persis, diperkecil ke satu tabel Postgres.
Dari mana key berasal
Key dibuat di klien, sekali, pada saat kasir membuka lembar pembayaran — bukan saat menekan Bayar. Urutan itu penting: key yang dibuat saat menekan akan selalu baru di tiap tekanan, yang meniadakan seluruh tujuannya. Di pos-web, httpClient melampirkan UUID yang ditangkap saat lembar dipasang, dan field-app Flutter melakukan hal sama lewat ApiPaymentRepository-nya, memakai ulang key itu lintas retry dari outbox offline-nya. Jadi double-tap, refresh manual, dan retry outbox setelah tersambung kembali semuanya membawa key identik. Server memperlakukannya sebagai satu operasi karena, secara logis, memang begitu.
// pos-web: key minted when the payment sheet opens, reused for every send
const idempotencyKey = useMemo(() => crypto.randomUUID(), []);
await httpClient.post('/payments/settle', {
orderId,
channel: 'CASH',
amountIdr: 45000, // integer IDR — never a float
}, {
headers: { 'Idempotency-Key': idempotencyKey },
});Unique constraint adalah penjaga sesungguhnya
Pengecekan di level aplikasi saja kalah dalam balapan. Dua request bisa sama-sama membaca tabel, sama-sama tidak melihat key yang ada, dan sama-sama lanjut menyisipkan. Satu-satunya yang selamat dari konkurensi adalah unique constraint yang ditegakkan Postgres sendiri. Saya menambahkan kolom idempotencyKey dengan indeks unik yang dilingkupi branchId, sesuai invarian branch-scoping yang sudah diikuti setiap query lain di ERP ini. Penyelesaian berjalan di dalam transaksi Prisma: sisipkan baris idempotency dulu, lalu buat pembayaran dan pergerakan kas turunannya. Jika insert melanggar unique constraint, Prisma melempar kode galat P2002, transaksi di-rollback, dan tidak pernah ada pembayaran kedua yang tertulis.
model IdempotencyKey {
id String @id @default(cuid())
key String
branchId String
resultJson Json // the stored prior response, replayed on retry
createdAt DateTime @default(now())
@@unique([branchId, key]) // Postgres enforces at-most-once per branch
}
// settlement.service.ts — the core of the guard
async settle(dto: SettleDto, key: string, branchId: string) {
const existing = await this.prisma.idempotencyKey.findUnique({
where: { branchId_key: { branchId, key } },
});
if (existing) return existing.resultJson; // replay, do not re-charge
try {
return await this.prisma.$transaction(async (tx) => {
const payment = await tx.payment.create({ data: { ...dto, branchId } });
await tx.stockMovement /* + cash entry */;
const result = { paymentId: payment.id, status: 'SETTLED' };
await tx.idempotencyKey.create({
data: { key, branchId, resultJson: result },
});
return result;
});
} catch (e) {
if (e.code === 'P2002') {
// lost the race — the winner already wrote it; return its result
const won = await this.prisma.idempotencyKey.findUnique({
where: { branchId_key: { branchId, key } },
});
return won.resultJson;
}
throw e;
}
}Jangan hanya menyimpan key dan melewatkan hasilnya. Jika Anda menyisipkan key tetapi mengembalikan respons baru tiap kali, retry yang tiba setelah commit pertama akan menerima payload berbeda dari aslinya — dan klien tak bisa membedakan antara tagihan kedua yang nyata dan sebuah replay. Selalu simpan hasil sebelumnya yang persis dan putar ulang byte demi byte.
Spec test yang menjaganya selamanya
Perbaikan yang tidak diuji akan membusuk saat berikutnya seseorang merefaktor transaksi. Maka invariannya dipaku oleh satu spec test yang menembakkan dua panggilan settle konkuren dengan key sama dan memastikan tepat satu baris Payment ada serta kedua respons identik. Saya menjalankannya terhadap Postgres nyata di CI, bukan mock, karena intinya adalah database yang menegakkan constraint — mock akan dengan senang hati meloloskan kedua insert dan tidak membuktikan apa pun. Inilah test yang gagal dengan keras jika suntingan masa depan menghapus indeks unik atau memindahkan insert ke luar transaksi.
// settlement.service.spec.ts
it('never double-charges on a repeated idempotency key', async () => {
const key = crypto.randomUUID();
const dto = { orderId, channel: 'CASH', amountIdr: 45000 };
const [a, b] = await Promise.all([
service.settle(dto, key, branchId),
service.settle(dto, key, branchId), // the double-tap
]);
expect(a).toEqual(b); // same result replayed
const rows = await prisma.payment.count({ where: { orderId } });
expect(rows).toBe(1); // exactly one charge
});Yang akan saya katakan ke diri saya dulu
- Buat key saat niat terbentuk (lembar dibuka), bukan saat tombol ditekan — kalau tidak, setiap retry adalah operasi baru.
- Biarkan database jadi penjaga. Pengecekan aplikasi mengurangi beban tetapi tak pernah menang balapan sejati; unique constraint yang menang.
- Lingkupi key dengan branchId, persis seperti query lain di ERP, agar key dari cabang berbeda tak pernah bertabrakan.
- Simpan dan putar ulang hasil sebelumnya secara penuh, bukan sekadar bendera sukses, agar retry tak bisa dibedakan dari aslinya.
- Uji konkurensi terhadap Postgres nyata. Mock tak bisa membuktikan constraint bertahan di bawah balapan.
| Pendekatan | Selamat dari balapan sejati? | Mengapa |
|---|---|---|
| Pengecekan level aplikasi saja | Tidak | Kedua request membaca kosong, keduanya menyisipkan |
| Unique constraint saja | Ya, tetapi berisik | Insert kedua galat; Anda harus menangkap dan memutar ulang |
| Cek plus constraint plus hasil tersimpan | Ya | Jalur cepat memutar ulang, jalur balapan jatuh ke pemenang |
Sejak transaksi itu tayang, hitungan penutupan cocok dengan struk setiap malam di semua cabang. Double-tap tetap terjadi terus — kasir tetaplah kasir — tetapi kini tak berbiaya. Pelanggan tertagih sekali, retry memutar ulang struk yang sama, dan sesi kas seimbang. Itulah seluruh janji idempotency: membuat hal yang tak aman jadi aman diulang, dan berhenti mengawasi perilaku manusia yang memang tak akan pernah Anda menangkan.

