On this page
Key takeaways
- A payment is a small state machine; writing its transitions as a table before code prevents most bugs.
- Enforce the machine in the database, not only in application code, so no code path can violate it.
- Model reversibility explicitly: some transitions can be undone until finality, others never.
- Property-based tests over random event sequences find the ordering bugs unit tests miss.
Every payment system has a state machine, whether or not its authors drew one. When it is implicit, it lives in scattered if statements, and the bugs appear as impossible combinations: an order marked paid whose payment was never confirmed, or a payment that moved backwards after a duplicate event. When it is explicit, the same complexity becomes a table you can review.
This post walks through designing that table for a self-hosted payment gateway: the states, the transitions, the guards, the invariants that must always hold, and how to enforce all of it in PostgreSQL and TypeScript. The formal roots are older than payments — Harel's statecharts [1] — but the practical payoff is simple: fewer impossible states.
Why an explicit machine
Implicit state
- Status is a free-text column
- Any code path may set any value
- Transitions are inferred from scattered checks
- Reordered events corrupt state
- Nobody can list the legal transitions
Explicit machine
- Status is an enum with defined values
- Only allowed transitions succeed
- Legal moves are one reviewable table
- Stale events are ignored by construction
- The table is the documentation
Step one: name the states
Resist the urge to add a state for every thought. Each state should answer a question the business or an operator actually asks. A useful test: if two states would be handled identically by every consumer, they are one state.
| State | Meaning | Who cares |
|---|---|---|
| created | Intent exists; nothing observed on the network | Checkout, application |
| seen | A matching transaction is visible but not yet counted | Checkout progress display |
| confirming | Accumulating confirmations under policy | Operators |
| confirmed | Policy satisfied; safe to fulfil | Application, finance |
| settled | Funds are where treasury expects them | Finance |
| expired | No payment arrived within the window | Checkout, support |
| underpaid | Value arrived but below the required amount | Support, finance |
| failed | A terminal failure with a recorded reason | Support |
Step two: write the transition table
The transition table is the heart of the design. For each state, list which states it may move to, what event causes the move, and whether the move is reversible. Anything not listed is illegal.
| From | Event | To | Reversible? | Guard |
|---|---|---|---|---|
| created | transaction_observed | seen | Yes, until confirmed | Amount and destination match the intent |
| created | expiry_reached | expired | Yes, if value arrives later (see below) | Now is after expires_at |
| seen | confirmation_progress | confirming | Yes | Depth below policy threshold |
| seen | reorg_detected | created | Yes | Observed block no longer canonical |
| confirming | policy_satisfied | confirmed | No — one-way | Confirmations or finality tag meet policy [3] |
| confirming | reorg_detected | seen | Yes | Block hash changed |
| confirmed | settlement_recorded | settled | No | Funds observed in treasury account |
| created | shortfall_detected | underpaid | Yes, on top-up | Value observed below required amount |
| underpaid | topup_observed | confirming | Yes | Cumulative value meets required amount |
The one-way door
Exactly one transition — the move to confirmed — should be treated as the point of no return, and everything upstream of it must be reversible. That is where the confirmation policy earns its keep, and why reorganisation handling matters.
Step three: guards and invariants
A guard is a condition that must hold for a transition to be allowed. An invariant is a fact that must hold in every state, always. Separating them clarifies where each check belongs.
| Kind | Example | Where to enforce |
|---|---|---|
| Guard | Depth must meet policy before confirmed | Transition function |
| Guard | Amount must match within tolerance to leave created | Transition function |
| Invariant | A confirmed payment has a confirmation timestamp | Database CHECK constraint [2] |
| Invariant | A settled payment was confirmed first | Transition table |
| Invariant | An expired payment has no confirmation timestamp | Database CHECK constraint |
| Invariant | Amounts are integers in minor units | Column type |
Step four: enforce it in the database
Application code enforces rules when it runs. The database enforces them always. For a system that moves value, put the strongest guarantees where every code path, including a hurried manual fix, must pass through them.
CREATE TYPE payment_state AS ENUM
('created','seen','confirming','confirmed','settled','expired','underpaid','failed');
CREATE TABLE payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
reference text NOT NULL UNIQUE,
state payment_state NOT NULL DEFAULT 'created',
amount_units bigint NOT NULL CHECK (amount_units > 0),
received_units bigint NOT NULL DEFAULT 0 CHECK (received_units >= 0),
confirmed_at timestamptz,
settled_at timestamptz,
expires_at timestamptz NOT NULL,
policy_version text,
updated_at timestamptz NOT NULL DEFAULT now(),
-- invariants
CHECK ((state IN ('confirmed','settled')) = (confirmed_at IS NOT NULL)),
CHECK (state <> 'settled' OR settled_at IS NOT NULL),
CHECK (state <> 'expired' OR confirmed_at IS NULL)
);
-- the legal transitions, as data
CREATE TABLE payment_transitions (
from_state payment_state NOT NULL,
to_state payment_state NOT NULL,
PRIMARY KEY (from_state, to_state)
);
INSERT INTO payment_transitions VALUES
('created','seen'),('created','expired'),('created','underpaid'),
('seen','confirming'),('seen','created'),
('confirming','confirmed'),('confirming','seen'),
('confirmed','settled'),
('underpaid','confirming');CREATE FUNCTION enforce_payment_transition() RETURNS trigger AS $$
BEGIN
IF NEW.state = OLD.state THEN RETURN NEW; END IF;
IF NOT EXISTS (
SELECT 1 FROM payment_transitions
WHERE from_state = OLD.state AND to_state = NEW.state
) THEN
RAISE EXCEPTION 'illegal payment transition % -> %', OLD.state, NEW.state
USING ERRCODE = 'check_violation';
END IF;
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER payments_transition_guard
BEFORE UPDATE OF state ON payments
FOR EACH ROW EXECUTE FUNCTION enforce_payment_transition();With this in place, a bug that tries to move a settled payment back to created fails loudly at the database rather than silently corrupting a ledger. The transition table is data, so adding a state is a reviewed migration, not a scattered code change.
Step five: the transition function in application code
The database is the last line of defence. The application should express the same logic as a pure function so it can be tested without a database and reasoned about in one place.
type State = "created" | "seen" | "confirming" | "confirmed" | "settled" | "expired" | "underpaid" | "failed";
type Event =
| { type: "transaction_observed"; block: { number: bigint; hash: string }; amount: bigint }
| { type: "confirmation_progress"; depth: number }
| { type: "policy_satisfied" }
| { type: "reorg_detected" }
| { type: "expiry_reached" }
| { type: "settlement_recorded" };
export function next(state: State, event: Event, ctx: { required: bigint }): State {
switch (state) {
case "created":
if (event.type === "transaction_observed")
return event.amount >= ctx.required ? "seen" : "underpaid";
if (event.type === "expiry_reached") return "expired";
return state;
case "seen":
if (event.type === "confirmation_progress") return "confirming";
if (event.type === "reorg_detected") return "created";
return state;
case "confirming":
if (event.type === "policy_satisfied") return "confirmed";
if (event.type === "reorg_detected") return "seen";
return state;
case "confirmed":
return event.type === "settlement_recorded" ? "settled" : state;
default:
return state; // settled, expired, failed: terminal for these events
}
}Ignore what does not apply
Notice the default of returning the current state. An event that does not apply — a stale reorg notice for a payment that is already confirmed, say — is ignored, not an error. That single choice makes duplicate and reordered events harmless.
Concurrency: two workers, one payment
The indexer, a webhook handler, and an operator action can all try to change the same payment at once. Without care, both read the old state and both write, and one update is lost. The fix is to serialise access to each payment row.
| Technique | How it works | Trade-off |
|---|---|---|
Row lock (SELECT ... FOR UPDATE) [3] | Lock the payment row for the duration of the transition | Simple and correct; holds a lock briefly |
| Optimistic version column | Update only if the version is unchanged; retry on conflict | No locks; needs a retry loop |
| Advisory lock per payment [3] | Application-chosen lock keyed on the payment id | Useful across several tables; must be used consistently |
| Serializable isolation [4] | Database detects conflicts and aborts one transaction | Strongest; requires retrying on serialisation failure |
async function apply(paymentId: string, event: Event) {
return db.transaction(async (tx) => {
const { rows } = await tx.query(
`SELECT id, state, amount_units FROM payments WHERE id = $1 FOR UPDATE`,
[paymentId],
);
const p = rows[0];
const to = next(p.state, event, { required: BigInt(p.amount_units) });
if (to === p.state) return p; // no-op: stale or duplicate event
await tx.query(`UPDATE payments SET state = $2 WHERE id = $1`, [paymentId, to]);
await tx.query(
`INSERT INTO payment_events (payment_id, from_state, to_state, event) VALUES ($1,$2,$3,$4)`,
[paymentId, p.state, to, JSON.stringify(event)],
);
return { ...p, state: to };
});
}Recording history: the event log
Current state answers what is true now. An append-only log of transitions answers how it got there, which is what you need for audits, debugging, and dispute resolution. Record the from-state, to-state, triggering event, actor, and timestamp in the same transaction as the change, so the two can never disagree.
| Column | Purpose |
|---|---|
| payment_id | Which payment changed |
| from_state, to_state | The transition |
| event (jsonb) | What caused it, including block hash and depth |
| actor | System, indexer, webhook, or a named operator |
| policy_version | The confirmation policy in force |
| created_at | When it happened |
Edge cases worth designing for
Value arrives after expiry
A payment link expires, and then a payer sends funds anyway. Do not force it into confirmed. Either allow a controlled expired → confirming transition for late arrivals within a grace window, or hold the value in a suspense record for an operator to decide. Whichever you choose, decide it deliberately and record the reason.
Cumulative underpayments
A payer sends part of the amount, then the rest. Track received value cumulatively and let the state reflect the total. This is why received_units exists alongside amount_units.
Duplicate observations
The indexer may see the same transaction twice, especially after a restart or a reorg. Make observation idempotent by keying on the transaction hash and log index, and treat a repeat as a no-op.
Testing a state machine properly
Example-based tests check the paths you thought of. State machines fail on the paths you did not. Property-based testing [5] generates random sequences of events and asserts that invariants hold no matter what order they arrive in.
import fc from "fast-check";
const eventArb = fc.oneof(
fc.constant({ type: "reorg_detected" } as const),
fc.constant({ type: "policy_satisfied" } as const),
fc.constant({ type: "expiry_reached" } as const),
fc.constant({ type: "settlement_recorded" } as const),
fc.record({ type: fc.constant("confirmation_progress" as const), depth: fc.nat(20) }),
);
test("a settled payment never moves backwards", () => {
fc.assert(
fc.property(fc.array(eventArb, { maxLength: 50 }), (events) => {
let state: State = "created";
let reachedSettled = false;
for (const e of events) {
state = next(state, e, { required: 100n });
if (state === "settled") reachedSettled = true;
if (reachedSettled) expect(state).toBe("settled");
}
}),
);
});Invariants worth property-testing
- A settled payment never leaves settled.
- A confirmed payment always has a confirmation timestamp.
- Replaying the same event twice gives the same state as once.
- Applying any permutation of stale events never yields a state outside the enum.
- Total received value is monotonic except on a recorded reorg.
Rolling the design out
- 01
Write the table first
Agree the states and transitions with operators and finance before writing code.
- 02
Encode it as data
Put the legal transitions in a table and the invariants in constraints.
- 03
Build the pure function
Implement and test the transition function without a database.
- 04
Wrap it in a locked transaction
Apply events under a row lock and log each transition.
- 05
Expose it to operators
Show the state and its history on the dashboard, and make illegal actions impossible in the interface too.
The result is a payment record that can explain itself. When someone asks how a payment reached its current state, the answer is a query over an append-only log, not a reconstruction from memory. That is the standard we hold StablePay to, and the one we recommend for anything that moves value.
References & further reading
- 1Statecharts: A Visual Formalism for Complex Systems — David Harel, Science of Computer Programming, 1987
- 2PostgreSQL Documentation: Constraints — PostgreSQL Global Development Group
- 3PostgreSQL Documentation: Explicit Locking — PostgreSQL Global Development Group
- 4PostgreSQL Documentation: Transaction Isolation — PostgreSQL Global Development Group
- 5
- 6Domain-Driven Design — Eric Evans, Addison-Wesley
- 7Designing Data-Intensive Applications — Martin Kleppmann, O'Reilly Media
The product behind this post
StablePay
Self-hosted stablecoin payment infrastructure.
From $4,800 one-time license
About the author
Product behaviour described here reflects what is implemented and tested; anything else is marked as planned. Code samples are illustrative.
All writing