Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the Payment module.
Use this page for the payment domain: what it does for customers, admins and the gateway, and how each flow behaves from start to finish.
| Source Type | Files or Docs | What Was Extracted |
|---|
| API | apps/api/src/modules/payment/customer/payment-customer.controller.ts, admin/payment-admin.controller.ts, gateway-return/gateway-return.controller.ts | Routes, permissions, statuses |
| Backend | shared/payment-attempt.service.ts, payment-settlement.service.ts, payment-completion.service.ts, payment-reconciliation.service.ts, payment-verification.processor.ts | Transitions, verification, reconciliation |
| Gateways | gateways/esewa.gateway.ts, cod.gateway.ts, payment-gateway.port.ts | The port, the amount-carrying outcome |
| Schema | packages/db/src/schema/payment/{payment-attempt,payment-event,enums}.ts | Six-status enum, flag columns, partial uniques |
| Error registry | apps/api/src/common/types/error-codes.ts (// PAYMENT) | PAYMENT_* codes |
| Field | Value |
|---|
| Module | payment |
| Submodule | N/A (eSewa gateway + COD gateway behind one port) |
| Primary user value | The customer pays for a frozen checkout and learns one of seven business outcomes; the operator reconciles the cases where money moved and no sale was recorded |
| Actors | Customer, admin, gateway (eSewa), Order module (future) |
| Main entry points | /api/mobile/payments/* (4), /api/payments/esewa/return/* (4 method variants = 2 paths), /api/payments* (3 admin) |
| Main outputs | Payment attempts with signed redirect forms, status polls, admin timelines |
| Related docs | API, Backend |
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|
| Customer | List methods, start a payment, poll an attempt, cancel an attempt | Call gateway-return routes, mark a payment paid, learn eSewa's vocabulary | JWT | PAYMENT_ALREADY_SETTLED is a success wearing a 409 — see §5.2 |
| Admin | List (incl. the reconciliation queue), read the full timeline, resolve a flagged payment | Mark paid, edit an amount, retry a charge, refund | Admin JWT + Payments_READ/Payments_UPDATE | Resolve records a note; changes no status |
| Gateway (eSewa) | Call the return routes | — | Per-attempt token in the URL | The routes carry a secret — never call them |
| Order (future) | Create the order from a paid payment | — | Internal | A paid payment means the checkout is completed |
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|
| List methods | Customer | Customer | GET /api/mobile/payments/methods | gateway registry | — | API §4 |
| Start payment | Customer | Customer | POST /api/mobile/payments | checkout, attempts | attempt + event | API |
| Poll attempt | Customer | Customer | GET /api/mobile/payments/:id | attempts | — | API |
| Cancel attempt | Customer | Customer | POST /api/mobile/payments/:id/cancel | attempt | status + event | API |
| Gateway return | Gateway | eSewa | GET/POST /api/payments/esewa/return/:id/:token/success-or-failure | attempt | status + events | API |
| Admin list | Admin | Admin | GET /api/payments | attempts | — | API |
| Admin timeline | Admin | Admin | GET /api/payments/:id | attempt + events | — | API |
| Admin resolve | Admin | Admin | POST /api/payments/:id/resolve | attempt | flag cleared + note | API |
A customer picks eSewa. The API creates an attempt, builds a signed form, and the client hands the customer to eSewa. eSewa returns to the API, which verifies server-to-server, then redirects the browser to the storefront result page.
| Branch | Condition | Behavior | Error/Result |
|---|
| Double submit | Attempt already in flight | 200 with the same body — not an error | same attempt |
| Other method live | Different method in flight | 409 | PAYMENT_ATTEMPT_ALREADY_LIVE |
| Gateway unanswered | Previous attempt pending | 409 — do not offer a retry, poll | PAYMENT_VERIFICATION_PENDING |
| Already settled | Checkout paid, order being written | 409 — a success wearing 409 | PAYMENT_ALREADY_SETTLED |
| eSewa not configured | No credentials | Method list has only COD | PAYMENT_METHOD_UNAVAILABLE if asked |
status | What happened | What the client does |
|---|
awaiting_gateway | Attempt exists; hand to the gateway | POST redirect.fields to redirect.url as a form |
processing | Gateway not final — also what needs-manual-review reports, deliberately | Show "confirming", poll after retryAfterSeconds; for review, show the message verbatim, no retry button |
paid | Done; order can be created | Success — always render it as one; if shortVariants (renamed from shortProducts) is non-empty, additionally say those items will not be included. Never render paid as a plain success without checking it first |
failed_retryable | Not paid; checkout still usable | Show message, offer another method |
failed_final | Not paid; checkout closed | Show message, back to cart |
cancelled | Customer stopped it | Back to checkout |
already_settled | This attempt was already resolved | Treat as paid |
processing is what a payment needing manual review reports — money may have moved and a human is looking at it; telling the customer it failed would invite them to pay again.
COD returns status: "paid" and redirect: null in the same response — nothing to hand off; the order can be created immediately. COD is a payment method, not a bypass: same attempt row, same transitions, same audit trail. Its "gateway" approves at hand-off.
After eSewa, the API lands the browser on {FRONTEND_BASE_URL}{PAYMENT_RESULT_PATH}?status=…&payment={attemptId}&checkout={checkoutId} (status ∈ paid, processing, failed, cancelled, unknown). Treat the query string as a hint, not as truth — it is unauthenticated and a customer can edit it. Call GET /api/mobile/payments/:id and render from that.
- List — standard pagination; filters
status, method, customerId, checkoutId, minAmount, maxAmount, awaitingReconciliation, sortBy, order.
awaitingReconciliation=true is the queue that matters — payments where money moved and no sale was recorded. The screen an operator opens every morning; it empties as they resolve.
- Detail — the attempt plus
timeline: every transition and every gateway message, oldest first, append-only. The row says where a payment ended; the timeline says how it got there — what a disputed charge needs. Gateway payloads are already redacted.
- Resolve —
{ "note": "…" } (10–2000 chars) 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.
| From | Event/Action | To | Guard Condition | Side Effects |
|---|
| — | create | initiated | checkout not expired | — |
initiated | hand to gateway | awaiting_gateway | method configured | signed form (eSewa) |
awaiting_gateway | gateway contacted | pending_verification | — | poll begins |
pending_verification | verified, amount matches | succeeded | confirmed amount = requested (CHECK) | checkout completes + outbox enqueue in the same tx |
pending_verification | gateway declines | failed | — | retryable/final per outcome |
pending_verification | gave up after asks | expired | backoff exhausted | flagged for reconciliation |
| any non-terminal | customer cancels | cancelled | not paid | — |
payment_attempt_status has SIX values — initiated, pending_verification, succeeded, failed, cancelled, expired. There is no seventh "needs a human" status: that is the reconciliation flag (reconciliation_flagged_at + reconciliation_reason), orthogonal to the outcome and true of succeeded, failed and expired attempts alike.
A gateway that says "ask later" parks the attempt (pending_verification), keeps the checkout's payment slot held so a second charge cannot start, and is re-asked on a bounded backoff. Running out of asks expires the attempt and flags it — a customer whose money left their account deserves better than a silent expiry.
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|
| Start payment | attempt + event | — | — | — | — | — |
| Gateway success | attempt → succeeded + event | — | outbox → COMPLETE_SETTLED_CHECKOUT (same tx) | — | — | — |
| Verify/poll | events | — | verification retries (bounded backoff) | — | — | — |
| Expiry | attempt → expired + flag | — | expiry sweep | — | — | — |
| Resolve | flag cleared + note | — | — | — | — | — |
Payment caches nothing.
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|
| Double-charge risk | Two refusals are NOT failures | See §5.1 branches | Poll, never retry | PAYMENT_VERIFICATION_PENDING, PAYMENT_ALREADY_SETTLED |
| Gateway silent | No callback | Attempt pending until backoff exhausts | Expiry + flag → admin resolve | verification processor |
| Amount mismatch | Gateway confirms different figure | Refused by CHECK; flagged | Admin resolve | schema |
| Confirmation lands late | On a closed attempt | Zero matched rows → re-read, never benign | Flag if genuinely stranded | settlement service |
| Method unavailable | Not configured | 400 | Re-fetch /methods | registry |
- Actor capability diagram — §3/§4.
- Sequence diagram per major flow — §5.1.
- State machine diagram — §7.1.
- Data side-effect diagram — §9.
- Error branch diagram — §10.
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|
| Start | 201 vs 200 | Customer | Double submit | Both same body | status code distinguishes | |
| Start | Signed form | Customer | eSewa | POST exactly as given | HMAC over field set | |
| Start | COD instant paid | Customer | COD | redirect null | same transitions | |
| Poll | Never re-queries gateway | Customer | Repeated calls | Free | reads state only | |
| Return | Token in URL | Gateway | Redirect | SHA-256 stored only | redactUrlSecrets | |
| Verify | Server-to-server | System | Return hit | Browser redirect never proof | status API | |
| Admin list | Reconciliation queue | Admin | awaitingReconciliation=true | Morning screen | — | |
| Admin detail | Timeline | Admin | Dispute | Append-only events | payloads redacted | |
| Resolve | Note only | Admin | Decision | Queue empties | no status change | |
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|
No payment table | One copy of the amount | — | schema (none exists) | — | grand_total stays singular | probe |
| Outcome carries amount | Replay unwritable | Safe charges | port + CHECK | — | verified amount must match | int spec |
| Succeeded before complete | No order without money | — | ordering + outbox | — | COMPLETE_SETTLED_CHECKOUT same tx | spec |
| Reconciliation = flag | Outcome not erased | Queue screen | columns | awaitingReconciliation | six-status enum | probe |
| Zero-match re-read | No silent discards | — | service | — | re-read before benign | int spec |
| Four return defences | Forged redirects | Safe | token + HMAC + status check + DB | — | redaction | spec |
| COD same path | No bypass | Stock decrement | port | — | same transitions | spec |
| Never write checkout_session | No silent bypass | — | service boundary | — | inventory finalisation intact | spec |
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|
| Attempt/event tables | Audit trail | Replay-proof | payment table | More rows | Accepted |
| Short transactions | No locks over HTTP | Idempotent writes | One tx | Partial states | Attempt-scoped |
| Flag not status | succeeded_at survives | Queue by flag | Seven-status enum | Two columns | Documented |
| Signed form to client | Storefront never learns gateway | — | Server-side redirect | Client must POST exactly | Warned |
| No admin mutations | Constraint-refused | No repriced charges | Admin override | Support burden | Resolve note |
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|
| Start | Checkout lapsed | expires_at passed | 409 | CHECKOUT_SESSION_EXPIRED | |
| Start | Not their checkout | Wrong id | 404 (no oracle) | CHECKOUT_SESSION_NOT_FOUND | |
| Start | Method unlisted | Not configured | 400 | PAYMENT_METHOD_UNAVAILABLE | |
| Return | Forged token | Attacker | Refused | — | SHA-256 + HMAC |
| Return | Tampered fields | Edited form | HMAC fails | — | |
| Verify | Status API omits amount | Gateway bug | Port rejects | flagged | |
| Confirm | Late on closed attempt | Slow gateway | Re-read, flag | — | |
| Expiry | Backoff exhausted | Gateway silent | expired + flagged | admin queue | |
| Cancel | Already paid | Double tap | 409 NOT_CANCELLABLE | show success | |
| Resolve | Already resolved | Two admins | 400 | PAYMENT_RESOLUTION_NOT_APPLICABLE | |
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|
| Start | checkout, attempts, registry | attempt + event | — | — | id, status, amount, redirect |
| Poll | attempts | — | — | — | status, message, retryAfterSeconds |
| Return/verify | attempt | status + events | — | outbox → complete | 303 to result page |
| Admin list | attempts | — | — | — | rows + pagination |
| Resolve | attempt | flag + note | — | — | message |