Idempotency Keys That Stop a POS From Double-Charging

Photo by Shixart1985 via Openverse (CC BY 2.0)
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.
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.
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.
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.
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.

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.
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.
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 },
});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.
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
});| Approach | Survives a true race? | Why |
|---|---|---|
| App-level check only | No | Both requests read empty, both insert |
| Unique constraint only | Yes, but noisy | Second insert errors; you must catch and replay |
| Check plus constraint plus stored result | Yes | Fast 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.