Payment Module Architecture
Module structure, service layers, gateway registry pattern, database schema, and design decisions for the payment system.
Payment Module Architecture
Audience: Backend developers, system architects Scope: Module organization, gateway registry, database schema, design decisions
Overview
The payment module is a standalone, reusable module at apps/api/src/modules/payment/. It is not tied to any specific feature — subscriptions, orders, donations, or any future entity can use it for payment processing.
The module handles two concerns:
- Gateway abstraction — a registry of payment gateway implementations behind a common interface
- Payment records — CRUD for the
paymentdatabase table (pending → completed/failed lifecycle)
Current production consumers:
SubscriptionMobileService(subscription purchases)- Order pipeline processors (
OrderProcessor,OrderPaymentListener,PaymentRetryProcessor) for checkout, buy-now, and training enrollment orders
High-Level Architecture
Gateway Registry Pattern
Gateways are NestJS @Injectable() classes injected into PaymentService via the constructor. The service stores them in a Map<string, PaymentGateway> keyed by gateway name.
// apps/api/src/modules/payment/payment.service.ts
@Injectable()
export class PaymentService {
private readonly gateways = new Map<string, PaymentGateway>();
constructor(
@Inject(DATABASE) private readonly db: Database,
esewa: EsewaGateway,
fonepay: FonepayGateway,
connectIps: ConnectIpsGateway,
) {
this.gateways.set(esewa.name, esewa);
this.gateways.set(fonepay.name, fonepay);
this.gateways.set(connectIps.name, connectIps);
}
getGateway(name: string): PaymentGateway {
const gateway = this.gateways.get(name);
if (!gateway) {
throw new BadRequestException(`Unsupported payment gateway: ${name}`);
}
return gateway;
}
}This pattern means:
- Adding a new gateway = create class + register in constructor + add to module providers
- No interface changes, no controller changes, no DTO changes
- Gateway lookup is O(1) by name
Initiation Types
Nepal's payment gateways use different client-side initiation patterns. The initiationType field tells the frontend how to start the payment:
| Type | Frontend Behavior | Used By |
|---|---|---|
form_post | Build a hidden <form> with gatewayPayload fields and POST to redirectUrl | eSewa, ConnectIPS |
redirect | Append gatewayPayload as query params to redirectUrl and navigate | Fonepay |
sdk | Pass gatewayPayload to the gateway's client-side SDK | Future: Khalti, Stripe |
The frontend doesn't need gateway-specific code — it only needs to handle three initiation patterns:
// Frontend — generic for ANY gateway
if (initiationType === "form_post") {
const form = document.createElement("form");
form.method = "POST";
form.action = redirectUrl;
for (const [name, value] of Object.entries(gatewayPayload)) {
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
if (initiationType === "redirect") {
const url = new URL(redirectUrl);
for (const [key, value] of Object.entries(gatewayPayload)) {
url.searchParams.set(key, value);
}
window.location.href = url.toString();
}Payment Flow (End-to-End)
Order Payment Flow (Checkout / Buy-now / Training)
- Checkout/buy-now/training enrollment create pending order + payment synchronously in API service flow.
- API returns payment-init payload immediately (
paymentId,redirectUrl,gatewayPayload,initiationType). - Gateway redirects are verified through
/api/payments/redirect/:paymentId/success|failureand emit order completion/failure events. - Failed callbacks trigger automatic verification retries via
order.payment_retry(max attempts with exponential backoff). - Shared outbox retention is handled by
orders_maintenance.cleanup_outbox, which cleans oldcompleted/failedrows fromoutbox_eventsusing retention configuration.
Database Schema
payment Table
Generic payment records using polymorphic references — not tied to any specific entity.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id | serial | No | auto | Primary key |
reference_id | integer | No | — | ID of the entity being paid for |
reference_type | varchar(50) | No | — | Entity type (e.g., "subscription", "order") |
user_id | text | No | — | FK → user.id (cascade) |
amount_npr | integer | No | — | Amount in NPR |
gateway | varchar(20) | No | — | Gateway used (see enum below) |
gateway_transaction_id | text | Yes | null | Gateway's transaction reference |
status | varchar(20) | No | "pending" | See status enum below |
gateway_response | jsonb | Yes | null | Raw response from gateway |
created_at | timestamp | No | now() | |
updated_at | timestamp | No | now() |
Indexes:
payment_reference_idx— on(reference_id, reference_type)payment_user_id_idx— on(user_id)payment_status_idx— on(status)
Polymorphic Reference Pattern
The reference_id + reference_type pattern allows any entity to use the payment table:
// Subscription payment
{ referenceId: 42, referenceType: "subscription" }
// Order payment (checkout / buy-now / training)
{ referenceId: 15, referenceType: "order" }Enums
Defined as TypeScript as const arrays in packages/db/src/schema/payment.ts — no database migration needed to add values.
export const PAYMENT_STATUSES = ["pending", "completed", "failed", "refunded"] as const;
export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
export const PAYMENT_GATEWAYS = ["esewa", "fonepay", "connect_ips", "admin_grant"] as const;
export type PaymentGatewayType = (typeof PAYMENT_GATEWAYS)[number];For user-facing contexts (DTOs, controller validation), a filtered subset excludes admin_grant:
// apps/api/src/modules/subscription/types.ts
export const USER_PAYMENT_GATEWAYS = PAYMENT_GATEWAYS.filter(
(g): g is Exclude<PaymentGatewayType, "admin_grant"> => g !== "admin_grant",
);
export type UserPaymentGatewayType = (typeof USER_PAYMENT_GATEWAYS)[number];PaymentService API
getGateway(name: string): PaymentGateway
Returns the gateway instance by name. Throws BadRequestException if not found.
getSupportedGateways(): string[]
Returns the list of registered gateway names.
createPaymentRecord(referenceId, referenceType, userId, amountNpr, gateway)
Creates a pending payment record in the database. Returns the inserted record.
const record = await this.paymentService.createPaymentRecord(
subscription.id, // referenceId
"subscription", // referenceType
userId,
plan.priceNpr,
"esewa", // PaymentGatewayType
);markPaymentCompleted(paymentId, gatewayTransactionId, gatewayResponse)
Updates payment status to completed and stores the gateway transaction ID and raw response.
markPaymentFailed(paymentId, gatewayResponse)
Updates payment status to failed and stores the gateway response.
getPaymentById(paymentId): payment
Returns a payment record by ID. Throws NotFoundException if not found.
getPaymentsByReference(referenceId, referenceType): payment[]
Returns all payment records for a given reference.
File Structure
apps/api/src/modules/payment/
├── payment.interface.ts # PaymentGateway interface + InitiationType
├── payment.service.ts # Gateway registry + payment CRUD
├── payment.module.ts # NestJS module (exports PaymentService)
├── payment.service.spec.ts # Unit tests
└── gateways/
├── esewa.gateway.ts # eSewa ePay v2 (implemented)
├── fonepay.gateway.ts # Fonepay (stub)
└── connect-ips.gateway.ts # ConnectIPS (stub)
packages/db/src/schema/
└── payment.ts # payment table + enums + relationsDesign Decisions
Why gatewayPayload: Record<string, string> instead of typed payloads?
Each Nepal gateway has different form fields with different names. A typed payload per gateway (EsewaPayload, FonepayPayload, etc.) would:
- Pollute the shared interface with gateway-specific types
- Require frontend code changes for every new gateway
- Create a growing union type in the response DTO
With Record<string, string>, the frontend just iterates the keys — it works for any form_post or redirect gateway without code changes.
Why initiationType instead of just redirectUrl?
Nepal gateways use three different client-side patterns (form POST, GET redirect, SDK). The frontend needs to know which pattern to use. Without initiationType, the frontend would have to maintain a mapping of gateway name → initiation method, which defeats the purpose of the abstraction.
Why polymorphic references instead of a subscriptionId FK?
The payment table is designed to serve multiple consumers — subscriptions and orders today, and future entities later. A direct FK would couple it to subscriptions and require schema changes for each new consumer. The reference_id + reference_type pattern is a proven solution for this (used by Rails STI, Laravel morphs, etc.).
Why TS const arrays instead of pgEnums?
Database enums (pgEnum) require an ALTER TYPE ... ADD VALUE migration every time a new value is added. Since gateway types may change frequently (adding Khalti, removing a gateway, etc.), we use TypeScript as const arrays with varchar columns and .$type<>() for type safety. Adding a new gateway is a code-only change.