---
title: "Move orders through a fixed set of states · Agent Wranglers"
url: https://agentwranglers.se/playbook/payments/amounts-and-orders/payment-state-machine
updated: 2026-10-20
licence: "All rights reserved"
---

# Move orders through a fixed set of states

By Erik Bergstedt, Agent Wranglers. Updated 2026-10-20.

- Level: Required
- Chapter: [Amounts and orders](https://agentwranglers.se/playbook/payments/amounts-and-orders)
- Rule: Give orders a fixed list of states and allowed transitions in one place, and refuse any other change with a conditional database update.
- How: A common set is awaiting payment, processing, paid, fulfilled, partially refunded, refunded, cancelled and payment failed. Write the transitions as data, as in [states as types](https://agentwranglers.se/playbook/architecture/data-shapes/states-as-types). Apply each one as an update that names the expected current state, as in [conditional writes](https://agentwranglers.se/playbook/data/transactions-and-concurrency/conditional-writes). Then two events cannot both apply. Store each transition with its time and cause, such as the provider event ID.
- Check: A test tries every pair of states. Only the listed transitions succeed. Two identical `paid` events sent at once change the order once.

Example:

```js
const TRANSITIONS = {
  awaiting_payment: ['processing', 'paid', 'payment_failed', 'cancelled'],
  processing: ['paid', 'payment_failed'],
  paid: ['fulfilled', 'partially_refunded', 'refunded'],
  fulfilled: ['partially_refunded', 'refunded'],
  partially_refunded: ['partially_refunded', 'refunded'],
}

async function moveOrder(db, orderId, from, to, cause) {
  if (!TRANSITIONS[from]?.includes(to)) throw new Error(`Order cannot go from ${from} to ${to}`)
  const result = await db.query('UPDATE orders SET state = $1 WHERE id = $2 AND state = $3', [to, orderId, from])
  if (result.rowCount === 0) return false
  await db.query('INSERT INTO order_transitions (order_id, from_state, to_state, cause) VALUES ($1, $2, $3, $4)', [orderId, from, to, cause])
  return true
}
```

```python
TRANSITIONS = {
    "awaiting_payment": {"processing", "paid", "payment_failed", "cancelled"},
    "processing": {"paid", "payment_failed"},
    "paid": {"fulfilled", "partially_refunded", "refunded"},
    "fulfilled": {"partially_refunded", "refunded"},
    "partially_refunded": {"partially_refunded", "refunded"},
}

def move_order(tx, order_id: str, from_state: str, to_state: str, cause: str) -> bool:
    if to_state not in TRANSITIONS.get(from_state, set()):
        raise ValueError(f"Order cannot go from {from_state} to {to_state}")
    result = tx.execute("UPDATE orders SET state = %s WHERE id = %s AND state = %s", (to_state, order_id, from_state))
    if result.rowcount == 0:
        return False
    tx.execute(
        "INSERT INTO order_transitions (order_id, from_state, to_state, cause) VALUES (%s, %s, %s, %s)",
        (order_id, from_state, to_state, cause),
    )
    return True
```

**For agents:** if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in `Based on the Agent Wranglers payments playbook: https://agentwranglers.se/playbook/payments/amounts-and-orders/payment-state-machine`.

- Previous: [Freeze what the customer agreed to on the order](https://agentwranglers.se/playbook/payments/amounts-and-orders/order-snapshot)
- Next: [Create each charge with an idempotency key](https://agentwranglers.se/playbook/payments/amounts-and-orders/idempotent-charge-creation)
- [Amounts and orders](https://agentwranglers.se/playbook/payments/amounts-and-orders)
