# Order lifecycle (https://prood.com/docs/architecture/order-lifecycle)

## Overview [#overview]

Prood uses a **three-dimensional order status model** inspired by [Commerce Layer](https://commercelayer.io). 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 [#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 [#state-machine]

```
placed → approved → fulfilled
  ↓         ↓
cancelled  cancelled
```

## Payment status [#payment-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 [#transitions]

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

For auto-capture providers (Stripe default), `unpaid` transitions directly to `paid`.

## Fulfillment status [#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 [#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 [#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 [#complete-lifecycle-example]

### Happy path (card payment, physical goods) [#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 [#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 [#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) [#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 [#database-schema]

```sql
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 [#typescript-types]

```ts
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 [#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 [#order-history]

Every status change is recorded in the `order_history` table:

```sql
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.

## Related pages [#related-pages]

<Cards>
  <Card title="Checkout flow" href="/docs/architecture/checkout-flow" description="How the checkout session creates orders and initiates payment." />
  <Card title="Dashboard orders" href="/docs/apps/dashboard/orders" description="Managing orders in the admin UI." />
  <Card title="Webhook setup" href="/docs/guides/webhook-setup" description="Configuring Stripe webhooks for payment events." />
  <Card title="Unified types" href="/docs/architecture/unified-types" description="The Order type definition in @prood/types." />
</Cards>
