Skip to main content
BlogBackend

Idempotency Keys That Stop a POS From Double-Charging

How I made carwash POS payment settlement safe against double-taps and network retries using idempotency keys, a Postgres unique constraint, and one guard spec test.

Matthews Wong

Software Engineer

July 20269 min read
Idempotency Keys That Stop a POS From Double-Charging

Photo by Shixart1985 via Openverse (CC BY 2.0)

Key Takeaway

To stop a POS double-charging, require a client-generated idempotency key on every settlement call, store it under a Postgres unique constraint, and return the stored prior result whenever the same key arrives twice. The database, not the app code, is the final guard: a duplicate insert fails atomically instead of charging again.

The first weekend the JID Carwash POS went live, a cashier tapped Pay twice on a slow tablet and the customer got charged 45,000 IDR for one wash and 45,000 IDR again a heartbeat later. Nobody noticed until the cash-session count came up 45,000 short of the receipts at closing. That single duplicate turned into an hour of arguing over the till. It was the bug that forced idempotency into settlement.service.ts, and it is the reason I now treat every payment write as an operation that must run at most once, no matter how many times the network or a human fires it.

A carwash POS is the worst-case environment for this. The tablets run on shared cellular data at the edge of a wash bay, requests time out and get retried, and cashiers under a queue tap fast and tap twice. Money in this ERP is integer IDR and never a float, so a duplicate is not a rounding error you can wave away — it is a whole extra rupiah amount recorded against a real customer. The only durable fix is to make the payment endpoint safe to call repeatedly.

Borrowing Stripe's idempotency design

I did not invent this. Stripe's engineering blog lays out the canonical shape: the client generates a unique key for one logical operation and sends it in a header, the server processes the request the first time and stores the result, and every later request carrying the same key gets the stored result back instead of re-running the logic. Stripe leans on a database unique constraint as the ultimate safety net, expires keys after 24 hours, and recommends V4 UUIDs or another high-entropy random string so two different operations never collide. I copied that shape almost verbatim, scaled down to a single Postgres table.

Where the key comes from

The key is generated on the client, once, at the moment the cashier opens the payment sheet — not when they tap Pay. That ordering matters: a key minted on tap would be brand new on every tap, which defeats the whole point. In pos-web the httpClient attaches a UUID captured when the sheet mounts, and the Flutter field-app does the same through its ApiPaymentRepository, reusing the key across retries from its offline outbox. So a double-tap, a manual refresh, and an outbox retry after reconnect all carry the identical key. The server treats them as one operation because, logically, they are.

// 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 },
});

The unique constraint is the real guard

An application-level check alone loses the race. Two requests can both read the table, both see no existing key, and both proceed to insert. The only thing that survives concurrency is a unique constraint enforced by Postgres itself. I added an idempotencyKey column with a unique index scoped by branchId, matching the branch-scoping invariant every other query in this ERP already follows. Settlement runs inside a Prisma transaction: insert the idempotency row first, then create the payment and the derived cash movement. If the insert violates the unique constraint, Prisma throws error code P2002, the transaction rolls back, and no second payment is ever written.

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;
  }
}

Do not store only the key and skip the result. If you insert the key but return a fresh response each time, a retry that arrives after the first commit will get a different payload than the original — and the client cannot tell the difference between a real second charge and a replay. Always store the exact prior result and replay it byte for byte.

The spec test that guards it forever

A fix that is not tested rots the next time someone refactors the transaction. So the invariant is pinned by one spec test that fires two concurrent settle calls with the same key and asserts exactly one Payment row exists and the two responses are identical. I run it against a real Postgres in CI, not a mock, because the whole point is that the database enforces the constraint — a mock would happily let both inserts through and prove nothing. This is the test that fails loudly if a future edit drops the unique index or moves the insert outside the transaction.

// 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
});

What I would tell my past self

  • Mint the key when intent forms (sheet open), not when the button is tapped — otherwise every retry is a new operation.
  • Let the database be the guard. Application checks reduce load but never win a true race; the unique constraint does.
  • Scope the key by branchId, exactly like every other query in the ERP, so keys from different branches never collide.
  • Store and replay the full prior result, not just a success flag, so a retry is indistinguishable from the original.
  • Test concurrency against real Postgres. A mock cannot prove the constraint holds under a race.
ApproachSurvives a true race?Why
App-level check onlyNoBoth requests read empty, both insert
Unique constraint onlyYes, but noisySecond insert errors; you must catch and replay
Check plus constraint plus stored resultYesFast path replays, race path falls back to the winner

Since that transaction shipped, the closing count has matched the receipts every single night across all branches. The double-tap still happens constantly — cashiers are cashiers — but it costs nothing now. The customer is charged once, the retry replays the same receipt, and the cash session balances. That is the entire promise of idempotency: make the unsafe thing safe to repeat, and stop policing human behavior you were never going to win.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is an idempotency key in a POS payment system?

An idempotency key is a unique client-generated string, usually a V4 UUID, attached to a single logical payment operation. The server processes the first request carrying that key and stores the result, then returns the same stored result for any later request with the same key. This makes the payment endpoint safe to call repeatedly so a double-tap or retry never creates a second charge.

Why is a database unique constraint better than an application check for idempotency?

An application check reads the table and then inserts, so two concurrent requests can both read an empty result and both insert, creating a duplicate. A Postgres unique constraint is enforced atomically at commit time, so the second insert fails no matter the timing. The database is the only layer that reliably wins a true race, which is why it is the final guard, not the app code.

When should the client generate the idempotency key?

Generate the key when the payment intent forms — for example when the cashier opens the payment sheet — not when they tap the Pay button. A key created on tap would be new on every tap, which defeats the purpose. Minting it once at sheet-open and reusing it across every retry, refresh, and offline-outbox resend ensures all those attempts are recognized as one operation.

What happens if two requests with the same key hit the server at once?

Both may pass the initial lookup and attempt to insert. One wins and commits the payment plus its idempotency row; the other hits the unique constraint and Prisma throws error code P2002. The losing request catches that error, reads the row the winner just wrote, and returns that stored result. The customer is charged exactly once and both responses are identical.

How do you test that settlement never double-charges?

Write a spec test that fires two concurrent settle calls with the same idempotency key using Promise.all, then assert that exactly one payment row exists and both responses are equal. Run it against a real Postgres instance in CI rather than a mock, because the guarantee depends on the database enforcing the unique constraint — a mock would let both inserts through and prove nothing.