Skip to main content
BlogBackend

Idempotency Key yang Mencegah POS Menagih Dua Kali

Cara saya membuat penyelesaian pembayaran POS cucian mobil aman dari double-tap dan retry jaringan menggunakan idempotency key, unique constraint Postgres, dan satu spec test penjaga.

Matthews Wong

Software Engineer

Juli 20269 menit baca
Idempotency Key yang Mencegah POS Menagih Dua Kali

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.

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.
PendekatanSelamat dari balapan sejati?Mengapa
Pengecekan level aplikasi sajaTidakKedua request membaca kosong, keduanya menyisipkan
Unique constraint sajaYa, tetapi berisikInsert kedua galat; Anda harus menangkap dan memutar ulang
Cek plus constraint plus hasil tersimpanYaJalur 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.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa itu idempotency key dalam sistem pembayaran POS?

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.

Mengapa unique constraint database lebih baik daripada pengecekan aplikasi untuk idempotency?

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.

Kapan klien harus membuat idempotency key?

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.

Apa yang terjadi jika dua request dengan key sama tiba di server bersamaan?

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.

Bagaimana cara menguji bahwa penyelesaian tak pernah menagih dua kali?

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.