Order Lifecycle
Orders have three independent status axes. They are not a single state machine — each axis advances independently based on different events.
The Three Axes
orderStatus — Business process state
draft → quote → claimed → confirmed → [cancelled]
| Status | Meaning |
|---|---|
draft | Created by staff, not yet sent to customer |
quote | Staff has published and sent to customer |
claimed | Customer has linked the order to their account |
confirmed | Staff has converted the quote to a confirmed order |
cancelled | Order is cancelled (terminal) |
paymentStatus — Payment state
unpaid → awaiting_payment → paid → [refunded]
| Status | Meaning |
|---|---|
unpaid | No payment initiated |
awaiting_payment | Staff has flagged order as ready for payment |
paid | Payment verified |
refunded | Payment has been refunded (terminal) |
fulfillmentStatus — Physical build and shipping (nullable)
null → awaiting_shipment → building → testing → ready → packaging → shipped → completed
fulfillmentStatus starts null and is only set when a build is created and the build status first changes. awaiting_shipment is a valid first value (for orders that don't require a build).
| Status | Meaning |
|---|---|
awaiting_shipment | Waiting for parts |
building | Assembly in progress |
testing | Burn-in and QA |
ready | Ready to ship |
packaging | Being packaged |
shipped | Shipped to customer |
completed | Delivered (terminal) |
Valid Transitions (src/orders/order-transitions.ts)
assertOrderStatusTransition(from, to) — throws BadRequestException on invalid transition:
| From | Allowed To |
|---|---|
draft | quote, claimed, confirmed, cancelled |
quote | claimed, confirmed, cancelled |
claimed | confirmed, cancelled |
confirmed | cancelled |
cancelled | (nothing) |
assertPaymentStatusTransition(from, to):
| From | Allowed To |
|---|---|
unpaid | awaiting_payment |
awaiting_payment | paid, unpaid |
paid | refunded |
refunded | (nothing) |
assertFulfillmentStatusTransition(from, to):
| From | Allowed To |
|---|---|
null | awaiting_shipment, building |
awaiting_shipment | building |
building | testing |
testing | ready |
ready | packaging |
packaging | shipped |
shipped | completed |
completed | (nothing) |
Setting fulfillmentStatus to null always throws — cannot un-set once started.
What Triggers Each Transition
orderStatus transitions
| Transition | Trigger |
|---|---|
draft → quote | Staff calls PATCH /orders/:id with orderStatus: 'quote' and/or POST /orders/:id/publish |
quote → claimed | Customer claims order via POST /claim |
* → confirmed | Staff calls POST /orders/:id/convert (also sets type → order) |
* → cancelled | Staff calls PATCH /orders/:id with orderStatus: 'cancelled' |
paymentStatus transitions
| Transition | Trigger |
|---|---|
unpaid → awaiting_payment | Staff calls PATCH /orders/:id with paymentStatus: 'awaiting_payment' |
awaiting_payment → paid | Payment verified via POST /payments/verify or Viva webhook |
paid → refunded | Staff calls PATCH /orders/:id with paymentStatus: 'refunded' |
fulfillmentStatus transitions
Driven by build status changes in BuildsService.update() via the BUILD_TO_FULFILLMENT mapping:
| Build status | → Fulfillment status |
|---|---|
in_progress | building |
testing | testing |
completed | ready |
failed | (not mapped — transient state) |
Staff can also set fulfillmentStatus directly via PATCH /orders/:id (e.g., ready → packaging → shipped → completed).
Status History (OrderStatusHistory)
Every transition is logged immutably:
{
orderId: string,
statusField: 'orderStatus' | 'paymentStatus' | 'fulfillmentStatus',
fromValue: string | null,
toValue: string,
note: string | null,
changedById: string | null, // null = system/automated
createdAt: Date,
}
This is an append-only audit log. Staff can also create manual history entries (with a note) without changing the actual status, which is used for the "customer accepted quote" event.
Packaging Gate
OrdersService.update() enforces a packaging gate before allowing fulfillmentStatus → packaging:
- The build must have 9 of 10 photo slots filled (
thermalPhotois excluded from the check — it's optional) build.qaChecklistmust not be empty
This prevents packaging an order without completing the QA and photo documentation requirements.
Claim Flow
- Staff creates order (with or without a
userId) - If no
userId, aclaimCodeis generated (NNN-NNNN-NNN) - Staff publishes the order — customer receives email with claim link
- Customer navigates to
/quote/[claimCode](public page) - Customer creates account or logs in
POST /claim—ClaimService.claim():- Validates
claimCodeexists - If already claimed by another user →
ForbiddenException - Advances
orderStatus → claimed - Sets
userId, nullsclaimCode - Sends
orderClaimedTemplateemail
- Validates
Quote Acceptance Flow
- Customer views claimed order in portal
- Customer clicks "Accept quote"
POST /portal/orders/:id/accept-quotePortalOrdersService.acceptQuote():- Validates
orderStatusisquoteorclaimed - Creates
OrderStatusHistoryentry: same status, note "Customer accepted the quote via portal" - Sends
quoteAcceptedStaffTemplatetoSTAFF_NOTIFY_EMAIL
- Validates
- Staff receives email notification, reviews, and calls
POST /orders/:id/convert
Why no orderStatus change on accept? Acceptance is the customer's intent, not a business state change. Staff must explicitly convert to confirm the order is ready to proceed to payment.
Warranty Activation
When fulfillmentStatus transitions to completed (via OrdersService.update()):
- All
OrderWarrantyitems on the order are queried - For each:
active = true,startDate = now,endDate = now + durationMonths - This triggers the
WarrantyNotificationsServicecron to start monitoring these warranties
Email Notifications Per Transition
| Event | Recipient | Template |
|---|---|---|
| Order published with known user | Customer | quoteCreatedTemplate |
orderStatus → anything while publishing | Customer | quoteCreatedTemplate |
paymentStatus → awaiting_payment | Customer | awaitingPaymentTemplate |
paymentStatus → paid | Customer | paymentConfirmedTemplate |
paymentStatus → refunded | Customer | refundedTemplate |
fulfillmentStatus → ready | Customer | readyToShipTemplate |
fulfillmentStatus → shipped | Customer | shippedTemplate (with tracking info) |
fulfillmentStatus → completed | Customer | deliveredTemplate |
orderStatus → cancelled | Customer | cancelledTemplate |
| Tracking number added to already-shipped order | Customer | shippedTemplate (resent) |
Build in_progress | Customer | buildStartedTemplate |
| Customer accepts quote | Staff (STAFF_NOTIFY_EMAIL) | quoteAcceptedStaffTemplate |
| Order claimed | Customer | orderClaimedTemplate |
| Warranty expiring (30 days) | Customer | warrantyExpiringTemplate |