Skip to main content

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 CHECK constraint and protected by optimistic locking.

The full state machine

All 14 states (11 enum-backed + 3 DB-only return states)

Happy path (5 states)

StateWhat it meansSet by
AWAITING_PAYMENTOrder created, waiting for Xendit confirmationSystem on order creation
READY_TO_SHIPPayment confirmed, seller can generate labelXendit webhook
SHIPPEDSeller created Biteship labelSeller action
DELIVEREDBiteship confirmed deliveryBiteship webhook
COMPLETEDBuyer confirmed receipt or 24hr passedBuyer action or auto-confirm

Disputes & returns (5 states)

StateWhat it meansSet by
RETURN_REQUESTEDBuyer requested return within 24hrBuyer action
RETURN_APPROVEDSeller approved (or auto-approved under IDR 50k)Seller action or system
RETURN_RECEIVEDSeller confirmed receipt of returned itemSeller action
DISPUTE_OPENDisagreement escalated to opsBuyer or seller
DISPUTE_RESOLVEDOps made decision on disputeOps

Edge cases & end states (4 states)

StateWhat it meansSet by
LATE_TO_SHIPSeller missed 2-day ship deadlineSystem (timer)
PAYMENT_REVIEWXendit webhook amount mismatchedSystem on webhook
REFUNDEDFunds returned to buyer from escrowOps or system
CANCELLEDOrder voided before completionSystem or ops

Time windows

Five timers govern the lifecycle:

WindowDurationTriggerEffect on expiry
Payment12 hoursOrder createdAuto-CANCELLED
Ship deadline2 daysREADY_TO_SHIPAuto-LATE_TO_SHIP
Return window24 hoursDELIVEREDWindow closes, no returns allowed
Auto-confirm24 hoursDELIVEREDAuto-COMPLETED, escrow released
Dispute window24 hoursDELIVEREDWindow closes (only seller-fault returns)
Code-vs-spec alignment (2026-04-28)

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_SHIPDELIVERED (must go through SHIPPED)
  • Can't transition RETURN_REQUESTEDCOMPLETED (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:

FromToWhy blocked
AWAITING_PAYMENTSHIPPEDMust pay first
READY_TO_SHIPDELIVEREDMust be shipped first
DELIVEREDREADY_TO_SHIPNo going backwards
COMPLETEDanythingTerminal state
REFUNDEDanythingTerminal state
CANCELLEDanythingTerminal state

Code references

What you wantWhere it lives
State enumdomain/order/OrderStatus.ts
State machine logicdomain/order/OrderStateMachine.ts
Use cases (one per transition)domain/order/use-cases/
DB CHECK migrationsupabase/migrations/20260424000001_order_states.sql
Optimistic lockinginfrastructure/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.

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 →