Idempotency Key yang Mencegah POS Menagih Dua Kali

Foto oleh Shixart1985 via Openverse (CC BY 2.0)
Idempotency key adalah string unik buatan klien, biasanya UUID V4, yang dilampirkan ke satu operasi pembayaran logis. Server memproses request pertama yang membawa key itu dan menyimpan hasilnya, lalu mengembalikan hasil tersimpan yang sama untuk request berikutnya dengan key yang sama. Ini membuat endpoint pembayaran aman dipanggil berulang sehingga double-tap atau retry tak pernah membuat tagihan kedua.
Pengecekan aplikasi membaca tabel lalu menyisipkan, jadi dua request konkuren bisa sama-sama membaca hasil kosong dan sama-sama menyisipkan, menciptakan duplikat. Unique constraint Postgres ditegakkan secara atomik saat commit, jadi insert kedua gagal apa pun waktunya. Database adalah satu-satunya lapisan yang andal menang di balapan sejati, itulah sebabnya ia jadi penjaga terakhir, bukan kode aplikasi.
Buat key saat niat pembayaran terbentuk — misalnya saat kasir membuka lembar pembayaran — bukan saat menekan tombol Bayar. Key yang dibuat saat menekan akan baru di tiap tekanan, yang meniadakan tujuannya. Membuatnya sekali saat lembar dibuka dan memakainya ulang lintas setiap retry, refresh, dan pengiriman ulang outbox offline memastikan semua percobaan itu dikenali sebagai satu operasi.
Keduanya mungkin lolos pencarian awal dan mencoba menyisipkan. Satu menang dan meng-commit pembayaran plus baris idempotency-nya; yang lain terkena unique constraint dan Prisma melempar kode galat P2002. Request yang kalah menangkap galat itu, membaca baris yang baru ditulis pemenang, dan mengembalikan hasil tersimpan itu. Pelanggan tertagih tepat satu kali dan kedua respons identik.
Tulis spec test yang menembakkan dua panggilan settle konkuren dengan idempotency key sama menggunakan Promise.all, lalu pastikan tepat satu baris pembayaran ada dan kedua respons setara. Jalankan terhadap instans Postgres nyata di CI alih-alih mock, karena jaminannya bergantung pada database yang menegakkan unique constraint — mock akan meloloskan kedua insert dan tidak membuktikan apa pun.

Foto oleh Shixart1985 via Openverse (CC BY 2.0)
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.
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.
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 },
});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.
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
});| 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.