tfpdev
04 / 06State

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.

01Step 1

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.

Every button presses on both shops at once. 2 agents: two people click Refund in the same second. Chaos: six random buttons at once.200 of 200 clicks left this hour
BreaksCareless: one status field
Writes whatever you press. Never asks.
RESERVECHARGEREFUNDCANCELNEWRESERVEDPAIDREFUNDEDCANCELLED
Now—
Sent back$0$0 was paid
Rules broken0
  1. Nothing pressed yet
HoldsCareful: rulebook + counter
Asks two things first: is this move allowed now? Did anyone change it since I looked?
RESERVECHARGEREFUNDCANCELNEWRESERVEDPAIDREFUNDEDCANCELLED
Now—
Sent back$0$0 was paid
Said no0
  1. 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
sm.transitions — every attempt, both sides0 rows
AtSideActionFromToResultReason
No transitions yet.

Limit: 200 clicks per hour per visitor. Payments older than 24 hours are deleted with their transitions.

02Step 2

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.

03Step 3

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
BreaksBreaks
The version that loses money. Read the grey notes under it, not the code.
refund-naive.tsts
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]  );}
Any state goes to any state. Two staff members click refund within the same second and both requests pass. The provider happily refunds twice.
HoldsHolds
The version running behind the buttons above.
refund-guarded.tsts
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)
The transition is claimed in the database before the provider is called. The second click loses the UPDATE, gets zero rows, and never reaches the provider. The CHECK is the last line of defence.
04Step 4

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.

Why this exists

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.

Read the case