Shop It Docs
Developer ResourcesPayment

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:

  1. Gateway abstraction — a registry of payment gateway implementations behind a common interface
  2. Payment records — CRUD for the payment database 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:

TypeFrontend BehaviorUsed By
form_postBuild a hidden <form> with gatewayPayload fields and POST to redirectUrleSewa, ConnectIPS
redirectAppend gatewayPayload as query params to redirectUrl and navigateFonepay
sdkPass gatewayPayload to the gateway's client-side SDKFuture: 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|failure and 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 old completed/failed rows from outbox_events using retention configuration.

Database Schema

payment Table

Generic payment records using polymorphic references — not tied to any specific entity.

ColumnTypeNullableDefaultNotes
idserialNoautoPrimary key
reference_idintegerNoID of the entity being paid for
reference_typevarchar(50)NoEntity type (e.g., "subscription", "order")
user_idtextNoFK → user.id (cascade)
amount_nprintegerNoAmount in NPR
gatewayvarchar(20)NoGateway used (see enum below)
gateway_transaction_idtextYesnullGateway's transaction reference
statusvarchar(20)No"pending"See status enum below
gateway_responsejsonbYesnullRaw response from gateway
created_attimestampNonow()
updated_attimestampNonow()

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

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