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.
| 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.

