B2B platform for digital goods

Order State Machine Design 2026: Modelling Order Lifecycles in Digital Goods

Production design of a B2B order FSM: 6 core states, transition invariants, audit log, and TypeScript implementation aligned with the FoxReload order lifecycle.

Order State Machine Design 2026: Modelling Order Lifecycles in Digital Goods

The order state machine is the heart of any B2B fulfilment. Correct design makes the system observable, debuggable and audit-friendly. Bad design breeds impossible states and race conditions you can chase for months. This article is a detailed guide to designing an FSM for digital-goods orders, aligned with the FoxReload order lifecycle.

1. Six core states

The FoxReload API returns these order statuses: active, paid, processing, completed, cancelled, failed. Your internal FSM should mirror them:

enum OrderState {
  ACTIVE = 'active',           // created, awaiting payment
  PAID = 'paid',               // payment confirmed, queued for fulfilment
  PROCESSING = 'processing',   // supplier API called, awaiting result
  COMPLETED = 'completed',     // success, codes in externalData
  CANCELLED = 'cancelled',     // cancelled — see cancelReason
  FAILED = 'failed',           // terminal — fulfilment failed
}

Allowed transitions (graph):

active      → paid | cancelled | failed
paid        → processing | cancelled | failed
processing  → completed | cancelled | failed
completed   → (terminal)
cancelled   → (terminal)
failed      → (terminal)

Any other transition (e.g., completed → processing) is invalid and must throw.

The cancelReason field on a cancelled order is one of: payment_failure, payment_expiration, user_request, or null.

2. Invariants

An invariant is a property always true regardless of state. For an order:

  1. total_amount > 0
  2. paid_at IS NULLstate ∈ {active, cancelled, failed}
  3. completed_at IS NULLstate ≠ completed
  4. codes IS NULL OR codes = []state ≠ completed
  5. cancel_reason IS NOT NULLstate = cancelled

Check invariants both inside the FSM transition handler and in the DB via CHECK constraints:

ALTER TABLE orders ADD CONSTRAINT chk_completed_codes
  CHECK (state != 'completed' OR codes IS NOT NULL);
ALTER TABLE orders ADD CONSTRAINT chk_cancel_reason
  CHECK (cancel_reason IS NULL OR state = 'cancelled');

3. XState or enum+switch?

For small/medium projects, enum + switch is enough:

async function transition(order: Order, event: OrderEvent): Promise<Order> {
  const next = nextState(order.state, event.type);
  if (!next) throw new Error(`invalid transition ${order.state} + ${event.type}`);
  return db.tx(async (tx) => {
    const updated = await tx.orders.update(order.id, {
      state: next,
      version: order.version + 1, // optimistic lock
    }, { where: { version: order.version } });
    await tx.audit.insert({
      event_id: randomUUID(),
      order_id: order.id,
      from_state: order.state,
      to_state: next,
      actor: event.actor,
      timestamp: new Date(),
    });
    return updated;
  });
}

For complex flows (multi-supplier, partial-delivery, fraud-hold) XState provides a visual diagram and hierarchical states.

4. Polling the FoxReload API for state updates

FoxReload does not push webhooks. Your FSM advances by polling GET /api/orders/{order_id} and syncing the returned status into your local state:

async function syncOrderState(localOrder: Order, apiKey: string): Promise<Order> {
  const res = await fetch(
    `https://public-api.foxreload.com/api/orders/${localOrder.foxreloadOrderId}`,
    { headers: { 'X-API-Key': apiKey } },
  );
  const remote = await res.json();
  if (remote.status !== localOrder.state) {
    return transition(localOrder, { type: remote.status, actor: 'system' });
  }
  return localOrder;
}

Run this in a polling loop with exponential backoff until the order reaches a terminal state.

5. Implementation comparison

Approach LoC Visualisable Type-safe Performance
Boolean flags 50 No No Best
Enum + switch 150 No Strong Best
XState 200+ Yes (Inspector) Strong Good
Temporal workflow 500+ Yes Strong Good (async)

For most FoxReload integrations, enum+switch is sufficient. The state transitions are simple (strictly forward, no branching beyond terminal states), and the polling pattern maps cleanly to a single background job per order.

CTA

The FoxReload API exposes the current order status and per-item externalData codes via GET /api/orders/{id}. Poll this to drive your FSM transitions. Get access.

Frequently asked questions

How many states should an order FSM have?
At least 5–6 for digital goods: active → paid → processing → completed, with cancelled and failed as absorbing terminal states. Slack states like partial_fulfillment should be added only if business logic demands — every extra state adds ~5% in bugs.
Can I use boolean flags instead of a state machine?
No. Flags like is_paid, is_processing, is_completed create impossible states (e.g., is_completed=true while is_paid=false). An enum-based FSM guarantees each order is in exactly one valid state. This eliminates 30%+ of state-related bugs.
How do I roll back an order on error?
An FSM doesn't roll back. From processing, on supplier error, transition to cancelled or failed (terminal). No backward edges — they break the audit trail. If the FoxReload order failed, check the cancelReason field for context.
What goes in the audit log for each transition?
event_id (UUIDv4), order_id, from_state, to_state, timestamp, actor (user_id or 'system'), reason, source_ip, metadata (e.g., cancelReason on cancellation). Append-only; no updates on existing rows.
Get FoxReload API access

Related articles