Prood
Architecture

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:

DimensionFieldPurpose
Order lifecyclestatusWhere the order is in its lifecycle
PaymentpaymentStatusWhether and how much has been paid
FulfillmentfulfillmentStatusWhether 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.

ValueDescription
placedOrder submitted by customer — awaiting payment
approvedPayment confirmed — ready for fulfillment
fulfilledAll items shipped to customer
cancelledOrder cancelled (payment failed, admin action, or timeout)

State machine

placed → approved → fulfilled
  ↓         ↓
cancelled  cancelled

Payment status

Independent from order status. Tracks the payment lifecycle.

ValueDescription
unpaidNo payment received (default)
authorizedFunds held but not captured (auth-only gateways)
paidPayment captured — funds collected
partially_refundedPartial refund issued
refundedFull refund issued
voidedAuthorization voided — no funds collected
freeZero-amount order (100% coupon/gift card)

Transitions

unpaid → authorized → paid → partially_refunded → refunded
  ↓         ↓
voided    voided

For auto-capture gateways (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.

ValueDescription
unfulfilledNo items shipped (default)
in_progressSome items shipped (partial fulfillment)
fulfilledAll items shipped
not_requiredDigital-only order — no physical shipping needed

Per-item fulfillment

Each OrderItem also has its own fulfillmentStatus for granular tracking:

ValueApplies to
unfulfilledNot yet shipped
partially_fulfilledMulti-quantity item, some shipped
fulfilledShipped
returnedReturned by customer
download_readyDigital product — download link available
license_sentSoftware license delivered
access_grantedService/subscription access enabled
ticket_issuedEvent ticket delivered

Lifecycle timestamps

Each status transition records a timestamp:

FieldSet when
placedAtOrder is created via checkout
approvedAtPayment webhook confirms capture
cancelledAtOrder is cancelled
fulfilledAtAdmin ships the order
createdAtRecord creation (same as placedAt for checkout orders)
updatedAtAny field is modified

Complete lifecycle example

Happy path (card payment, physical goods)

StepTriggerstatuspaymentStatusfulfillmentStatus
Customer submits checkoutPOST /carts/{id}/place-orderplacedunpaidunfulfilled
Stripe captures paymentWebhook checkout.session.completedapprovedpaidunfulfilled
Admin ships orderPOST /admin/orders/{id}/fulfillfulfilledpaidfulfilled

Payment failure

StepTriggerstatuspaymentStatusfulfillmentStatus
Customer submits checkoutPlace orderplacedunpaidunfulfilled
Card declinedWebhook payment_intent.payment_failedcancelledvoidedunfulfilled

Refund after fulfillment

StepTriggerstatuspaymentStatusfulfillmentStatus
Order shippedfulfilledpaidfulfilled
Admin issues full refundPOST /admin/orders/{id}/refundcancelledrefundedfulfilled

Free order (100% coupon)

StepTriggerstatuspaymentStatusfulfillmentStatus
Checkout with couponPlace orderplacedfreeunfulfilled
Auto-approved (no payment needed)approvedfreeunfulfilled

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 eventSets statusSets paymentStatus
checkout.session.completed / payment.capturedapprovedpaid
payment_intent.payment_failedcancelledvoided
charge.refunded (full)cancelledrefunded
charge.refunded (partial)no changepartially_refunded

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.

Migration from legacy statuses

Existing orders with legacy single-status values are automatically migrated:

Legacy statusNew statusNew paymentStatusNew fulfillmentStatus
pendingplacedunpaidunfulfilled
processingapprovedpaidunfulfilled
shippedfulfilledpaidfulfilled
deliveredfulfilledpaidfulfilled
refundedcancelledrefundedunfulfilled
returnedcancelledrefundedunfulfilled

On this page