Engineering · 5 Mar 2026

Designing a payment state machine you can trust.

States, transitions, guards, and invariants for a payment intent — with the SQL and TypeScript to enforce them, and the tests that keep them honest.

Published
5 Mar 2026
Reading time
7 min
Readers
—
Topics
StablePayState machinesArchitectureTypeScriptPostgreSQL
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.

StateMeaningWho cares
createdIntent exists; nothing observed on the networkCheckout, application
seenA matching transaction is visible but not yet countedCheckout progress display
confirmingAccumulating confirmations under policyOperators
confirmedPolicy satisfied; safe to fulfilApplication, finance
settledFunds are where treasury expects themFinance
expiredNo payment arrived within the windowCheckout, support
underpaidValue arrived but below the required amountSupport, finance
failedA terminal failure with a recorded reasonSupport

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.

FromEventToReversible?Guard
createdtransaction_observedseenYes, until confirmedAmount and destination match the intent
createdexpiry_reachedexpiredYes, if value arrives later (see below)Now is after expires_at
seenconfirmation_progressconfirmingYesDepth below policy threshold
seenreorg_detectedcreatedYesObserved block no longer canonical
confirmingpolicy_satisfiedconfirmedNo — one-wayConfirmations or finality tag meet policy [3]
confirmingreorg_detectedseenYesBlock hash changed
confirmedsettlement_recordedsettledNoFunds observed in treasury account
createdshortfall_detectedunderpaidYes, on top-upValue observed below required amount
underpaidtopup_observedconfirmingYesCumulative 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.

createdIntent recorded
seenTransaction visible
confirmingDepth accumulating
confirmedPolicy met
settledFunds where expected
The happy path and its two loops

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.

KindExampleWhere to enforce
GuardDepth must meet policy before confirmedTransition function
GuardAmount must match within tolerance to leave createdTransition function
InvariantA confirmed payment has a confirmation timestampDatabase CHECK constraint [2]
InvariantA settled payment was confirmed firstTransition table
InvariantAn expired payment has no confirmation timestampDatabase CHECK constraint
InvariantAmounts are integers in minor unitsColumn 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.

Illustrative — states, timestamps, and a transition guard enforced in PostgreSQLSQL
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');
Illustrative — a trigger that rejects illegal transitionsSQL
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.

Illustrative — a pure transition function with guardsTypeScript
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.

TechniqueHow it worksTrade-off
Row lock (SELECT ... FOR UPDATE) [3]Lock the payment row for the duration of the transitionSimple and correct; holds a lock briefly
Optimistic version columnUpdate only if the version is unchanged; retry on conflictNo locks; needs a retry loop
Advisory lock per payment [3]Application-chosen lock keyed on the payment idUseful across several tables; must be used consistently
Serializable isolation [4]Database detects conflicts and aborts one transactionStrongest; requires retrying on serialisation failure
Illustrative — apply an event under a row lockTypeScript
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.

ColumnPurpose
payment_idWhich payment changed
from_state, to_stateThe transition
event (jsonb)What caused it, including block hash and depth
actorSystem, indexer, webhook, or a named operator
policy_versionThe confirmation policy in force
created_atWhen 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.

Illustrative — a property test over random event sequencesTypeScript
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

  1. 01

    Write the table first

    Agree the states and transitions with operators and finance before writing code.

  2. 02

    Encode it as data

    Put the legal transitions in a table and the invariants in constraints.

  3. 03

    Build the pure function

    Implement and test the transition function without a database.

  4. 04

    Wrap it in a locked transaction

    Apply events under a row lock and log each transition.

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

  1. 1
    Statecharts: A Visual Formalism for Complex Systems — David Harel, Science of Computer Programming, 1987
  2. 2
    PostgreSQL Documentation: Constraints — PostgreSQL Global Development Group
  3. 3
    PostgreSQL Documentation: Explicit Locking — PostgreSQL Global Development Group
  4. 4
    PostgreSQL Documentation: Transaction Isolation — PostgreSQL Global Development Group
  5. 5
  6. 6
    Domain-Driven Design — Eric Evans, Addison-Wesley
  7. 7
    Designing Data-Intensive Applications — Martin Kleppmann, O'Reilly Media
Found this useful? Share it

Get the next essay in your inbox

Practical writing on payments infrastructure, operations software, and shipping real systems. No spam, no sales sequence.

We only use your email to send the studio's writing. See the privacy policy.

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

Have a system like this to run?

Explore the catalog, or write down the problem and the constraints. We respond when the fit is real.

Free apps from the studio. Enter your email, get a private download link. Free for personal use.

Get them free

Have a product to sell? We review, list, and sell it for you — you keep 90% of every sale.

Apply to sell with us