Nano Store - Ecommerce Docs
Developer ResourcesCheckout

Checkout Backend Documentation

Backend architecture, data model, services, and operational behavior for the Checkout module.

Checkout - Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/checkout/ module filesCustomer + admin leaves, worker module
Controllerscustomer/checkout-customer.controller.ts, admin/checkout-admin.controller.tsRoutes, permissions, rate limits
Servicesshared/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.tsOne transaction, quote-without-writing, freeze-and-pay orchestration, transitions, release
Utilitiesshared/checkout-transition.util.ts, checkout-validation.util.ts, checkout-totals.util.ts, checkout-errors.util.tsAttempt scoping, validation, totals
Schemapackages/db/src/schema/checkout/*.tsGrand-total CHECK, partial uniques, expiry
Workersworkers/checkout-expiry-sweep.processor.ts, checkout-maintenance.scheduler.tsThe 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

ConcernSource of TruthNotes
Purchase attemptcheckout_session + item/promotion snapshots
HoldsInventory + promotion ledgers (their own tables)Taken via their services inside the same tx
Totalsgrand_total CHECK — the arithmetic is a database invariant
Expiryexpires_at read predicate, never a stored status
Payment stateSession status + attempt number

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
CheckoutModuleAggregatecheckout.module.tsNone—LeavesComposes customer/admin/worker
CheckoutCustomerModuleLeafcustomer/CheckoutCustomerControllersession service, quote service, CheckoutPayService—Quote/start/pay/active/cancel
CheckoutAdminModuleLeafadmin/CheckoutAdminControlleradmin service—List/detail/cancel/expire
CheckoutWorkerModuleLeafcheckout-worker.module.tsNonesweep processor + scheduler—The expiry sweep
CheckoutSharedModuleLeafshared/Nonepayment/release/access services, utilsServicesShared 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.sql

Key files:

FilePurposeKey ExportsNotes
shared/checkout-session.service.tsThe checkout flowCheckoutSessionServiceOne transaction, everything atomic
shared/checkout-quote.service.tsPrice without committingCheckoutQuoteServiceNo transaction, no lock, no writes
customer/checkout-pay.service.tsFreeze and pay orchestrationCheckoutPayServiceOwns no transaction of its own; never releases anything
shared/checkout-payment.service.tsPayment contractmarkPaymentStarted, complete, failNo HTTP surface
shared/checkout-transition.util.tsState machinetransition guardsAttempt-scoped
workers/checkout-expiry-sweep.processor.tsExpiry sweepprocessorpayment_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.ts

5.2 Tables

checkout_session

ColumnTypeNullableIndex/ConstraintRelationNotes
id / public_idserial / uuid v7NoPK / UNIQUE—
customer_iduuidNoFK + partial uniquecustomers.idOne live checkout per customer — the whole double-click story
statuscheckout_session_status enumNo——pending_payment / payment_in_progress / completed / cancelled / expired
attemptintegerNo——The payment-attempt handle; every payment-side transition is scoped to it
expires_attimestamptzNoindex—Expiry is a read predicate — never a stored status
cart_versionintegerYes——The version the client checked out from
address_snapshotjsonbNo——The whole shipping address, copied
subtotal / discount_amount / shipping_amount / shipping_discount_amount / grand_totalbigintNoCHECK grand_total = subtotal - discount + shipping - shipping_discount—Minor units; the arithmetic is a database invariant
currencyvarcharNo——NPR
created_at / updated_attimestamptzNo——
closed_byuuid / textYes——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

MethodCalled ByReadsWritesSide EffectsErrors
create()POST /checkout, CheckoutPayService.paycart, address, inventory, promotionsession + items + promotions + holdsreleases on failure? no — nothing to release; a total/destination refusal rolls back inside the transactionthe eleven CHECKOUT_* codes plus CHECKOUT_TOTAL_CHANGED, CHECKOUT_SESSION_MISMATCH
getActive()GET /activesessions———
cancel()customer POST /:id/cancelsessionstatus + releasesunlocks cartCHECKOUT_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

MethodCalled ByReadsWritesSide EffectsErrors
quote()POST /checkout/quotecart, address, promotion evaluation (not reservation)—noneCHECKOUT_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

MethodCalled ByReadsWritesSide EffectsErrors
pay()POST /checkout/paylive sessiondelegates to CheckoutSessionService.create then PaymentCustomerService.startnone of its ownCHECKOUT_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

MethodCalled ByReadsWritesSide EffectsErrors
markPaymentStarted()PaymentAttemptService.startsessionstatus + attempt—requires expires_at > now()
complete()Payment settlement pathsessionstatus—same attempt required
fail()PaymentAttemptService.start's own repair path, and elsewhere in Paymentsessionstatusretryable → holds intact; non-retryable → releasesame 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

MethodCalled ByReadsWritesSide EffectsErrors
releaseAndClose()admin cancel/expire, sweepsessionstatus + releases + auditreleases 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_progress IS 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_at is expired to every reader whether or not the sweep has run — expiry is a read predicate. The sweep writes expired only 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:

  1. 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 (finalize would throw INVENTORY_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.
  2. No payment exists — no attempt in initiated or pending_verification, and none succeeded.

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:

ConstantValueMeans
CHECKOUT_SESSION_TTL_SECONDS5 minHow long to start paying
CHECKOUT_PAYMENT_WINDOW_SECONDS25 minHow long a session may sit in payment_in_progress
CHECKOUT_HOLD_TTL_SECONDS30 minStock 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:

MethodFromTo
markPaymentStartedpending_payment, not lapsedpayment_in_progress
completepayment_in_progress, same attemptcompleted
failpayment_in_progress, same attemptpending_payment (retryable, holds intact) or cancelled (non-retryable, released)

Two guards worth knowing:

  • markPaymentStarted requires expires_at > now() — and it is not redundant with the status: a lapsed session the sweep has not reached still reads pending_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_session directly. Doing so bypasses finalize/confirm and 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

QueueJobWhen
checkoutexpiry sweepscheduled 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_QUOTE 40/min account-keyed on quote (writes nothing, holds nothing, takes no lock), CUSTOMER_CHECKOUT_ATTEMPT 10/min account-keyed on start (a checkout is a money-bearing attempt), CUSTOMER_CHECKOUT_PAY 10/min account-keyed on pay (freezes the basket and reaches a gateway), CUSTOMER_READ 60/min, CUSTOMER_WRITE 20/min on cancel.
  • Admin routes: JwtAuthGuard + RoleGuard — Checkout_READ (list/detail), Checkout_UPDATE (cancel/expire); ADMIN_READ 30/min, ADMIN_WRITE 10/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 complete could 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.