# Checkout flow (https://prood.com/docs/architecture/checkout-flow)

The `@prood/checkout` package provides a **framework-agnostic state machine** that orchestrates the complete checkout flow. It enforces valid transitions, emits events, and works identically in Node.js, Edge runtimes, and the browser.

In this monorepo, checkout sessions are hosted by `@prood/checkout-host` in `apps/checkout`, persisted in **Upstash Redis**.

## State machine [#state-machine]

```mermaid
stateDiagram-v2
  [*] --> idle
  idle --> info: setCustomerInfo()
  info --> shipping: setShippingAddress()
  info --> payment: submitPayment() pickup/none
  shipping --> payment: submitPayment()
  payment --> confirming: confirmPayment()
  payment --> complete: captured
  payment --> failed: declined
  confirming --> complete: provider OK
  confirming --> failed: provider error
  failed --> payment: retry
  complete --> [*]
```

The checkout flow adapts dynamically based on the **fulfillment type** set during session creation:

| Fulfillment | Flow | Skips |
| --- | --- | --- |
| `shipping` | idle → info → **shipping** → payment → confirming → complete | — |
| `local_delivery` | idle → info → **shipping** → payment → confirming → complete | — |
| `pickup` | idle → info → payment → confirming → complete | address + shipping |
| `none` | idle → info → payment → confirming → complete | address + shipping |

### State transitions [#state-transitions]

| State | Allowed transitions | Triggered by |
| --- | --- | --- |
| `idle` | `info` | `setCustomerInfo()` |
| `info` | `shipping`, `payment` | `setShippingAddress()` or `submitPayment()` (pickup/none) |
| `shipping` | `payment` | `submitPayment()` |
| `payment` | `confirming`, `failed` | `confirmPayment()`, webhook |
| `confirming` | `payment`, `complete`, `failed` | Back to `payment` while the provider reports it pending |
| `failed` | `payment` | Retry with new payment method |
| `complete` | — | Terminal state |

Invalid transitions throw errors — the state machine prevents skipping steps.

## End-to-end flow in Prood [#end-to-end-flow-in-prood]

```mermaid
sequenceDiagram
  participant C as Customer
  participant SF as Storefront
  participant API as Commerce API
  participant CHK as Checkout app
  participant PP as Payment provider
  participant WH as Webhooks

  C->>SF: Fill checkout form
  SF->>API: POST /v1/carts/{id}/place-order
  API-->>SF: Order created
  SF->>CHK: POST /api/sessions
  CHK-->>SF: checkoutUrl
  SF->>C: Redirect to /c/{sessionId}
  CHK->>CHK: loadSession() from Redis
  CHK->>C: Render payment UI
  C->>CHK: Submit payment
  CHK->>PP: createSession / charge
  opt 3DS required
    PP->>C: Redirect for verification
    C->>CHK: POST /api/sessions/{id}/confirm
  end
  PP->>WH: Async webhook
  WH->>API: forwardPaymentWebhook()
  CHK->>C: Redirect to order confirmation
```

## Step-by-step API usage [#step-by-step-api-usage]

### Create a session (programmatic) [#create-a-session-programmatic]

```ts
import { CheckoutSession } from '@prood/checkout'
import { StripePaymentProvider } from '@prood/payment-stripe'

const provider = new StripePaymentProvider({
  secretKey: config.secretKey, // the store's own, from integration_config
})

const session = new CheckoutSession({
  provider,
  amount: 99.99,       // major units
  currency: 'EUR',
  orderId: 'ord_123',
  returnUrl: 'https://store.example.com/order-confirmation',
  fulfillment: 'shipping',
})
```

### Set customer info (idle → info) [#set-customer-info-idle--info]

```ts
session.setCustomerInfo({
  email: 'customer@example.com',
  firstName: 'Maria',
  lastName: 'Silva',
  phone: '+351912345678',
})
// session.state === 'info'
```

### Set shipping address (info → shipping) [#set-shipping-address-info--shipping]

Only required when fulfillment is `shipping` or `local_delivery`:

```ts
session.setShippingAddress({
  street: 'Rua Augusta 123',
  street2: null,
  city: 'Lisboa',
  state: null,
  country: 'PT',
  postalCode: '1100-053',
})
// session.state === 'shipping'
```

For `pickup` or `none` fulfillment, call `submitPayment()` directly from `info`.

### Submit payment (shipping/info → payment) [#submit-payment-shippinginfo--payment]

```ts
const paymentSession = await session.submitPayment({
  sourceToken: 'pm_xxx',  // Stripe payment method ID
})

if (paymentSession.redirectUrl) {
  // Redirect customer to 3DS verification
}
// session.state === 'payment'
```

### Confirm payment (payment → confirming → complete) [#confirm-payment-payment--confirming--complete]

After 3DS redirect or synchronous capture:

```ts
const confirmed = await session.confirmPayment()
// session.state === 'complete' (if captured)
// session.state === 'failed' (if declined)
```

### Webhook safety net [#webhook-safety-net]

For asynchronous results, `handleWebhookUpdate()` can transition from any non-terminal state:

```ts
session.handleWebhookUpdate({
  id: event.id,
  providerId: 'stripe',
  status: 'captured',
  amount: 9999,
  currency: 'EUR',
  redirectUrl: null,
  createdAt: new Date().toISOString(),
})
```

## Sync-on-return vs webhook [#sync-on-return-vs-webhook]

Prood uses a **dual strategy** for payment confirmation:

| Method | When | How |
| --- | --- | --- |
| **Sync-on-return** | Customer redirected back from 3DS | `confirmPayment()` calls provider to verify |
| **Webhook** | Async notification from provider | `handleWebhookUpdate()` or API webhook handler updates order |

The webhook acts as a safety net. If the customer closes their browser during 3DS, or the redirect fails, the webhook still updates the order status.

## Events [#events]

`CheckoutSession` extends `EventEmitter`:

```ts
session.on('stateChange', ({ from, to }) => {
  console.log(`Checkout: ${from} → ${to}`)
})

session.on('complete', ({ paymentSession }) => {
  // Send confirmation email, update order status
})

session.on('error', ({ error, state }) => {
  // Log error, show failure UI
})

session.on('expired', () => {
  // Session TTL exceeded
})
```

## Session expiry [#session-expiry]

Sessions can be configured with a TTL:

```ts
const session = new CheckoutSession({
  provider,
  amount: 4500,
  currency: 'EUR',
  channel: 'pos',
  expiresIn: 30 * 60 * 1000, // 30 minutes
})
```

Once expired, all state-mutating methods throw and the `expired` event fires. The `expiresAt` timestamp is included in the snapshot for client-side countdown display.

## Retry logic [#retry-logic]

Failed payments can be retried — the state machine allows `failed → payment`:

```ts
if (session.state === 'failed') {
  const retry = await session.submitPayment({
    sourceToken: 'pm_new_card',
  })
}
```

## Snapshots [#snapshots]

`toSnapshot()` returns a serializable representation for SSR hydration, Redis persistence, or client display:

```ts
const snapshot = session.toSnapshot()
// {
//   state: 'payment',
//   channel: 'web',
//   fulfillment: 'shipping',
//   expiresAt: '2026-05-29T10:00:00.000Z',
//   customerInfo: { ... },
//   paymentSession: { id: 'pi_xxx', status: 'pending', ... },
//   amount: 9999,
//   currency: 'EUR',
//   error: null,
// }
```

`@prood/checkout-host` persists snapshots in Redis and rehydrates them on each request.

## Checkout URL strategy [#checkout-url-strategy]

Prood separates **where customers browse** from **where they pay**:

| Surface | Domain | Example |
| --- | --- | --- |
| Storefront | `{slug}.prood.app` or custom domain | `https://demo-store.prood.app` |
| Hosted checkout (Phase 1 — current) | `prood.com/pay` | `https://prood.com/pay/c/cs_...` |
| Store-hosted checkout (Phase 2 — planned) | Same as storefront host | `https://demo-store.prood.app/c/cs_...` |

### Phase 1 — shared checkout on prood.com [#phase-1--shared-checkout-on-proodcom]

After the storefront places an order, it creates a session via `POST {CHECKOUT_URL}/api/sessions` and redirects the customer to:

```
https://prood.com/pay/c/{sessionId}
```

- `CHECKOUT_URL=https://prood.com/pay` in production: the public base **including** the `/pay` path. Links are built by appending to it (`{CHECKOUT_URL}/c/{id}`, `{CHECKOUT_URL}/confirm/{id}`, `{CHECKOUT_URL}/api/webhooks/...`), never resolved against it
- `tenantId` is resolved from the storefront host
- `returnUrl` points back to the store host (e.g. `https://demo-store.prood.app/order-confirmation?orderId=...`)
- Webhooks: `https://prood.com/pay/api/webhooks/{provider}/{orgId}`

This is the simplest multi-tenant setup: one checkout deployment, tenant context on the session.

### Phase 2 — Shopify-like checkout on the store host (planned) [#phase-2--shopify-like-checkout-on-the-store-host-planned]

For stronger brand trust, payment URLs can move to the merchant's storefront host:

```
https://demo-store.prood.app/c/{sessionId}
```

Implementation options: reverse-proxy `/c/*` to the checkout app, or mount checkout session routes on the storefront. Requires passing `checkoutBaseUrl` from the storefront when creating the session instead of a global `CHECKOUT_URL` prefix.

## Related pages [#related-pages]

<Cards>
  <Card title="Checkout app" href="/docs/apps/checkout" description="Hosted payment app — sessions, routes, and Redis." />
  <Card title="Payment integration" href="/docs/guides/payment-integration" description="Configure Stripe, Easypay, and Ifthenpay." />
  <Card title="@prood/checkout package" href="/docs/packages/checkout" description="State machine API reference." />
</Cards>
