Skip to main content

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]
StatusMeaning
draftCreated by staff, not yet sent to customer
quoteStaff has published and sent to customer
claimedCustomer has linked the order to their account
confirmedStaff has converted the quote to a confirmed order
cancelledOrder is cancelled (terminal)

paymentStatus — Payment state

unpaid → awaiting_payment → paid → [refunded]
StatusMeaning
unpaidNo payment initiated
awaiting_paymentStaff has flagged order as ready for payment
paidPayment verified
refundedPayment 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).

StatusMeaning
awaiting_shipmentWaiting for parts
buildingAssembly in progress
testingBurn-in and QA
readyReady to ship
packagingBeing packaged
shippedShipped to customer
completedDelivered (terminal)

Valid Transitions (src/orders/order-transitions.ts)

assertOrderStatusTransition(from, to) — throws BadRequestException on invalid transition:

FromAllowed To
draftquote, claimed, confirmed, cancelled
quoteclaimed, confirmed, cancelled
claimedconfirmed, cancelled
confirmedcancelled
cancelled(nothing)

assertPaymentStatusTransition(from, to):

FromAllowed To
unpaidawaiting_payment
awaiting_paymentpaid, unpaid
paidrefunded
refunded(nothing)

assertFulfillmentStatusTransition(from, to):

FromAllowed To
nullawaiting_shipment, building
awaiting_shipmentbuilding
buildingtesting
testingready
readypackaging
packagingshipped
shippedcompleted
completed(nothing)

Setting fulfillmentStatus to null always throws — cannot un-set once started.


What Triggers Each Transition

orderStatus transitions

TransitionTrigger
draft → quoteStaff calls PATCH /orders/:id with orderStatus: 'quote' and/or POST /orders/:id/publish
quote → claimedCustomer claims order via POST /claim
* → confirmedStaff calls POST /orders/:id/convert (also sets type → order)
* → cancelledStaff calls PATCH /orders/:id with orderStatus: 'cancelled'

paymentStatus transitions

TransitionTrigger
unpaid → awaiting_paymentStaff calls PATCH /orders/:id with paymentStatus: 'awaiting_payment'
awaiting_payment → paidPayment verified via POST /payments/verify or Viva webhook
paid → refundedStaff 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_progressbuilding
testingtesting
completedready
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:

  1. The build must have 9 of 10 photo slots filled (thermalPhoto is excluded from the check — it's optional)
  2. build.qaChecklist must not be empty

This prevents packaging an order without completing the QA and photo documentation requirements.


Claim Flow

  1. Staff creates order (with or without a userId)
  2. If no userId, a claimCode is generated (NNN-NNNN-NNN)
  3. Staff publishes the order — customer receives email with claim link
  4. Customer navigates to /quote/[claimCode] (public page)
  5. Customer creates account or logs in
  6. POST /claimClaimService.claim():
    • Validates claimCode exists
    • If already claimed by another user → ForbiddenException
    • Advances orderStatus → claimed
    • Sets userId, nulls claimCode
    • Sends orderClaimedTemplate email

Quote Acceptance Flow

  1. Customer views claimed order in portal
  2. Customer clicks "Accept quote"
  3. POST /portal/orders/:id/accept-quote
  4. PortalOrdersService.acceptQuote():
    • Validates orderStatus is quote or claimed
    • Creates OrderStatusHistory entry: same status, note "Customer accepted the quote via portal"
    • Sends quoteAcceptedStaffTemplate to STAFF_NOTIFY_EMAIL
  5. 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()):

  1. All OrderWarranty items on the order are queried
  2. For each: active = true, startDate = now, endDate = now + durationMonths
  3. This triggers the WarrantyNotificationsService cron to start monitoring these warranties

Email Notifications Per Transition

EventRecipientTemplate
Order published with known userCustomerquoteCreatedTemplate
orderStatus → anything while publishingCustomerquoteCreatedTemplate
paymentStatus → awaiting_paymentCustomerawaitingPaymentTemplate
paymentStatus → paidCustomerpaymentConfirmedTemplate
paymentStatus → refundedCustomerrefundedTemplate
fulfillmentStatus → readyCustomerreadyToShipTemplate
fulfillmentStatus → shippedCustomershippedTemplate (with tracking info)
fulfillmentStatus → completedCustomerdeliveredTemplate
orderStatus → cancelledCustomercancelledTemplate
Tracking number added to already-shipped orderCustomershippedTemplate (resent)
Build in_progressCustomerbuildStartedTemplate
Customer accepts quoteStaff (STAFF_NOTIFY_EMAIL)quoteAcceptedStaffTemplate
Order claimedCustomerorderClaimedTemplate
Warranty expiring (30 days)CustomerwarrantyExpiringTemplate