Happy House - 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, checkout-payment.service.ts, checkout-release.service.ts, checkout-access.service.tsOne transaction, 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 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

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.tsNoneLeavesComposes customer/admin/worker
CheckoutCustomerModuleLeafcustomer/CheckoutCustomerControllersession serviceStart/active/cancel
CheckoutAdminModuleLeafadmin/CheckoutAdminControlleradmin serviceList/detail/cancel/expire
CheckoutWorkerModuleLeafcheckout-worker.module.tsNonesweep processor + schedulerThe 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
    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.sql

Key files:

FilePurposeKey ExportsNotes
shared/checkout-session.service.tsThe checkout flowCheckoutSessionServiceOne transaction, everything atomic
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 enumNopending_payment / payment_in_progress / completed / cancelled / expired
attemptintegerNoThe payment-attempt handle; every payment-side transition is scoped to it
expires_attimestamptzNoindexExpiry is a read predicate — never a stored status
cart_versionintegerYesThe version the client checked out from
address_snapshotjsonbNoThe whole shipping address, copied
subtotal / discount_amount / shipping_amount / shipping_discount_amount / grand_totalbigintNoCHECK grand_total = subtotal - discount + shipping - shipping_discountMinor units; the arithmetic is a database invariant
currencyvarcharNoNPR
created_at / updated_attimestamptzNo
closed_byuuid / textYesWhich 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
start()POST /checkoutcart, address, inventory, promotionsession + items + promotions + holdsreleases on failure? no — nothing to releasethe eleven CHECKOUT_* codes
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.

6.2 CheckoutPaymentService — the contract Payment inherits

MethodCalled ByReadsWritesSide EffectsErrors
markPaymentStarted()nobody yetsessionstatus + attemptrequires expires_at > now()
complete()nobody yetsessionstatussame attempt required
fail()nobody yetsessionstatusretryable → holds intact; non-retryable → releasesame 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

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 lapsednow() > 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 expiredchk_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.1 Start checkout (one transaction)

7.2 Payment transitions (future)

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 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

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: JwtAuthGuardCUSTOMER_CHECKOUT_ATTEMPT 10/min account-keyed on start (a checkout is a money-bearing attempt), CUSTOMER_READ 60/min, CUSTOMER_WRITE 20/min on cancel.
  • Admin routes: JwtAuthGuard + RoleGuardCheckout_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.