State Machine
A payment moves through stages: created, reserved, taken, refunded. The careless version lets two clicks refund the same money twice. The careful one says no to the second click.
Try it
Two shops, the same $49 payment. Every button presses on both. Try to make the careless one send back money it never took. Two agents clicking Refund in the same second is how it happens for real.
- Nothing pressed yet
- Nothing pressed yet
Two shops, the same $49 payment, nothing pressed yet. Every button presses on both. Your goal: make the careless shop send back money it never took.
What the database wrote down, row by row
| At | Side | Action | From | To | Result | Reason |
|---|---|---|---|---|---|---|
| No transitions yet. | ||||||
Limit: 200 clicks per hour per visitor. Payments older than 24 hours are deleted with their transitions.
What the careful side has
A rulebook
One list that says which button is allowed at which stage. Refund is allowed after the money was taken, not before, and not twice.
A change counter
Every change bumps a counter and says which number it saw. Two people press Refund together: the first bumps the counter, the second's number is out of date, the second gets nothing.
Write it down, then phone the bank
Mark the payment refunded first, then ask the card company to send the money. If that call fails, undo the mark. The other way round is how refunds go out twice.
The code
What runs behind the buttons, in one sentence. The code itself is one click away.
The bad version overwrites the status with whatever was asked. The good version changes it only if the current stage allows that move and nobody else changed the payment since it last looked.
Show the code anyway
export async function refund(paymentId: string) { const p = await db.one("SELECT * FROM payments WHERE id = $1", [paymentId]); await provider.refund(p.provider_charge_id, p.amount_cents); await db.query( `UPDATE payments SET state = 'refunded', refunded_cents = refunded_cents + $2 WHERE id = $1`, [paymentId, p.amount_cents] );}const TRANSITIONS = { authorize: { from: ["created"], to: "authorized" }, capture: { from: ["authorized"], to: "captured" }, void: { from: ["authorized"], to: "voided" }, refund: { from: ["captured"], to: "refunded" },};export async function refund(paymentId: string, seenVersion: number) { const t = TRANSITIONS.refund; const { rows } = await db.query( `UPDATE payments SET state = $2, refunded_cents = refunded_cents + amount_cents, version = version + 1 WHERE id = $1 AND state = ANY($3) AND version = $4 RETURNING provider_charge_id, amount_cents`, [paymentId, t.to, t.from, seenVersion] ); if (rows.length === 0) throw new IllegalTransition("refund", paymentId); await provider.refund(rows[0].provider_charge_id, rows[0].amount_cents);}// In the schema:// CHECK (refunded_cents <= amount_cents)Still curious?
A few more questions people ask. Open what you need.
Why not make the second person wait in line?
You could, but the line would be waiting on a phone call to the card company. The counter lets the second click fail instantly instead of queueing.
What does the second person see?
A message: 'this payment was already refunded a moment ago'. Nothing goes out twice.
What is the database's own rule here?
Refunds can never add up to more than the payment. Even buggy code cannot break that one, because the database refuses to save it.
Is the rulebook enough on its own?
No. Two people can both look at the rulebook, both see 'refund allowed', and both act. The counter settles who was first.
Ticketing: the refund that ran twice
This article is the toy version of a problem from a real project. The case page tells what happened and what it cost.