Checkout Module Overview
Turning a cart into a validated, price-frozen purchase attempt that holds stock — one transaction, no saga.
Audience: Product owners, QA, frontend and backend developers Scope: The checkout surface, the one-transaction model, and the payment contract the Payment module inherits
Checkout Module - Overview
1. What the module is
Checkout turns a cart into a validated, price-frozen purchase attempt that holds stock. Three customer routes and four admin routes. Nothing here takes money — payment is a separate module that does not exist yet.
A customer can: start checkout (POST /api/mobile/checkout), fetch their live session (GET /api/mobile/checkout/active), and cancel it (POST /:id/cancel). Support can: list sessions, read one, and release a session by cancel or force-expire.
The cart, promotion, inventory and shipping modules were not modified — zero files. Checkout consumes their contracts.
2. The routes
| Surface | Route | Permission |
|---|---|---|
| Customer | POST /api/mobile/checkout | — |
| Customer | GET /api/mobile/checkout/active | — |
| Customer | POST /api/mobile/checkout/:id/cancel | — |
| Admin | GET /api/checkout/sessions | Checkout_READ |
| Admin | GET /api/checkout/sessions/:id | Checkout_READ |
| Admin | POST /api/checkout/sessions/:id/cancel | Checkout_UPDATE |
| Admin | POST /api/checkout/sessions/:id/expire | Checkout_UPDATE |
{id} is always a uuid7 public_id; no integer primary key is exposed.
3. The rules that matter
- It is ONE database transaction, not a saga. Cart, inventory, promotion, shipping and address are modules over the same PostgreSQL database, and every write path accepts a
DbExecutor— so validation, both reservations and the freeze commit together or not at all. A failed checkout leaves nothing behind: no orphan hold, no locked cart. This is why the module is small. - The holds outlive the session on purpose. Inventory and promotion each sweep their own holds on their own schedule, and neither has heard of a checkout session — a hold reserved for only as long as the session lives would be reclaimed while the customer was still at the payment gateway.
- Expiry is a read predicate, never a stored status. A session past its
expires_atis expired to every reader whether or not the sweep has run. payment_in_progressis exempt from the sweep — releasing stock under a customer at a gateway turns a successful charge into an unfulfillable order. The cost: a gateway that never calls back leaves a session only an administrator can clear, which is what the admin force-expire route is for.- A declined card does not destroy the basket. A retryable failure returns the session to
pending_paymentwith the holds intact. - Every payment-side transition is attempt-scoped, not merely idempotent — the retry makes the state machine cyclic.
- Everything on a session is a snapshot. Product name, SKU, price, MRP, the promotion's name and what it was worth, the whole shipping address. A receipt must never join
productorpromotion— an operator editing one would otherwise rewrite the history of every past purchase.
Page guide
| Page | Read it for |
|---|---|
| Features and flows | Actor journeys, the session lifecycle, error-recovery flows, edge cases |
| Backend | The one-transaction model, holds/TTL, expiry-as-predicate, the sweep exemption, the totals invariant |
| API | All seven endpoints, DTOs and error codes |