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, shared/checkout-quote.service.ts, customer/checkout-pay.service.ts, checkout-payment.service.ts, checkout-release.service.ts, checkout-access.service.ts | One transaction, quote-without-writing, freeze-and-pay orchestration, 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 nine routes.
- The one-transaction checkout flow, the price-without-committing quote path, the freeze-and-pay
orchestration, 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, quote service, CheckoutPayService | — | Quote/start/pay/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
checkout-customer.service.ts
checkout-pay.service.ts # freeze the basket and start paying, in one request
dto/
admin/
checkout-admin.controller.ts
dto/
shared/
checkout-session.service.ts # the one-transaction flow
checkout-quote.service.ts # price the basket, writes nothing
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-quote.service.ts | Price without committing | CheckoutQuoteService | No transaction, no lock, no writes |
customer/checkout-pay.service.ts | Freeze and pay orchestration | CheckoutPayService | Owns no transaction of its own; never releases anything |
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 |
|---|---|---|---|---|---|
create() | POST /checkout, CheckoutPayService.pay | cart, address, inventory, promotion | session + items + promotions + holds | releases on failure? no — nothing to release; a total/destination refusal rolls back inside the transaction | the eleven CHECKOUT_* codes plus CHECKOUT_TOTAL_CHANGED, CHECKOUT_SESSION_MISMATCH |
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.
create() accepts an optional expectedGrandTotal. When supplied, the total computed from the
reservation is compared against it inside the transaction, before the insert (§5's totals CHECK is
the schema-level backstop; this is the request-level one). A mismatch throws ConflictException /
CHECKOUT_TOTAL_CHANGED and rolls back the whole transaction — no session row, no stock hold, no
promotion hold, and the cart stays active. When the field is absent, the guard does not run at
all, which is what keeps POST /checkout behaving exactly as it did for every client that predates
POST /checkout/pay.
The same input also drives assertSessionMatchesRequest, called from every door onto an
already-existing live session — the !live.lapsed branch, the unique-violation race loser, and
CheckoutPayService's own resume path — comparing fulfilment type, address, cart version and grand
total against the live session before handing it back. A mismatch throws CHECKOUT_SESSION_MISMATCH
rather than silently returning someone else's — or an earlier request's own — frozen destination and
total.
6.1a CheckoutQuoteService — the price, without the commitment
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
quote() | POST /checkout/quote | cart, address, promotion evaluation (not reservation) | — | none | CHECKOUT_ADDRESS_NOT_ALLOWED_FOR_PICKUP, CHECKOUT_ADDRESS_REQUIRED_FOR_DELIVERY, CHECKOUT_ADDRESS_NOT_FOUND |
Everything create() reads, none of what it writes: no session, no stock hold, no promotion hold,
no cart lock. Deliberately no transaction and no lock — a quote is a snapshot of a moving target
by definition, refetched the moment the customer changes anything; wrapping it in a transaction would
put a sidebar render into the same lock convoy create() bounds with its own statement timeout, for
no benefit.
Everything the create path throws for as a business refusal — unserviceable district, inapplicable
coupon, locked cart, unavailable items — comes back here as readiness/coupon data on a 200
instead. What still throws is malformed or unauthorised input: a pickup carrying an address, a
delivery carrying none, an address that is not the customer's.
Where quote and create() legitimately disagree: create() computes totals from the promotion
reservation's amounts; the quote computes them from the evaluation's. They differ when a
promotion's limit is consumed in between — usually not as a different total at all, since
reserveOne throws rather than reserving less. Both outcomes are handled at the commit:
POST /checkout/pay carries the figure the customer was shown and refuses to charge anything else
(CHECKOUT_TOTAL_CHANGED).
6.1b CheckoutPayService — freeze and pay, orchestration only
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
pay() | POST /checkout/pay | live session | delegates to CheckoutSessionService.create then PaymentCustomerService.start | none of its own | CHECKOUT_TOTAL_CHANGED, CHECKOUT_SESSION_MISMATCH, the eleven create-path codes, PAYMENT_ALREADY_SETTLED |
Calls create() with expectedGrandTotal set, then hands the resulting session to
PaymentCustomerService.start. Owns no transaction and re-implements no rule — and never releases
anything. The obvious design (create the session, release it if starting the payment fails) is
unsafe in both directions: PaymentAttemptService moves the session to payment_in_progress before
it can fail, and from there CheckoutReleaseService.releaseHolds releases unconditionally — freeing
stock behind a gateway that may still charge — while CheckoutCustomerService.cancel refuses the
release and leaves the cart locked with no sweep to clear it. So neither half needs compensating: a
refusal of the total or the destination is raised inside the create transaction and rolls back
(§6.1); a failure inside the payment start is repaired by the payment module itself (§6.2).
Resuming a stuck CHECKOUT_PAYMENT_IN_PROGRESS. create() refuses outright when the customer
already has a session at a gateway. But a session can also sit in payment_in_progress with no live
attempt at all — a start that died between moving the session and inserting its attempt — and
PaymentAttemptService.heal exists precisely for that, reachable only from start. So pay() catches
that one refusal, re-checks the live session against the request with the same comparison create()
makes (fulfilment type, address, cart version, total), and — only on a match — hands its publicId to
PaymentCustomerService.start, which heals it if orphaned and refuses it if a payment really is in
flight. A mismatch throws CHECKOUT_SESSION_MISMATCH instead of resuming someone else's session: two
tabs, the first one's start died at the gateway, the second is on a different saved address — equal
totals do not make it safe, because two addresses in one district can cost the same and are not the
same place.
6.2 CheckoutPaymentService — the contract Payment inherits
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
markPaymentStarted() | PaymentAttemptService.start | session | status + attempt | — | requires expires_at > now() |
complete() | Payment settlement path | session | status | — | same attempt required |
fail() | PaymentAttemptService.start's own repair path, and elsewhere in Payment | 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. Checkout owns the contract; the Payment module is the only caller.
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.
PaymentAttemptService.start undoes its own failure. A throw from inserting the payment attempt
or from the gateway call is caught, and the session is handed back to pending_payment with its holds
intact via fail(..., { retryable: true }) (or the live attempt is cancelled through the same path a
customer's own cancel takes, if one was already inserted) — so the customer can simply press Pay
again rather than being stranded in payment_in_progress with no attempt to resolve it and no lever
to escape it. Settlement failures are deliberately outside that repair: once the gateway has
confirmed, undoing the attempt would erase a real payment to tidy up a later failure, and that case
already has an owner — the completion retry job.
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.0a Quote (no transaction)
7.0b Pay (freeze then start)
7.1 Start checkout (one transaction)
7.2 Payment transitions
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 contract exists — Payment goes through it rather than the table.
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_QUOTE40/min account-keyed on quote (writes nothing, holds nothing, takes no lock),CUSTOMER_CHECKOUT_ATTEMPT10/min account-keyed on start (a checkout is a money-bearing attempt),CUSTOMER_CHECKOUT_PAY10/min account-keyed on pay (freezes the basket and reaches a gateway),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.