Shop It Docs
Developer ResourcesPayment

Adding a New Gateway

Step-by-step guide to adding a new payment gateway — from enum registration to implementation and testing.

Adding a New Gateway

Audience: Backend developers Scope: Checklist for adding a new payment gateway to the system


Overview

The payment module is designed so that adding a new gateway requires zero changes to interfaces, DTOs, controllers, or frontend code. You only need to:

  1. Add the gateway name to the enum
  2. Create the gateway class
  3. Register it in the module
  4. Add environment variables

Step 1: Add to the Gateway Enum

In packages/db/src/schema/payment.ts:

export const PAYMENT_GATEWAYS = [
  "esewa",
  "fonepay",
  "connect_ips",
  "admin_grant",
  "khalti",  // ← add your gateway here
] as const;

Since we use TypeScript as const arrays (not pgEnums), this is a code-only change — no database migration needed. The varchar(20) column accepts any string; the TypeScript type ensures compile-time safety.

After adding here, rebuild the @bullhouse/db package:

pnpm --filter @bullhouse/db build

Step 2: Create the Gateway Class

Create apps/api/src/modules/payment/gateways/khalti.gateway.ts:

import { createHmac } from "node:crypto";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type {
  InitiatePaymentParams,
  InitiatePaymentResult,
  PaymentGateway,
  VerifyPaymentParams,
  VerifyPaymentResult,
} from "../payment.interface";

@Injectable()
export class KhaltiGateway implements PaymentGateway {
  readonly name = "khalti";
  private readonly logger = new Logger(KhaltiGateway.name);

  constructor(private readonly config: ConfigService) {
    // Read config values in constructor
  }

  async initiate(params: InitiatePaymentParams): Promise<InitiatePaymentResult> {
    // 1. Generate a unique transaction ID
    const transactionId = `${params.referenceId}-${Date.now()}`;

    // 2. Build the signed payload for the frontend
    //    - Compute signature/hash if the gateway requires it
    //    - Include all fields the gateway expects

    // 3. Return the result
    return {
      gatewayTransactionId: transactionId,
      initiationType: "sdk",  // or "form_post" or "redirect"
      redirectUrl: "https://khalti.com/payment/...",
      gatewayPayload: {
        // Fields specific to this gateway
        publicKey: "test_public_key_...",
        productIdentity: transactionId,
        productName: "Subscription",
        amount: String(params.amountNpr * 100), // Khalti uses paisa
        // ... other fields
      },
    };
  }

  async verify(params: VerifyPaymentParams): Promise<VerifyPaymentResult> {
    // 1. Validate the callback data (signature check if applicable)
    // 2. Call the gateway's server-side verification API
    // 3. Return the result

    return {
      success: true, // or false based on verification
      amountNpr: 500,
    };
  }
}

Key Decisions When Implementing

DecisionOptionsGuidance
initiationTypeform_post, redirect, sdkMatch the gateway's client-side flow
gatewayTransactionId formatAny unique stringUse ${referenceId}-${Date.now()} for easy debugging
gatewayPayload keysGateway's exact field namesUse the names the gateway/SDK expects (e.g., PID, MERCHANTID, publicKey)
Signature algorithmHMAC-SHA256, HMAC-SHA512, RSA, etc.Whatever the gateway specifies
Config injectionConfigServiceRead all gateway config in the constructor

Step 3: Register in the Module

3a. Add to PaymentModule providers

In apps/api/src/modules/payment/payment.module.ts:

import { KhaltiGateway } from "./gateways/khalti.gateway";

@Module({
  providers: [
    EsewaGateway,
    FonepayGateway,
    ConnectIpsGateway,
    KhaltiGateway,        // ← add here
    PaymentService,
  ],
  exports: [PaymentService],
})
export class PaymentModule {}

3b. Add to PaymentService constructor

In apps/api/src/modules/payment/payment.service.ts:

import { KhaltiGateway } from "./gateways/khalti.gateway";

constructor(
  @Inject(DATABASE) private readonly db: Database,
  esewa: EsewaGateway,
  fonepay: FonepayGateway,
  connectIps: ConnectIpsGateway,
  khalti: KhaltiGateway,  // ← inject
) {
  this.gateways.set(esewa.name, esewa);
  this.gateways.set(fonepay.name, fonepay);
  this.gateways.set(connectIps.name, connectIps);
  this.gateways.set(khalti.name, khalti);  // ← register
}

Step 4: Add Environment Variables

4a. Add to Zod validation

In apps/api/src/config/env.validation.ts:

KHALTI_SECRET_KEY: z.string().optional(),
KHALTI_PUBLIC_KEY: z.string().optional(),
KHALTI_BASE_URL: z.string().url().default("https://a.khalti.com/api/v2"),

4b. Add to sample.env

# Khalti
KHALTI_SECRET_KEY=test_secret_key_...
KHALTI_PUBLIC_KEY=test_public_key_...
KHALTI_BASE_URL=https://a.khalti.com/api/v2

Step 5 (Optional): Update User-Facing Gateway List

If this is a user-facing gateway (not internal like admin_grant), no changes are needed — USER_PAYMENT_GATEWAYS is derived by filtering out admin_grant from PAYMENT_GATEWAYS:

// apps/api/src/modules/subscription/types.ts — auto-includes new gateways
export const USER_PAYMENT_GATEWAYS = PAYMENT_GATEWAYS.filter(
  (g): g is Exclude<PaymentGatewayType, "admin_grant"> => g !== "admin_grant",
);

The DTO validation (@IsIn(USER_PAYMENT_GATEWAYS)) automatically accepts the new gateway. No DTO or controller changes needed.


What You Don't Need to Change

FileWhy Not
payment.interface.tsThe interface is gateway-agnostic
purchase-subscription.dto.tsUses USER_PAYMENT_GATEWAYS (auto-derived)
extend-subscription-mobile.dto.tsSame as above
purchase-response.dto.tsUses generic gatewayPayload: Record<string, string>
subscription-mobile.controller.tsJust passes data through
subscription-mobile.service.tsUses UserPaymentGatewayType (auto-derived)
Frontend codeHandles initiationType generically (unless new type)

Testing

Unit Test Template

describe("KhaltiGateway", () => {
  let gateway: KhaltiGateway;
  let configService: ConfigService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        KhaltiGateway,
        {
          provide: ConfigService,
          useValue: {
            getOrThrow: jest.fn((key: string) => {
              const config: Record<string, string> = {
                KHALTI_SECRET_KEY: "test_secret",
                KHALTI_PUBLIC_KEY: "test_public",
                KHALTI_BASE_URL: "https://a.khalti.com/api/v2",
              };
              return config[key];
            }),
          },
        },
      ],
    }).compile();

    gateway = module.get(KhaltiGateway);
  });

  it("should return correct initiationType", async () => {
    const result = await gateway.initiate({
      amountNpr: 500,
      referenceId: 1,
      referenceType: "subscription",
      userId: "user_1",
      returnUrl: "https://app.com/callback",
    });

    expect(result.initiationType).toBe("sdk");
    expect(result.gatewayPayload).toBeDefined();
    expect(result.gatewayTransactionId).toBeDefined();
  });

  it("should verify successful payment", async () => {
    jest.spyOn(global, "fetch").mockResolvedValueOnce({
      ok: true,
      json: async () => ({ status: "Completed", amount: 50000 }),
    } as Response);

    const result = await gateway.verify({
      gatewayTransactionId: "1-1708000000000",
      gatewayResponse: { token: "khalti_token_123" },
    });

    expect(result.success).toBe(true);
    expect(result.amountNpr).toBe(500);
  });
});

Integration Testing

Use the gateway's sandbox/test environment with test credentials. Each Nepal gateway provides a test mode:

GatewayTest Base URLDocs
eSewahttps://rc-epay.esewa.com.npdeveloper.esewa.com.np
Fonepayhttps://dev-clientapi.fonepay.comContact Fonepay
ConnectIPSUAT environment (contact NCHL)doc.connectips.com
Khaltihttps://a.khalti.com (test keys)docs.khalti.com

Nepal Gateway Comparison

AspecteSewaFonepayConnectIPSKhalti
Initiation typeform_postredirectform_postsdk
Signature algoHMAC-SHA256 (base64)HMAC-SHA512 (hex)SHA256withRSA (base64)N/A (API key auth)
Secret typeShared stringShared stringRSA cert (.pfx)API key pair
Amount formatRupees (string)Rupees (number)Paisa (integer)Paisa (integer)
Callback deliverybase64 JSON on redirectQuery params on redirectTXNID on redirectToken from SDK
Callback URLsPer transactionPer transactionPre-registeredPer transaction
StatusImplementedStubStubNot started