Order States
14 states, 11 enum-backed + 3 DB-only return states, fully constrained. The order state machine is the most important contract in the platform. Every state transition is enforced by Postgres
CHECKconstraint and protected by optimistic locking.
The full state machine
All 14 states (11 enum-backed + 3 DB-only return states)
Happy path (5 states)
| State | What it means | Set by |
|---|---|---|
AWAITING_PAYMENT | Order created, waiting for Xendit confirmation | System on order creation |
READY_TO_SHIP | Payment confirmed, seller can generate label | Xendit webhook |
SHIPPED | Seller created Biteship label | Seller action |
DELIVERED | Biteship confirmed delivery | Biteship webhook |
COMPLETED | Buyer confirmed receipt or 24hr passed | Buyer action or auto-confirm |
Disputes & returns (5 states)
| State | What it means | Set by |
|---|---|---|
RETURN_REQUESTED | Buyer requested return within 24hr | Buyer action |
RETURN_APPROVED | Seller approved (or auto-approved under IDR 50k) | Seller action or system |
RETURN_RECEIVED | Seller confirmed receipt of returned item | Seller action |
DISPUTE_OPEN | Disagreement escalated to ops | Buyer or seller |
DISPUTE_RESOLVED | Ops made decision on dispute | Ops |
Edge cases & end states (4 states)
| State | What it means | Set by |
|---|---|---|
LATE_TO_SHIP | Seller missed 2-day ship deadline | System (timer) |
PAYMENT_REVIEW | Xendit webhook amount mismatched | System on webhook |
REFUNDED | Funds returned to buyer from escrow | Ops or system |
CANCELLED | Order voided before completion | System or ops |
Time windows
Five timers govern the lifecycle:
| Window | Duration | Trigger | Effect on expiry |
|---|---|---|---|
| Payment | 12 hours | Order created | Auto-CANCELLED |
| Ship deadline | 2 days | READY_TO_SHIP | Auto-LATE_TO_SHIP |
| Return window | 24 hours | DELIVERED | Window closes, no returns allowed |
| Auto-confirm | 24 hours | DELIVERED | Auto-COMPLETED, escrow released |
| Dispute window | 24 hours | DELIVERED | Window closes (only seller-fault returns) |
Auto-release / auto-confirm 24 hours: unified with the dispute window in code (PS-1112), the earlier 48h auto-confirm was removed. Return window 24 hours: code aligned in routes-orders.tsx:1734 (PS-155, 2026-05-05).
Transition triggers
Every transition has exactly one valid trigger:
State change protections
Three layers prevent race conditions and invalid transitions:
1. DB CHECK constraint
Postgres rejects any state value not in the allowed list. New states require a migration.
ALTER TABLE orders ADD CONSTRAINT valid_order_status
CHECK (order_status IN (
'AWAITING_PAYMENT', 'READY_TO_SHIP', 'LATE_TO_SHIP', 'SHIPPED',
'DELIVERED', 'COMPLETED', 'DISPUTE_OPEN', 'DISPUTE_RESOLVED',
'REFUNDED', 'CANCELLED', 'PAYMENT_REVIEW',
'RETURN_REQUESTED', 'RETURN_APPROVED', 'RETURN_RECEIVED'
));
2. Optimistic locking
Every order has a version column. Updates use the pattern:
UPDATE orders
SET order_status = 'READY_TO_SHIP', version = version + 1
WHERE id = $1 AND version = $2 AND order_status = 'AWAITING_PAYMENT'
If the version or expected current state doesn't match, the update fails. Caller must refetch and retry.
3. Application-layer state guards
Domain code in domain/order/ enforces business rules on top of DB constraints:
- Can't transition
READY_TO_SHIP→DELIVERED(must go throughSHIPPED) - Can't transition
RETURN_REQUESTED→COMPLETED(must go through resolution) - Can't approve return on order older than return window
Forbidden transitions
These transitions are explicitly not allowed and will be rejected:
| From | To | Why blocked |
|---|---|---|
AWAITING_PAYMENT | SHIPPED | Must pay first |
READY_TO_SHIP | DELIVERED | Must be shipped first |
DELIVERED | READY_TO_SHIP | No going backwards |
COMPLETED | anything | Terminal state |
REFUNDED | anything | Terminal state |
CANCELLED | anything | Terminal state |
Code references
| What you want | Where it lives |
|---|---|
| State enum | domain/order/OrderStatus.ts |
| State machine logic | domain/order/OrderStateMachine.ts |
| Use cases (one per transition) | domain/order/use-cases/ |
| DB CHECK migration | supabase/migrations/20260424000001_order_states.sql |
| Optimistic locking | infrastructure/repositories/OrderRepository.ts |
Auditability
Every state transition is logged to audit_log with:
- Previous state
- New state
- Trigger (system, buyer, seller, ops)
- Actor user_id
- Timestamp
- Optional reason
This makes every order's lifecycle reconstructible for dispute resolution.
Common state-related questions
Q: What happens if the Xendit webhook never fires?
A: Order stays AWAITING_PAYMENT until 12hr timer auto-cancels. OutboxProcessor also polls Xendit's status API as a backup.
Q: Can sellers cancel their own orders?
A: No. Once READY_TO_SHIP, only ops can cancel.
Q: What happens if a Biteship delivery webhook is missed?
A: Order stays SHIPPED. Ops dashboard surfaces orders shipped >5 days ago without delivery confirmation for manual intervention.
Q: Can a buyer dispute after 24 hours?
A: No. Dispute window closes with the return window. After that, COMPLETED is final.
Q: Why is PAYMENT_REVIEW separate from AWAITING_PAYMENT?
A: AWAITING_PAYMENT means we're waiting for Xendit. PAYMENT_REVIEW means Xendit paid us but the amount was wrong. Different problems, different recovery paths.
Next: Returns & Disputes →