Order lifecycle
Three-dimensional order status model — separating order, payment, and fulfillment into independent dimensions.
Overview
Prood uses a three-dimensional order status model inspired by Commerce Layer. Instead of a single status field that conflates payment and shipping state, orders track three independent dimensions:
| Dimension | Field | Purpose |
|---|---|---|
| Order lifecycle | status | Where the order is in its lifecycle |
| Payment | paymentStatus | Whether and how much has been paid |
| Fulfillment | fulfillmentStatus | Whether items have been shipped |
This separation makes it trivial to answer questions like "show me paid orders that haven't shipped yet" or "which orders have partial refunds" without parsing a single overloaded status field.
Order status
The primary lifecycle status of the order.
| Value | Description |
|---|---|
placed | Order submitted by customer — awaiting payment |
approved | Payment confirmed — ready for fulfillment |
fulfilled | All items shipped to customer |
cancelled | Order cancelled (payment failed or admin action) |
State machine
placed → approved → fulfilled
↓ ↓
cancelled cancelledPayment status
Independent from order status. Tracks the payment lifecycle.
| Value | Description |
|---|---|
unpaid | No payment received (default) |
authorized | Funds held but not captured (auth-only providers) |
paid | Payment captured — funds collected |
partially_refunded | Partial refund issued |
refunded | Full refund issued |
voided | Authorization voided — no funds collected |
free | Zero-amount order (100% coupon) |
Transitions
unpaid → authorized → paid → partially_refunded → refunded
↓ ↓
voided voidedFor auto-capture providers (Stripe default), unpaid transitions directly to paid.
Fulfillment status
Tracks shipping progress. Independent from payment — an order can be fulfilled before capture in delayed-capture scenarios.
| Value | Description |
|---|---|
unfulfilled | No items shipped (default) |
in_progress | Some items shipped (partial fulfillment) |
fulfilled | All items shipped |
not_required | Digital-only order — no physical shipping needed |
Per-item fulfillment
Each OrderItem also has its own fulfillmentStatus for granular tracking:
| Value | Applies to |
|---|---|
unfulfilled | Not yet shipped |
partially_fulfilled | Multi-quantity item, some shipped |
fulfilled | Shipped |
returned | Returned by customer |
download_ready | Digital product — download link available |
license_sent | Software license delivered |
access_granted | Service/subscription access enabled |
ticket_issued | Event ticket delivered |
Lifecycle timestamps
Each status transition records a timestamp:
| Field | Set when |
|---|---|
placedAt | Order is created via checkout |
approvedAt | Payment webhook confirms capture |
cancelledAt | Order is cancelled |
fulfilledAt | Admin ships the order |
createdAt | Record creation (same as placedAt for checkout orders) |
updatedAt | Any field is modified |
Complete lifecycle example
Happy path (card payment, physical goods)
| Step | Trigger | status | paymentStatus | fulfillmentStatus |
|---|---|---|---|---|
| Customer submits checkout | POST /carts/{id}/place-order | placed | unpaid | unfulfilled |
| Stripe captures payment | Webhook checkout.session.completed | approved | paid | unfulfilled |
| Admin ships order | POST /admin/orders/{id}/fulfill | fulfilled | paid | fulfilled |
Payment failure
| Step | Trigger | status | paymentStatus | fulfillmentStatus |
|---|---|---|---|---|
| Customer submits checkout | Place order | placed | unpaid | unfulfilled |
| Payment fails | Webhook checkout.session.async_payment_failed | cancelled | voided | unfulfilled |
Refund after fulfillment
| Step | Trigger | status | paymentStatus | fulfillmentStatus |
|---|---|---|---|---|
| Order shipped | — | fulfilled | paid | fulfilled |
| Admin issues full refund | POST /admin/orders/{id}/refund | cancelled | refunded | fulfilled |
Free order (100% coupon)
| Step | Trigger | status | paymentStatus | fulfillmentStatus |
|---|---|---|---|---|
| Checkout with coupon | Place order | placed | free | unfulfilled |
| Auto-approved (no payment needed) | — | approved | free | unfulfilled |
Database schema
CREATE TABLE orders (
id TEXT PRIMARY KEY,
order_number TEXT NOT NULL UNIQUE,
customer_id TEXT REFERENCES customers(id),
contact_email TEXT,
-- Three-dimensional status
status TEXT NOT NULL DEFAULT 'placed',
payment_status TEXT NOT NULL DEFAULT 'unpaid',
fulfillment_status TEXT NOT NULL DEFAULT 'unfulfilled',
-- Lifecycle timestamps
placed_at TIMESTAMPTZ,
approved_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
fulfilled_at TIMESTAMPTZ,
-- Totals, addresses, etc.
...
);TypeScript types
type OrderStatus = 'placed' | 'approved' | 'fulfilled' | 'cancelled'
type PaymentStatus =
| 'unpaid' | 'authorized' | 'paid'
| 'partially_refunded' | 'refunded'
| 'voided' | 'free'
type OrderFulfillmentStatus =
| 'unfulfilled' | 'in_progress' | 'fulfilled' | 'not_required'
interface Order {
status: OrderStatus
paymentStatus: PaymentStatus
fulfillmentStatus: OrderFulfillmentStatus
placedAt: string | null
approvedAt: string | null
cancelledAt: string | null
fulfilledAt: string | null
// ... other fields
}Webhook-driven transitions
The payment status is updated exclusively by webhooks from the payment provider. The API app's reconcilePayment handler maps provider events:
| Webhook event | Sets status | Sets paymentStatus |
|---|---|---|
checkout.session.completed / payment.captured | approved | paid |
checkout.session.async_payment_failed / payment.failed | cancelled | voided |
payment.refunded (Stripe charge.refunded, Easypay refund notification) | cancelled | refunded |
payment.partially_refunded | no change | partially_refunded |
A failure or refund about a payment the order no longer uses changes nothing: a shopper's retry replaces the payment on the order, and the abandoned one can still report in. A capture of another payment on an order that already has its money leaves one history note per transaction, because that money may be owed back.
Order history
Every status change is recorded in the order_history table:
CREATE TABLE order_history (
id TEXT PRIMARY KEY,
order_id TEXT REFERENCES orders(id),
from_status TEXT,
to_status TEXT NOT NULL,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);This provides a full audit trail visible in the dashboard order detail page.