Payment Backend Documentation
Backend architecture, data model, services, and operational behavior for the Payment module.
Payment - Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/payment/ module files | Customer/admin/gateway-return/worker leaves |
| Controllers | customer/payment-customer.controller.ts, admin/payment-admin.controller.ts, gateway-return/gateway-return.controller.ts | Routes, permissions, four return defences |
| Services | shared/payment-attempt.service.ts, payment-settlement.service.ts, payment-completion.service.ts, payment-reconciliation.service.ts, payment-access.service.ts | Transitions, completion ordering, flag |
| Gateways | gateways/payment-gateway.port.ts, esewa.gateway.ts, cod.gateway.ts, payment-gateway.registry.ts | The port, the amount-carrying outcome |
| Workers | workers/payment-verification.processor.ts, payment-expiry-sweep.processor.ts, payment-maintenance.scheduler.ts | Backoff verification, expiry |
| Schema | packages/db/src/schema/payment/{payment-attempt,payment-event,enums}.ts | Six-status enum, flag columns, CHECKs |
| Error registry | apps/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) andpayment_eventbeneath it.- The gateway port and its two implementations (eSewa, COD).
- Verification, completion, expiry and reconciliation.
Does Not Own
- Money figures. There is no
paymenttable —checkout_session.grand_totalis the only amount, and payment never writes it orcheckout_sessionat all (writing it would bypass inventory finalisation and promotion confirmation — the customer charged, stock never decremented, nothing errors). - Orders. A
paidpayment means the checkout iscompleted; turning that into an order is the Order module's job.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Frozen money | checkout_session.grand_total | No second copy |
| Attempt state | payment_attempt.status — six values | |
| History | payment_event — append-only | |
| "Needs a human" | Flag columns (reconciliation_flagged_at + reconciliation_reason) | Never a status |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
PaymentCustomerModule | Leaf | customer/ | PaymentCustomerController | attempt service | — | Methods/start/poll/cancel |
PaymentAdminModule | Leaf | admin/ | PaymentAdminController | admin service | — | List/timeline/resolve |
PaymentGatewayReturnModule | Leaf | gateway-return/ | GatewayReturnController | settlement service | — | eSewa's routes |
PaymentWorkerModule | Leaf | payment-worker.module.ts | None | processors + scheduler | — | Verification, expiry |
PaymentSharedModule | Leaf | shared/ | None | services + port + registry | Services | Shared 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.sqlKey files:
| File | Purpose | Key Exports | Notes |
|---|---|---|---|
gateways/payment-gateway.port.ts | The gateway contract | GatewayOutcome | Confirmed variant carries amount + currency |
shared/payment-attempt.service.ts | The lifecycle | attempt transitions | Succeeded-before-complete ordering |
shared/payment-reconciliation.service.ts | The flag | flag/clear + note | No status change |
shared/payment-url.service.ts | URLs + redaction | redactUrlSecrets | Keeps the token out of logs |
5. Data Model
5.1 Schema Source
packages/db/src/schema/payment/
payment-attempt.ts payment-event.ts enums.ts5.2 Tables
payment_attempt
| Column | Type | Nullable | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|
id / public_id | serial / uuid v7 | No | PK / UNIQUE | — | |
checkout_session_id | integer | No | partial unique WHERE status = 'succeeded' | checkout | "One checkout, one successful payment" is an index, not an argument |
method | payment_method enum | No | — | — | cod / esewa |
status | payment_attempt_status enum | No | index | — | Six values — see §5.3 |
attempt_number | integer | No | — | — | Per-checkout attempt counter |
requested_amount / currency | bigint / varchar | No | — | — | Minor units; from the checkout |
verified_amount | bigint | Yes | CHECK chk_payment_attempt_verified_amount_matches | — | Must equal requested_amount when confirmed |
succeeded_at | timestamptz | Yes | — | — | Money provably moved; must survive reconciliation |
expires_at | timestamptz | No | — | — | |
gateway_token_hash | varchar | No | — | — | Only the SHA-256 of the return token is stored |
reconciliation_flagged_at | timestamptz | Yes | index (the queue) | — | The flag — not a status |
reconciliation_reason | enum | Yes | — | — | NULL exactly when flagged_at is NULL |
reconciliation_note | text | Yes | — | — | What the human decided |
created_at / updated_at | timestamptz | No | — | — |
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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
start() | POST /payments | checkout, attempts, registry | attempt + event | — | the eleven PAYMENT_* codes |
cancel() | POST /:id/cancel | attempt | status + event | — | PAYMENT_ATTEMPT_NOT_CANCELLABLE |
get() | GET /:id | attempt | — | — | PAYMENT_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
applyOutcome() | return routes, verification processor | attempt | status + verified amount + event | completion 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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
flag() | settlement/expiry paths | attempt | flag columns | — | — |
resolve() | POST /:id/resolve | attempt | note + flag cleared | — | PAYMENT_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:
- The per-attempt token — only its SHA-256 is stored; the raw value exists only in the URL.
- The HMAC over the gateway's declared field set (
signed_field_names) — every field deciding the outcome must be covered. - The server-to-server status check — a browser redirect is never proof of payment; the API re-queries the gateway.
- 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
| Queue | Jobs | Notes |
|---|---|---|
payment | verification (re-ask with bounded backoff), completion (COMPLETE_SETTLED_CHECKOUT via outbox), expiry sweep, maintenance | One 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_FOUNDfor 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) andAPI_PUBLIC_BASE_URLmust 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_URLmust 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.