Checkout Backend Documentation
Backend architecture, data model, services, and operational behavior for the Checkout module.
Checkout - Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/checkout/ module files | Customer + admin leaves, worker module |
| Controllers | customer/checkout-customer.controller.ts, admin/checkout-admin.controller.ts | Routes, permissions, rate limits |
| Services | shared/checkout-session.service.ts, checkout-payment.service.ts, checkout-release.service.ts, checkout-access.service.ts | One transaction, transitions, release |
| Utilities | shared/checkout-transition.util.ts, checkout-validation.util.ts, checkout-totals.util.ts, checkout-errors.util.ts | Attempt scoping, validation, totals |
| Schema | packages/db/src/schema/checkout/*.ts | Grand-total CHECK, partial uniques, expiry |
| Workers | workers/checkout-expiry-sweep.processor.ts, checkout-maintenance.scheduler.ts | The sweep and its exemption |
2. Backend Scope and Boundaries
Owns
- The three checkout session tables and the seven routes.
- The one-transaction checkout flow, the expiry sweep, and the payment contract (
markPaymentStarted/complete/fail) that the Payment module inherits.
Does Not Own
- Money. No route charges anything; payment is a separate module that does not exist yet.
- Orders. A completed session becomes an order elsewhere.
- The cart, promotion, inventory and shipping modules — zero files in them were modified. Checkout consumes their contracts.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Purchase attempt | checkout_session + item/promotion snapshots | |
| Holds | Inventory + promotion ledgers (their own tables) | Taken via their services inside the same tx |
| Totals | grand_total CHECK — the arithmetic is a database invariant | |
| Expiry | expires_at read predicate, never a stored status | |
| Payment state | Session status + attempt number |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
CheckoutModule | Aggregate | checkout.module.ts | None | — | Leaves | Composes customer/admin/worker |
CheckoutCustomerModule | Leaf | customer/ | CheckoutCustomerController | session service | — | Start/active/cancel |
CheckoutAdminModule | Leaf | admin/ | CheckoutAdminController | admin service | — | List/detail/cancel/expire |
CheckoutWorkerModule | Leaf | checkout-worker.module.ts | None | sweep processor + scheduler | — | The expiry sweep |
CheckoutSharedModule | Leaf | shared/ | None | payment/release/access services, utils | Services | Shared engine + payment contract |
4. File and Directory Map
apps/api/src/modules/checkout/
customer/
checkout-customer.controller.ts
dto/
admin/
checkout-admin.controller.ts
dto/
shared/
checkout-session.service.ts # the one-transaction flow
checkout-payment.service.ts # the contract Payment inherits
checkout-release.service.ts # cancel/expire release paths
checkout-access.service.ts # ownership scoping
checkout-transition.util.ts # the state machine + attempt scoping
checkout-validation.util.ts # readiness checks
checkout-totals.util.ts # totals computation
checkout-errors.util.ts # error-code mapping
checkout.constants.ts # TTLs, limits
workers/
checkout-expiry-sweep.processor.ts
checkout-maintenance.scheduler.ts
packages/db/src/schema/checkout/
checkout-session.ts checkout-session-item.ts checkout-session-promotion.ts enums.ts
packages/db/src/migrations/0010_checkout.sqlKey files:
| File | Purpose | Key Exports | Notes |
|---|---|---|---|
shared/checkout-session.service.ts | The checkout flow | CheckoutSessionService | One transaction, everything atomic |
shared/checkout-payment.service.ts | Payment contract | markPaymentStarted, complete, fail | No HTTP surface |
shared/checkout-transition.util.ts | State machine | transition guards | Attempt-scoped |
workers/checkout-expiry-sweep.processor.ts | Expiry sweep | processor | payment_in_progress exempt |
5. Data Model
5.1 Schema Source
packages/db/src/schema/checkout/
checkout-session.ts checkout-session-item.ts checkout-session-promotion.ts enums.ts5.2 Tables
checkout_session
| Column | Type | Nullable | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|
id / public_id | serial / uuid v7 | No | PK / UNIQUE | — | |
customer_id | uuid | No | FK + partial unique | customers.id | One live checkout per customer — the whole double-click story |
status | checkout_session_status enum | No | — | — | pending_payment / payment_in_progress / completed / cancelled / expired |
attempt | integer | No | — | — | The payment-attempt handle; every payment-side transition is scoped to it |
expires_at | timestamptz | No | index | — | Expiry is a read predicate — never a stored status |
cart_version | integer | Yes | — | — | The version the client checked out from |
address_snapshot | jsonb | No | — | — | The whole shipping address, copied |
subtotal / discount_amount / shipping_amount / shipping_discount_amount / grand_total | bigint | No | CHECK grand_total = subtotal - discount + shipping - shipping_discount | — | Minor units; the arithmetic is a database invariant |
currency | varchar | No | — | — | NPR |
created_at / updated_at | timestamptz | No | — | — | |
closed_by | uuid / text | Yes | — | — | Which administrator acted (cancel/expire audit) |
checkout_session_item
Snapshots per line: product_id, name, sku, unit_price, mrp, quantity, line_total — copied at freeze time. A receipt must never join product; an operator editing a product must never rewrite purchase history.
name and sku come from CartQueryService.loadState's productSnapshots — the product ROW, read in the same statement that produced the price. Not from the cart's serialized response: the cart embeds the storefront CARD shape, which carries no sku at all, so sourcing it there would write null into a purchase record with nothing failing.
The same-statement part is the load-bearing bit. The checkout transaction is READ COMMITTED and locks the cart row, not the product rows, so each statement takes its own snapshot. Reading the name in a second query — loadLineIdentity has the join to hand and it would be the obvious place — means an admin who renames and reprices a product between the two writes a line naming the new product at the old price. One statement cannot disagree with itself.
checkout-session.int.spec.ts asserts the snapshot, and that assertion is what turns red if the source moves back to the display response.
The variant snapshot — never join to render this. Same discipline, same reason: an operator
renaming a colour or relabelling a variant must not rewrite what a customer bought last March.
variant_id is the real key (product_id is a denormalised copy, as on cart_item).
variant_public_id is NOT NULL — every line names the configuration it froze, since migration
0028. variant_name stays nullable, and its NULL is a real statement: the variant WAS the
product's sole configuration and carried no label of its own; the response builder then renders the
product name, which is what the customer actually saw. A composite FK,
fk_checkout_session_item_variant_identity (variant_id, variant_public_id) against
product_variant (id, public_id) (added by migration 0029), ties the two so a disagreement
between them cannot occur — NOT NULL alone proves a value is present, not that it agrees with the
integer id, and this line is what the order line is copied from.
(packages/db/src/schema/checkout/checkout-session-item.ts:63,75-98,166)
The current response does NOT expose the variant snapshot. CheckoutItemDto
(apps/api/src/modules/checkout/customer/dto/checkout-response.dto.ts:87-121) and the object literal
that builds it (apps/api/src/modules/checkout/customer/checkout-response.builder.ts:34-55) carry
id, productId, name, sku, unitPrice, mrp, quantity, lineTotal, priceChange and
reserved — no variantPublicId or variantName field. The column exists and is written on every
insert (below); it is read back to build the order line, not to render the checkout session.
Deploy ordering for migration 0028 is not optional. checkout_session_item.variant_public_id
became NOT NULL in 0028_variant_public_id_not_null.sql, and the code that writes it must already
be serving before that migration runs. Migrating first means every POST /api/mobile/checkout while
the old build (which never wrote the column) is still live raises 23502 and returns 500, rolling
back its inventory holds. The migration header sets SET LOCAL lock_timeout = '5s' so the two SET NOT NULL statements — each an ACCESS EXCLUSIVE lock — fail fast rather than stalling behind an
open transaction. 0029_variant_identity_fk.sql adds the composite FK and is safe in either order.
(packages/db/src/migrations/0028_variant_public_id_not_null.sql:5-9,37-52)
checkout_session_promotion
Snapshots per applied promotion: promotion_id, name, coupon_code, discount_amount, shipping_discount_amount — copied from the reservation (see §6.1).
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 CheckoutSessionService — the one transaction
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
start() | POST /checkout | cart, address, inventory, promotion | session + items + promotions + holds | releases on failure? no — nothing to release | the eleven CHECKOUT_* codes |
getActive() | GET /active | sessions | — | — | — |
cancel() | customer POST /:id/cancel | session | status + releases | unlocks cart | CHECKOUT_SESSION_NOT_FOUND, CHECKOUT_SESSION_NOT_CANCELLABLE, CHECKOUT_PAYMENT_IN_PROGRESS |
It is ONE database transaction, not a saga. Cart, inventory, promotion, shipping and address are modules over the same PostgreSQL database, and every one of their write paths already accepts a DbExecutor — so validation, both reservations and the freeze commit together or not at all. A failed checkout leaves no orphan hold and no locked cart, because there is no partial state to compensate for. The specification described a distributed coordinator; that is what checkout is in the literature and not what it is here — which is why the module is small.
Discount figures come from the reservation, not from the evaluation that preceded it. Promotion re-evaluates under its own row lock and writes what it returns into its ledger, so taking the earlier number would give a session whose own arithmetic is consistent while disagreeing with what promotion believes it granted.
6.2 CheckoutPaymentService — the contract Payment inherits
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
markPaymentStarted() | nobody yet | session | status + attempt | — | requires expires_at > now() |
complete() | nobody yet | session | status | — | same attempt required |
fail() | nobody yet | session | status | retryable → holds intact; non-retryable → release | same attempt required |
No HTTP surface, deliberately — a customer who could call complete could mark their own purchase paid. It is written, tested against a real database, and called by nobody: the contract the Payment module inherits (see payment-handoff.md).
Every payment-side transition is attempt-scoped, not merely idempotent. The retry transition makes the state machine cyclic, and once a cycle exists "a duplicate matches nothing" stops being true — a redelivered decline for a superseded attempt would knock a live payment back and let the real success no-op silently. The attempt number is the handle: complete requires payment_in_progress and the same attempt; fail likewise.
6.3 CheckoutReleaseService — cancel and expire
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
releaseAndClose() | admin cancel/expire, sweep | session | status + releases + audit | releases inventory hold + coupon slot, unlocks cart | — |
Both admin release actions differ only in the audit value: cancel is for a pending_payment session judged fraudulent or duplicated; expire is for a session stuck at a payment gateway that never called back — the only lever for that state, because the automatic sweep deliberately never touches it. Both record which administrator acted.
6.4 The expiry sweep
CheckoutExpirySweepProcessor (on the checkout queue, enqueued by CheckoutMaintenanceScheduler):
payment_in_progressIS NEVER CLAIMED — encoded in the partial index the sweep scans, not only in a WHERE clause, so a future query that forgets the status filter still cannot touch it. Releasing stock under a customer at a gateway turns a successful charge into an unfulfillable order.- A session past its
expires_atis expired to every reader whether or not the sweep has run — expiry is a read predicate. The sweep writesexpiredonly to record that the holds actually went back. A status a background job must flip is wrong for the whole interval until that job's next run, and here that interval is one where a customer could pay for stock about to be released. - The scheduler enqueues directly — a cron with no accompanying database write, one of the two stated outbox exemptions.
6.5 The zombie reclaim pass
The exemption above has a cost, and this second pass is the bound on it.
A customer reaches a gateway and it never calls back — tab closed, network dropped, outage. The
first pass will never claim that session; the customer cannot cancel it, because
isCustomerCancellable refuses payment_in_progress outright; and uq_cart_customer_id_live means
they cannot start another checkout. That customer could not shop again until an administrator
intervened, and the only thing telling one to was a log line.
The reclaim pass closes such a session as cancelled with close reason gateway_timeout, once
two things hold:
- The holds have provably lapsed —
now() > expires_at + CHECKOUT_HOLD_TTL_SECONDS + grace. The "don't release stock under a paying customer" rule protects the window while the holds are live and stops applying the moment they are not: inventory's own sweep has by then reclaimed the units on its own schedule, so the session can no longer complete at all (finalizewould throwINVENTORY_RESERVATION_ALREADY_SETTLED). Closing it releases nothing that is not already released. The grace absorbs clock skew and the inventory sweep's own one-minute cadence. - No payment exists — no attempt in
initiatedorpending_verification, and nonesucceeded.
The succeeded guard is the one that must never be dropped. A session whose payment landed is the
opposite problem: the customer has been charged, and force-expiring it releases their stock and
cancels their checkout with no admin path back. Only the completion job resolves that case, and
PaymentExpirySweepHandler reports it separately and in capitals for the same reason.
There is deliberately no gateway probe here, and adding one would be a second poller.
PaymentExpirySweepHandler already gives every due attempt one last server-to-server status query,
settles anything the gateway now reports as paid, and flags the undecided ones — and EsewaGateway
states outright that there is no separate poll method because confirm() with an empty payload is
the poll. By the time a session is old enough to reach this pass the gateway has already been asked,
and the answer is recorded in payment_attempt. Reading that is strictly better than asking again:
it costs no network call and cannot be wrong about a payment that landed mid-query.
gateway_timeout maps to cancelled, not expired — chk_checkout_session_expired_reason
reserves expired for the TTL sweep, and this session did not run out of time. closed_by_admin_id
stays NULL, because a system close must not impersonate an operator, and
chk_checkout_session_admin_close_attribution requires exactly that. last_activity_at is not
bumped: it drives abandoned-cart reminders, and this is a system release rather than something the
customer did.
6.6 Holds and the TTL
Inventory and promotion each sweep their own holds on their own schedule, and neither knows what a checkout session is — so a hold reserved for only as long as the session lives would be reclaimed while the customer was still at the payment gateway, after which finalising the sale throws on someone who has already been charged. CHECKOUT_HOLD_TTL_SECONDS is the session TTL plus a full payment window. A session that expires unpaid has its holds released explicitly and immediately, so the longer TTL costs nothing in the common case.
The three numbers, and why the third is not edited by hand:
| Constant | Value | Means |
|---|---|---|
CHECKOUT_SESSION_TTL_SECONDS | 5 min | How long to start paying |
CHECKOUT_PAYMENT_WINDOW_SECONDS | 25 min | How long a session may sit in payment_in_progress |
CHECKOUT_HOLD_TTL_SECONDS | 30 min | Stock and promotion hold TTL — defined as the sum of the two above |
The hold TTL is derived, not independent. Changing either input keeps the relationship by
construction, and writing 30 * 60 there by hand is how the two silently stop agreeing.
Five minutes is the deadline to click Pay, not to finish paying: START_PAYMENT requires
expires_at > now(), and after that the session is exempt from expiry entirely, so nobody at a
gateway loses their basket. The worst-case late-callback margin — a session entering payment at the
last instant of its TTL — is what remains of the hold, which is the payment window. That went from
15 minutes to 25, so the shorter session TTL made this margin larger, not smaller.
7. Runtime Flows
7.1 Start checkout (one transaction)
7.2 Payment transitions (future)
The three transitions Payment owns:
| Method | From | To |
|---|---|---|
markPaymentStarted | pending_payment, not lapsed | payment_in_progress |
complete | payment_in_progress, same attempt | completed |
fail | payment_in_progress, same attempt | pending_payment (retryable, holds intact) or cancelled (non-retryable, released) |
Two guards worth knowing:
markPaymentStartedrequiresexpires_at > now()— and it is not redundant with the status: a lapsed session the sweep has not reached still readspending_payment, and starting a payment against it would charge a customer for stock the sweep is entitled to release at any moment.- Payment must NOT
UPDATE checkout_sessiondirectly. Doing so bypassesfinalize/confirmand the failure is silent: the customer is charged, stock is never decremented, and the coupon slot is never consumed. That is the entire reason this service exists before its caller does.
8. Cache
Checkout caches nothing. Every read is a live session row; every value a customer sees is either frozen in the session or derived at read time.
9. Jobs and Workers
| Queue | Job | When |
|---|---|---|
checkout | expiry sweep | scheduled via CheckoutMaintenanceScheduler (a plain cron — no outbox row, no accompanying DB write) |
The sweep claims expired, non-payment_in_progress sessions, releases their holds and records the expiry. There is no lifecycle-flip job beyond it — expiry itself is a read predicate.
10. Security and Authorization
- Customer routes:
JwtAuthGuard—CUSTOMER_CHECKOUT_ATTEMPT10/min account-keyed on start (a checkout is a money-bearing attempt),CUSTOMER_READ60/min,CUSTOMER_WRITE20/min on cancel. - Admin routes:
JwtAuthGuard+RoleGuard—Checkout_READ(list/detail),Checkout_UPDATE(cancel/expire);ADMIN_READ30/min,ADMIN_WRITE10/min. - Another customer's session and a session that does not exist both return 404 with the same code — a 403 would confirm the id is real. Never distinguish them.
- Payment transitions have no HTTP surface — a customer who could call
completecould mark their own purchase paid. - Admin may read and release, never edit: no update route and no delete route — a purchase contract an operator can reprice is not a contract.
11. Snapshots and the receipt rule
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. Documentation (and any future receipt code) must never join checkout_session_item to product, or checkout_session_promotion to promotion — an operator editing one would otherwise rewrite the history of every past purchase.