Order lifecycle
The orders row owns business state only: status, payout_status, fulfillment_method. What the payment provider thinks lives on a 1:1 payments row; what the carrier or courier thinks lives on a 1:1 fulfillments row. The machine fires normalized events, and webhook handlers stamp payments.provider_status_raw / fulfillments.provider_status_raw, so the provider's own trail survives next to our normalized reading of it.
For the deeper dive on shipping money flow, label-buy timing, and cancellation refunds, see Fulfillment & money flow.
Canonical statuses
| Status | Meaning |
|---|---|
pending_payment | Order created, payment not confirmed. The listing is reserved. |
active | Payment confirmed. Seller is fulfilling. Splits pre/post-ship by orders.shippedAt. |
awaiting_buyer_confirmation | Delivered or handed off. The buyer's acceptance window is running (acceptanceWindowDays, 2 in both live markets). |
completed | Funds released. The listing moves reserved → sold. |
cancelled | Terminal cancel. The listing moves reserved → available. |
disputed | Buyer raised a dispute during the acceptance window. payout_status = blocked; the dispute region is active. |
Fulfillment method (shipping | same_day | meetup) and payment status (pending | analyzing | paid | failed | expired | refunded | chargeback) are independent dimensions — see the diagrams below for how they fan out.
Payment flow (PIX vs credit card)
What happens when a buyer clicks "place order" — the two payment paths diverge here.
| Method | Initial payments.status | Confirmation | Timeout |
|---|---|---|---|
| PIX (BR) | pending | Provider webhook after the buyer scans the QR | 30 min, then a cron cancels |
| Credit card | analyzing | Provider webhook once anti-fraud passes | the provider's own timeout |
In both cases the listing is reserved immediately at order creation, before payment confirms. If payment fails or expires, a cron releases it back to available.
Happy path — shipping (carrier)
The standard flow when the seller ships with a carrier.
No diagram code provided
Happy path — same-day (Lalamove / Uber Direct)
An on-demand courier, dispatched once the seller marks the parcel ready.
No diagram code provided
Happy path — meetup
In-person handoff. Buyer and seller agree a time and place in chat, and the buyer confirms the handoff in one tap.
No diagram code provided
Dispute path
Buyer raises a dispute during the acceptance window. The dispute itself is the machine's fifth region, mirroring disputes.status (none until a row exists): triage, party questions and the decision all move that region, and every move is an order event with a dispute from/to pair on order_events. A closure event is fired twice: the deciding side records it on the region, and the saga fires it again (role: system) after the settlement claim to move the order and the payout. The region, its role rules and the money fence are on the dispute region page.
No diagram code provided
Cancellation paths
Orders can be cancelled at two distinct stages — pre-payment, or after payment but before shipping. Once an order is shipped (active + shippedAt set) or delivered, cancellation gives way to the dispute flow.
No diagram code provided
| Stage | Who can cancel | Trigger / reason |
|---|---|---|
pending_payment | buyer, cron (payment_expired), webhook (payment_failed) | Buyer abandons / payment fails / 30-min PIX timeout |
active, shippedAt = null | buyer, seller, admin, cron (ship_deadline_expired) | Manual cancel, or the ship deadline missed |
active, shippedAt set | — | Cannot cancel once in transit; only dispute_opened after delivery |
disputed | the saga, after an admin resolves for the buyer (dispute_resolved_refund) | Dispute resolved in buyer's favor |
Full state diagram
All possible transitions of the order region; the payment, fulfillment, payout and dispute regions move alongside it.
No diagram code provided
Guards
| Guard | Condition | Used on |
|---|---|---|
methodTakesEvent | the method's traits take the event (methodTakes, fulfillments/fulfillment.methods.ts) | every method-owned event: the label events and seller_shipped (shipping), courier_dispatched (same-day), courier_in_transit and delivery_confirmed (both), handoff_confirmed (meetup); ship_deadline_expired where a deadline runs |
notShipped | orders.shippedAt IS NULL | ship_deadline_expired (don't auto-cancel an in-transit order) |
noActiveDispute | the dispute region is none or a closure | auto_accept_timeout |
canOpenDispute | the dispute region is none and the event is fired by a buyer, seller or admin | dispute_opened |
settlesResolvedRefund / settlesResolvedSeller / settlesResolvedPartial / settlesWithdrawn / settlesDismissed | the dispute region already holds the matching closure and the event is fired by system (the saga's second fire) | the five dispute closures on the order and payout regions |
byAdmin, byBuyerOrAdmin, bySellerOrAdmin, byBuyer, bySeller, asksBuyer, asksSeller | the event's role / party | the dispute region's own edges |
See packages/commerce/orders/order.status-projection.ts for the full list.
Listing status follows the order
| Order status | Listing status |
|---|---|
pending_payment / active / awaiting_buyer_confirmation / disputed | reserved |
completed | sold |
cancelled | available |
Who changes the status, and when
| Transition | Event | Actor | Trigger | Notes |
|---|---|---|---|---|
→ pending_payment | (order created) | system (checkout API) | Buyer places order | Item: available → reserved; payment + fulfillment rows created |
pending_payment → active | payment_confirmed | webhook | Payment settled | payments.status = paid, payout_status = hold, fulfillments.status = awaiting_seller; seller's held funds credited |
pending_payment → cancelled | payment_failed | webhook | Anti-fraud declined / payment failed | Item released |
pending_payment → cancelled | payment_expired | cron | The charge expired | Listing released |
pending_payment → cancelled | buyer_cancel | buyer | Buyer abandons checkout | Item released |
active (no state change) | label_purchased / label_generated | system / webhook | Seller clicks "Print label" — see Fulfillment | Sets fulfillments.gatewayShipmentId, labelUrl. orders.shippedAt still null. |
active (no state change) | seller_shipped | webhook | Carrier records the parcel as posted | orders.shippedAt = now, fulfillments.status = posted |
active (no state change) | courier_dispatched / courier_in_transit | webhook | Same-day courier flow | fulfillments.status = courier_dispatched / in_transit |
active → awaiting_buyer_confirmation | delivery_confirmed | webhook | Carrier marks it delivered | acceptByAt = now + the acceptance window, fulfillments.status = delivered |
active → awaiting_buyer_confirmation | handoff_confirmed | buyer | Buyer confirms the meetup handoff | fulfillments.status = handoff_confirmed, acceptByAt = now + the acceptance window |
active → cancelled | ship_deadline_expired | cron | Ship deadline passed, nothing posted | Refund issued, listing released. Guarded by notShipped. |
active → cancelled | buyer_cancel / seller_cancel / admin_cancel | buyer / seller / admin | Manual | Refund + item released |
awaiting_buyer_confirmation → completed | buyer_accepted | buyer | Buyer clicks "Confirm receipt" | payout_status = released, item: reserved → sold |
awaiting_buyer_confirmation → completed | auto_accept_timeout | cron | 2 days post-delivery, no dispute | payout_status = released. Guarded by noActiveDispute. |
awaiting_buyer_confirmation → disputed | dispute_opened | buyer | Buyer raises dispute | Dispute region none → open; auto-accept blocked, payout_status = blocked. The open fence in order.claims.ts refuses it once a settlement claim is standing. |
disputed (no state change) | dispute_triaged / dispute_party_asked / dispute_party_responded / dispute_evidence_added | admin / the awaited party | Case work | Only the dispute region moves |
disputed (no state change) | any of the five closures, first fire | admin (or buyer withdrawing) | The decision | Dispute region enters its closure; order and payout wait for the saga |
disputed → cancelled | dispute_resolved_refund, second fire | system (saga, after the settlement claim) | Dispute resolved for buyer | Buyer refunded, payments.status = refunded |
disputed → completed | dispute_resolved_seller / dispute_withdrawn / dispute_dismissed, second fire | system | Dispute closed in the seller's favour | payout_status = released |
disputed → completed | dispute_resolved_partial, second fire | system | Split decision | payments.status = partially_refunded, payout_status = released minus the refunded portion |
Deadlines & timers
| Timer | Window | Owner | What happens at zero |
|---|---|---|---|
| PIX payment (BR) | 30 minutes (pixChargeExpiryMs) | cron (expire-pending-payments) | payment_expired → cancelled |
| Card anti-fraud | provider-managed | the payment provider | A webhook confirms or fails the payment |
| Ship deadline | 7 days from payment_confirmed (shipDeadlineMs) | cron (overdue-orders sweep) | ship_deadline_expired → cancelled + refund (gated by notShipped) |
| Auto-accept | 2 days from delivery or handoff (acceptanceWindowDays) | cron (auto-accept-orders) | auto_accept_timeout → completed + funds released (gated by noActiveDispute) |