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:
- Add the gateway name to the enum
- Create the gateway class
- Register it in the module
- 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 buildStep 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
| Decision | Options | Guidance |
|---|---|---|
initiationType | form_post, redirect, sdk | Match the gateway's client-side flow |
gatewayTransactionId format | Any unique string | Use ${referenceId}-${Date.now()} for easy debugging |
gatewayPayload keys | Gateway's exact field names | Use the names the gateway/SDK expects (e.g., PID, MERCHANTID, publicKey) |
| Signature algorithm | HMAC-SHA256, HMAC-SHA512, RSA, etc. | Whatever the gateway specifies |
| Config injection | ConfigService | Read 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/v2Step 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
| File | Why Not |
|---|---|
payment.interface.ts | The interface is gateway-agnostic |
purchase-subscription.dto.ts | Uses USER_PAYMENT_GATEWAYS (auto-derived) |
extend-subscription-mobile.dto.ts | Same as above |
purchase-response.dto.ts | Uses generic gatewayPayload: Record<string, string> |
subscription-mobile.controller.ts | Just passes data through |
subscription-mobile.service.ts | Uses UserPaymentGatewayType (auto-derived) |
| Frontend code | Handles 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:
| Gateway | Test Base URL | Docs |
|---|---|---|
| eSewa | https://rc-epay.esewa.com.np | developer.esewa.com.np |
| Fonepay | https://dev-clientapi.fonepay.com | Contact Fonepay |
| ConnectIPS | UAT environment (contact NCHL) | doc.connectips.com |
| Khalti | https://a.khalti.com (test keys) | docs.khalti.com |
Nepal Gateway Comparison
| Aspect | eSewa | Fonepay | ConnectIPS | Khalti |
|---|---|---|---|---|
| Initiation type | form_post | redirect | form_post | sdk |
| Signature algo | HMAC-SHA256 (base64) | HMAC-SHA512 (hex) | SHA256withRSA (base64) | N/A (API key auth) |
| Secret type | Shared string | Shared string | RSA cert (.pfx) | API key pair |
| Amount format | Rupees (string) | Rupees (number) | Paisa (integer) | Paisa (integer) |
| Callback delivery | base64 JSON on redirect | Query params on redirect | TXNID on redirect | Token from SDK |
| Callback URLs | Per transaction | Per transaction | Pre-registered | Per transaction |
| Status | Implemented | Stub | Stub | Not started |