Happy House - Ecommerce Docs
Developer ResourcesPayment

Payment Backend Documentation

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

Payment - Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/payment/ module filesCustomer/admin/gateway-return/worker leaves
Controllerscustomer/payment-customer.controller.ts, admin/payment-admin.controller.ts, gateway-return/gateway-return.controller.tsRoutes, permissions, four return defences
Servicesshared/payment-attempt.service.ts, payment-settlement.service.ts, payment-completion.service.ts, payment-reconciliation.service.ts, payment-access.service.tsTransitions, completion ordering, flag
Gatewaysgateways/payment-gateway.port.ts, esewa.gateway.ts, cod.gateway.ts, payment-gateway.registry.tsThe port, the amount-carrying outcome
Workersworkers/payment-verification.processor.ts, payment-expiry-sweep.processor.ts, payment-maintenance.scheduler.tsBackoff verification, expiry
Schemapackages/db/src/schema/payment/{payment-attempt,payment-event,enums}.tsSix-status enum, flag columns, CHECKs
Error registryapps/api/src/common/types/error-codes.ts (// PAYMENT)PAYMENT_* codes

2. Backend Scope and Boundaries

Owns

  • payment_attempt (one row per interaction with a method) and payment_event beneath it.
  • The gateway port and its two implementations (eSewa, COD).
  • Verification, completion, expiry and reconciliation.

Does Not Own

  • Money figures. There is no payment table — checkout_session.grand_total is the only amount, and payment never writes it or checkout_session at all (writing it would bypass inventory finalisation and promotion confirmation — the customer charged, stock never decremented, nothing errors).
  • Orders. A paid payment means the checkout is completed; turning that into an order is the Order module's job.

Source of Truth

ConcernSource of TruthNotes
Frozen moneycheckout_session.grand_totalNo second copy
Attempt statepayment_attempt.statussix values
Historypayment_event — append-only
"Needs a human"Flag columns (reconciliation_flagged_at + reconciliation_reason)Never a status

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
PaymentCustomerModuleLeafcustomer/PaymentCustomerControllerattempt serviceMethods/start/poll/cancel
PaymentAdminModuleLeafadmin/PaymentAdminControlleradmin serviceList/timeline/resolve
PaymentGatewayReturnModuleLeafgateway-return/GatewayReturnControllersettlement serviceeSewa's routes
PaymentWorkerModuleLeafpayment-worker.module.tsNoneprocessors + schedulerVerification, expiry
PaymentSharedModuleLeafshared/Noneservices + port + registryServicesShared engine

4. File and Directory Map

apps/api/src/modules/payment/
  customer/
    payment-customer.controller.ts
    dto/
  admin/
    payment-admin.controller.ts
    dto/
  gateway-return/
    gateway-return.controller.ts
  gateways/
    payment-gateway.port.ts       # the port: outcome carries amount + currency
    esewa.gateway.ts              # HMAC, status API, PENDING handling
    cod.gateway.ts                # approves at hand-off
    payment-gateway.registry.ts   # enabled methods from env
  shared/
    payment-attempt.service.ts    # lifecycle + outbox completion
    payment-settlement.service.ts # gateway answer -> attempt transition
    payment-completion.service.ts # checkout completion (via contract)
    payment-reconciliation.service.ts  # flag/clear + note
    payment-access.service.ts     # ownership + live-status scoping
    payment-event.service.ts      # append-only timeline
    payment-url.service.ts        # return/result URLs + redactUrlSecrets
    payment.constants.ts          # backoff, TTLs
  workers/
    payment-queue.processor.ts    # THE one processor on the payment queue
    payment-verification.processor.ts
    payment-expiry-sweep.processor.ts
    payment-maintenance.scheduler.ts
packages/db/src/schema/payment/
  payment-attempt.ts  payment-event.ts  enums.ts
packages/db/src/migrations/0011_payment.sql

Key files:

FilePurposeKey ExportsNotes
gateways/payment-gateway.port.tsThe gateway contractGatewayOutcomeConfirmed variant carries amount + currency
shared/payment-attempt.service.tsThe lifecycleattempt transitionsSucceeded-before-complete ordering
shared/payment-reconciliation.service.tsThe flagflag/clear + noteNo status change
shared/payment-url.service.tsURLs + redactionredactUrlSecretsKeeps the token out of logs

5. Data Model

5.1 Schema Source

packages/db/src/schema/payment/
  payment-attempt.ts  payment-event.ts  enums.ts

5.2 Tables

payment_attempt

ColumnTypeNullableIndex/ConstraintRelationNotes
id / public_idserial / uuid v7NoPK / UNIQUE
checkout_session_idintegerNopartial unique WHERE status = 'succeeded'checkout"One checkout, one successful payment" is an index, not an argument
methodpayment_method enumNocod / esewa
statuspayment_attempt_status enumNoindexSix values — see §5.3
attempt_numberintegerNoPer-checkout attempt counter
requested_amount / currencybigint / varcharNoMinor units; from the checkout
verified_amountbigintYesCHECK chk_payment_attempt_verified_amount_matchesMust equal requested_amount when confirmed
succeeded_attimestamptzYesMoney provably moved; must survive reconciliation
expires_attimestamptzNo
gateway_token_hashvarcharNoOnly the SHA-256 of the return token is stored
reconciliation_flagged_attimestamptzYesindex (the queue)The flag — not a status
reconciliation_reasonenumYesNULL exactly when flagged_at is NULL
reconciliation_notetextYesWhat the human decided
created_at / updated_attimestamptzNo

payment_event

Append-only timeline: one row per transition and per gateway message, oldest first, with the gateway payload already redacted. The attempt row says where a payment ended; the timeline says how it got there.

5.3 Enums — and the flag

payment_attempt_status has SIX values: initiated, pending_verification, succeeded, failed, cancelled, expired.

There is no seventh "needs a human" status. The master plan once described reconciliation as a status; the shipped code models it as a flag (reconciliation_flagged_at + reconciliation_reason), orthogonal to the outcome:

  • A succeeded charge whose checkout refused to complete is flagged.
  • A failed one where the gateway confirmed a figure we would not accept is flagged.
  • An expired one whose confirmation arrived late is flagged.

As a status it would have erased succeeded_at on the one row where money provably moved, and dropped that row out of the index holding the one-successful-payment invariant. payment_reconciliation_reason covers checkout_refused, attempt_superseded, amount_mismatch, late_confirmation, and the "undecided at the deadline" case.

5.4 Relationship Diagram

6. Services and Responsibilities

6.1 PaymentAttemptService

MethodCalled ByReadsWritesSide EffectsErrors
start()POST /paymentscheckout, attempts, registryattempt + eventthe eleven PAYMENT_* codes
cancel()POST /:id/cancelattemptstatus + eventPAYMENT_ATTEMPT_NOT_CANCELLABLE
get()GET /:idattemptPAYMENT_ATTEMPT_NOT_FOUND

There is no payment table, and that is the module's central decision. checkout_session already holds the frozen money, the customer, the lifecycle and the attempt counter; a parallel payment.amount would be a second copy of grand_total kept equal only by application code remembering to — and when two copies of an amount disagree, the customer is charged one of them. "One checkout, one successful payment" is therefore a partial unique index, not a service-layer argument about concurrency.

6.2 PaymentSettlementService — the gateway answer

MethodCalled ByReadsWritesSide EffectsErrors
applyOutcome()return routes, verification processorattemptstatus + verified amount + eventcompletion via outbox

A gateway's answer carries an AMOUNT, never a boolean. GatewayOutcome's confirmed variant holds the figure and the currency, so a caller cannot reach "this succeeded" without holding them — and chk_payment_attempt_verified_amount_matches refuses to record a confirmed amount that differs from the one requested. That combination is what makes the replay defect this module was built against structurally unwritable: completion gated on a bare success flag, with the confirmed figure discarded beside it, is what lets a cheap real payment be replayed against an expensive purchase. Both halves matter; neither alone is the fix.

6.3 Completion ordering

The attempt is marked succeeded BEFORE the checkout completes, and the same transaction enqueues COMPLETE_SETTLED_CHECKOUT through the outbox. The ordering means a shipped order can never exist without a money record; the outbox row is what makes the window between them survivable rather than merely observable.

The completion job's result names lines by VARIANT, not by product. checkout-payment.service.ts's finalizeHolds joins product_variant and returns lostReservationVariantPublicIds; PaymentCompletionService copies that onto the settlement result as shortVariants (renamed from shortProducts — a cart can hold two configurations of one product and only one can go short, so a product id cannot say which line will not ship). buildPaymentResponse echoes it onto PaymentResponseDto.shortVariants unconditionally on a succeeded settlement — never suppressed, because a paid customer must always be told when a line they were charged for is not coming. (apps/api/src/modules/payment/shared/payment-completion.service.ts:109, apps/api/src/modules/payment/shared/payment-settlement.service.ts:53, apps/api/src/modules/payment/customer/payment-response.builder.ts:60-61)

6.4 PaymentVerificationProcessor — PENDING and backoff

PENDING is not a decline. The processor re-asks on a bounded backoff while the attempt keeps the checkout's payment slot held — a second charge cannot start. Running out of asks expires the attempt and flags it, because a customer whose money left their account deserves better than a silent expiry.

6.5 The zero-match re-read

Zero matched rows is never treated as benign without a re-read. The same conditional-UPDATE predicate fails for a harmless duplicate AND for a real confirmation landing on an attempt something else already closed. Conflating them discards a real payment in silence — so the settlement path re-reads before deciding the delivery was a duplicate.

6.6 PaymentReconciliationService

MethodCalled ByReadsWritesSide EffectsErrors
flag()settlement/expiry pathsattemptflag columns
resolve()POST /:id/resolveattemptnote + flag clearedPAYMENT_RESOLUTION_NOT_APPLICABLE

Resolve records what a human decided; changes no status, no amount, no outcome; takes the case out of the queue. There is deliberately no admin route to mark a payment paid, edit an amount, retry a charge or refund one — marking paid is refused by a database constraint, not merely absent.

6.7 The gateway return endpoint — four independent defences

The public return routes carry the attempt token in the URL path and defend in four layers:

  1. The per-attempt token — only its SHA-256 is stored; the raw value exists only in the URL.
  2. The HMAC over the gateway's declared field set (signed_field_names) — every field deciding the outcome must be covered.
  3. The server-to-server status check — a browser redirect is never proof of payment; the API re-queries the gateway.
  4. The database — the attempt row and its CHECKs decide.

Because the token is in the URL path and the global logging interceptor writes request URLs at error level, redactUrlSecrets exists to keep it out of the application log.

7. Runtime Flows

7.1 Verify and complete

7.2 Several short transactions, not one

Checkout could be a single transaction because every collaborator was a module over the same database. Payment's collaborator is a gateway over HTTP, and a network call cannot sit inside a transaction holding inventory row locks. Every payment write is therefore idempotent and attempt-scoped instead of atomic-with-the-network-call.

8. Cache

Payment caches nothing. Every read is a live attempt row; saying "no caching" once is enough.

9. Jobs and Workers

QueueJobsNotes
paymentverification (re-ask with bounded backoff), completion (COMPLETE_SETTLED_CHECKOUT via outbox), expiry sweep, maintenanceOne processor on the queue; the completion job is enqueued through the outbox in the succeeded transaction

The expiry sweep expires attempts past their deadline and flags the ones where money may have moved. PENDING re-asks are scheduled by the verification processor on a bounded backoff, never unbounded.

10. Security and Authorization

  • Customer routes: JwtAuthGuard; customer reads are ownership-scoped (PAYMENT_ATTEMPT_NOT_FOUND for another customer's attempt — no existence oracle).
  • Admin: JwtAuthGuard + RoleGuard, Payments_READ / Payments_UPDATE.
  • Gateway-return routes: public by necessity (eSewa has no bearer token) — defended by the four layers in §6.7, and they carry a per-attempt secret. Never called by the storefront.
  • Payment never writes checkout_session — doing so would bypass inventory finalisation and promotion confirmation, and the failure is silent: customer charged, stock never decremented, nothing errors. The checkout payment contract is the only path.
  • No caching, no secrets in docs, gateway payloads redacted before storage.

11. Operational notes

  • Before eSewa can be used in an environment, five variables must be set (PAYMENT_ESEWA_ENABLED, ESEWA_EPAY_URL, ESEWA_EPAY_STATUS_URL, ESEWA_PRODUCT_CODE, ESEWA_SECRET_KEY) and API_PUBLIC_BASE_URL must be the URL eSewa can actually reach — it is what the return URLs are built from. Without them the API boots normally and offers COD only.
  • FRONTEND_BASE_URL must be correct, or the post-payment redirect falls back to the frontend root and logs an error.
  • The gateway-return routes appear in neither Swagger document, by design.