@loopbitstudio/nest-payment
v0.3.0
Published
Reusable NestJS payment platform: gateways, payments, webhooks, subscriptions, invoicing.
Readme
@loopbitstudio/nest-payment
A reusable NestJS payment ecosystem — gateway adapters, payment lifecycle, refunds, webhooks, subscriptions, invoicing/tax, and a typed event bus.
Deliberately decoupled from any specific ORM or database: the package owns no persistence. You implement narrow storage ports against whatever you already use (TypeORM, Prisma, Mongoose, Drizzle, raw SQL …), and the platform stays usable even with no storage bound.
📦 Published on npm:
@loopbitstudio/nest-payment.
Features
- Gateway abstraction — one
PaymentGatewayinterface; adapters for Razorpay, Stripe, Cashfree, PayU and PhonePe. Write your code once, swap providers freely. - Payments — create order, verify, capture, refund, status.
- Webhooks as a first-class module — signature verification, normalization, and routing to
@WebhookEventhandlers, with a ready-made endpoint. - Subscriptions — plans, trials, billing cycles, renewals, cancellation.
- Invoicing + tax — line items with GST (CGST/SGST/IGST) calculation, credit notes.
- Event-driven — typed domain events over
@nestjs/event-emitter; extend the platform without modifying it. - ORM-agnostic storage ports — bind your own persistence, or run stateless.
Installation
Fastest: one-command setup
Inside your NestJS project, run:
npx @loopbitstudio/nest-payment initThis installs the package + peer deps, scaffolds a payment/ module (wiring + an example controller + webhook handlers), seeds .env and .env.example with PAYMENT_PROVIDER and the gateway's credential keys, and wires PaymentModule into your AppModule plus { rawBody: true } and the exception filter into main.ts (writing *.bak backups). Re-running is safe — every step skips when it is already done, so real secrets are never clobbered. Pick a gateway with --gateway stripe|razorpay|cashfree|payu|phonepe; see npx @loopbitstudio/nest-payment init --help for all flags.
The app will not boot until you fill in the key and secret — missing gateway configuration is fatal by design. See docs/installation.md.
Manual install
npm install @loopbitstudio/nest-payment
# peer deps (if not already present)
npm install @nestjs/common @nestjs/core @nestjs/event-emitter class-validator class-transformer reflect-metadata rxjsAfter scaffolding, run your formatter (Prettier/ESLint) to tidy the generated wiring.
Quick start
1. Register the module
The gateway is chosen by the PAYMENT_PROVIDER environment variable, so switching providers is a deploy-time change rather than a code change:
# .env
PAYMENT_PROVIDER=razorpay # razorpay | stripe | cashfree | payu | phonepe
RAZORPAY_API_KEY=rzp_test_xxx
RAZORPAY_API_SECRET=xxx
RAZORPAY_WEBHOOK_SECRET=xxximport { Module } from '@nestjs/common';
import { PaymentPlatformModule } from '@loopbitstudio/nest-payment';
@Module({
imports: [
PaymentPlatformModule.forRootFromEnv({
// Optional — bind only the ports you use:
storage: { payment: new MyPaymentStore() },
// Optional — invoice/tax config:
invoice: { sellerGstin: '29ABCDE1234F1Z5', defaultTaxRate: 18 },
}),
],
})
export class AppModule {}Credentials are resolved when the module is instantiated, so this works with ConfigModule.forRoot() and dotenv. See Configuration reference for the full variable list.
forRoot() takes adapter instances directly — use it when credentials come from a secrets manager rather than the environment:
PaymentPlatformModule.forRoot({
gateways: [new RazorpayAdapter({ apiKey, apiSecret, webhookSecret })],
defaultGateway: GatewayProvider.RAZORPAY,
})2. Enable raw body + the exception filter
Webhook signature verification needs the raw request bytes, so create the app with { rawBody: true }:
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { PaymentExceptionFilter } from '@loopbitstudio/nest-payment';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { rawBody: true });
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new PaymentExceptionFilter()); // maps platform errors -> HTTP status
await app.listen(3000);
}
bootstrap();3. Use the services
import { Controller, Post, Body } from '@nestjs/common';
import { OrderService, RefundService } from '@loopbitstudio/nest-payment';
@Controller('checkout')
export class CheckoutController {
constructor(
private readonly orders: OrderService,
private readonly refunds: RefundService,
) {}
@Post('order')
create(@Body() body) {
// amounts are in minor units (paise/cents)
return this.orders.createOrder({ amount: 50000, currency: 'INR' as any });
}
}Architecture
@loopbitstudio/nest-payment
├── payment Create order, verify, capture, refund, status
├── gateway PaymentGateway interface + adapters + registry ← the core abstraction
├── webhook Signature verify → normalize → route (@WebhookEvent)
├── subscription Plans, trials, cycles, renewals, cancellation
├── invoice Invoice generation + GST/tax + credit notes
├── events Typed domain events + PlatformEventBus
└── common Enums, record interfaces, exceptions, storage ports, DI tokens, utilsThe dependency direction that matters: subscription → payment (a renewal creates a payment), not the other way around. The payment core is tax-agnostic — all GST logic is confined to the invoice module.
Gateways
Every provider is wrapped in an adapter implementing PaymentGateway:
export interface PaymentGateway {
readonly provider: GatewayProvider;
createOrder(input): Promise<GatewayOrder>;
fetchPayment(id): Promise<GatewayPayment>;
capturePayment(input): Promise<GatewayPayment>;
refund(input): Promise<GatewayRefund>;
verifyPaymentSignature(input): boolean; // client handshake
verifyWebhookSignature(input): boolean; // incoming webhook
parseWebhook(rawBody): NormalizedWebhookEvent;
}Resolve adapters via the GatewayRegistry (services do this for you); it falls back to defaultGateway when a provider isn't specified.
Implementation status: PhonePe is complete — it calls the PhonePe API directly and needs no subclassing (see PhonePe). For Razorpay, Stripe, Cashfree and PayU the signature-verification methods are fully implemented, but the API calls (
createOrder,capturePayment,refund,fetchPayment) throw a self-describingGatewayExceptionuntil you wire each provider's SDK/HTTP client into the adapter.
To complete an adapter, subclass the shipped one and register it — the env wiring will then build your class for that provider:
export class LiveRazorpayAdapter extends RazorpayAdapter {
async createOrder(input) { /* call the Razorpay SDK */ }
// …fetchPayment, capturePayment, refund
}
PaymentPlatformModule.forRootFromEnv({
adapters: { [GatewayProvider.RAZORPAY]: LiveRazorpayAdapter },
});Add a brand-new gateway by extending BaseGatewayAdapter and passing the instance to forRoot({ gateways: [...] }).
PhonePe
PhonePeAdapter implements PhonePe PG V1 (merchantId + SALT key + X-VERIFY checksum, not the newer OAuth Standard Checkout V2). It is wired end to end — orders, status, refunds and callbacks all hit the PhonePe API over fetch, with no extra dependency and nothing to subclass.
PAYMENT_PROVIDER=phonepe
PHONEPE_MERCHANT_ID=PGTESTPAYUAT # alias for PHONEPE_API_KEY
PHONEPE_SALT_KEY=xxxxxxxx # alias for PHONEPE_API_SECRET
PHONEPE_EXTRA_SALT_INDEX=1 # optional, defaults to 1
PHONEPE_EXTRA_REDIRECT_URL=https://app.example.com/payments/return
PHONEPE_EXTRA_CALLBACK_URL=https://api.example.com/webhooks/phonepe
# PHONEPE_WEBHOOK_SECRET is optional — it defaults to the SALT key, which is
# what PhonePe actually signs callbacks with.Four things behave differently from the other gateways, and consuming code has to know about each:
1. Checkout is a redirect. createOrder() returns a redirectUrl — send the browser there:
const { order } = await orderService.createOrder({ amount: 49_900, currency: Currency.INR, orderId });
return { redirectUrl: order.redirectUrl }; // PhonePe's hosted checkout page2. There is no gateway-side order id. PhonePe is keyed by a merchantTransactionId that we generate, returned as order.gatewayOrderId. It is derived from your receipt plus a random suffix (PhonePe rejects a reused id, so a retry of the same order must not resend the same one). fetchPayment() takes that id — not a payment id — and returns PhonePe's own transactionId as gatewayPaymentId.
3. There is no client-side signature. The browser comes back from the redirect carrying nothing trustworthy, so PhonePeAdapter.supportsPaymentSignature is false and PaymentService.verifyPayment() skips the signature step for it, relying on the server-to-server status call. Every other check — identifiers, gateway status, amount, currency, state transition — still runs. Call verifyPayment() without a signature, passing the merchant transaction id:
await paymentService.verifyPayment({
gateway: GatewayProvider.PHONEPE,
gatewayOrderId: merchantTransactionId,
gatewayPaymentId: merchantTransactionId, // no separate payment id exists yet
});4. INR only, auto-captured. A non-INR createOrder() throws rather than transacting a mismatched amount, and capturePayment() throws — PhonePe settles on completion, so a COMPLETED payment is already captured. Refunds need an explicit amount; there is no full-refund shortcut.
Callbacks arrive as { "response": "<base64 JSON>" } on POST /webhooks/phonepe, signed in the x-verify header with the callback checksum form (payload + salt, no API path). The adapter verifies constant-time, decodes the envelope, and composes an eventId of merchantTransactionId:state so replays dedupe on the transaction rather than on a body hash.
Webhooks
A ready-made endpoint is registered at POST /webhooks/:provider (disable with webhookController: false and call WebhookService.handle() from your own route).
React to events two ways:
// Option A — platform decorator, discovered automatically
import { WebhookEvent, WebhookEventType } from '@loopbitstudio/nest-payment';
@Injectable()
export class PaymentWebhooks {
@WebhookEvent(WebhookEventType.PAYMENT_CAPTURED)
onCaptured(event) { /* ... */ }
}
// Option B — standard NestJS event listener (same events are emitted on the bus)
import { OnEvent } from '@nestjs/event-emitter';
@OnEvent(WebhookEventType.PAYMENT_CAPTURED)
handle(event) { /* ... */ }The order of operations is security-critical and enforced by the platform: verify signature → parse → dispatch. Nothing downstream sees an unverified payload.
Events
Emitted via the typed PlatformEventBus and consumable with @OnEvent:
| Event | Name |
|---|---|
| PaymentCreatedEvent | payment.created |
| PaymentSucceededEvent | payment.succeeded |
| PaymentFailedEvent | payment.failed |
| RefundCreatedEvent | refund.created |
| RefundProcessedEvent | refund.processed |
| SubscriptionRenewedEvent | subscription.renewed |
| SubscriptionCancelledEvent | subscription.cancelled |
@OnEvent('payment.succeeded')
handleSuccess(event: PaymentSucceededEvent) {
sendReceiptEmail(event.payment);
}Storage (bring your own DB)
The platform defines persistence ports; you provide the implementation. All are optional and injected with @Optional() — unbound ports simply mean "not persisted".
| Port | Token | Used by |
|---|---|---|
| PaymentStorage | PAYMENT_STORAGE | payment, refund |
| SubscriptionStorage | SUBSCRIPTION_STORAGE | subscription |
| InvoiceStorage | INVOICE_STORAGE | invoice |
import { PaymentStorage, CreatePaymentRecord, PaymentRecord } from '@loopbitstudio/nest-payment';
export class TypeOrmPaymentStore implements PaymentStorage {
async createPayment(data: CreatePaymentRecord): Promise<PaymentRecord> { /* ... */ }
async updatePayment(id, patch) { /* ... */ }
async findPaymentById(id) { /* ... */ }
async findPaymentByGatewayId(gatewayPaymentId) { /* ... */ }
async createRefund(data) { /* ... */ }
async updateRefund(id, patch) { /* ... */ }
async findRefundById(id) { /* ... */ }
}
// then: PaymentPlatformModule.forRoot({ ..., storage: { payment: new TypeOrmPaymentStore() } })Subscriptions & invoicing
const plan = await planService.createPlan({
name: 'Pro', amount: 99900, currency: Currency.INR,
interval: BillingInterval.MONTH, intervalCount: 1, trialDays: 14,
});
const sub = await subscriptionService.create({ planId: plan.id, customerId: 'cus_1' });
await subscriptionService.renew(sub.id);
await subscriptionService.cancel(sub.id, { atPeriodEnd: true });
const invoice = await invoiceService.generate({
customerId: 'cus_1', currency: Currency.INR,
lineItems: [{ description: 'Pro plan', quantity: 1, unitAmount: 99900, taxRate: 18 }],
intraState: true, // CGST + SGST; false → IGST
});Subscription/invoice stateful operations require their respective storage ports.
InvoiceService.renderPdf()is a stub — plug in your preferred renderer.
Configuration reference
Environment variables
PAYMENT_PROVIDER selects the gateway; everything else is namespaced by that provider's name in upper case (RAZORPAY_*, STRIPE_*, CASHFREE_*, PAYU_*, PHONEPE_*).
| Variable | Description |
|---|---|
| PAYMENT_PROVIDER | Required. razorpay | stripe | cashfree | payu | phonepe. Comma-separate to register several — the first is the default. |
| <PROVIDER>_API_KEY | Required. Public key / key id. |
| <PROVIDER>_API_SECRET | Required. Signs requests and verifies payment signatures. |
| <PROVIDER>_WEBHOOK_SECRET | Verifies inbound webhooks. Without it, webhooks are rejected (warned at boot). |
| <PROVIDER>_SANDBOX | true/false. Falls back to PAYMENT_SANDBOX, then to NODE_ENV !== 'production'. |
| <PROVIDER>_EXTRA_* | Provider-specific extras → config.extra, camelCased (RAZORPAY_EXTRA_ACCOUNT_ID → extra.accountId). |
Each provider's own credential names are accepted as aliases, so you can paste straight from a dashboard: RAZORPAY_KEY_ID/RAZORPAY_KEY_SECRET, STRIPE_SECRET_KEY, CASHFREE_APP_ID/CASHFREE_SECRET_KEY, PAYU_MERCHANT_KEY/PAYU_MERCHANT_SALT, PHONEPE_MERCHANT_ID/PHONEPE_SALT_KEY.
Misconfiguration is fatal at boot, not at first charge: an unknown PAYMENT_PROVIDER or a missing key/secret throws GatewayConfigurationException naming the exact variables.
forRootFromEnv(options)
| Option | Type | Description |
|---|---|---|
| adapters | Partial<Record<GatewayProvider, GatewayAdapterCtor>> | Use your own adapter subclass for a provider (see Gateways). |
| env | Record<string, string \| undefined> | Read from something other than process.env (tests). |
| requireCredentials | boolean | Fail on missing key/secret. Default true. |
| defaultGateway | GatewayProvider | Override the first entry of PAYMENT_PROVIDER. |
| storage | { payment?, ledger?, outbox?, webhookEvents?, idempotency?, subscription?, invoice? } | Persistence implementations (all optional). |
| invoice | { sellerGstin?, defaultTaxRate?, intraStateByDefault? } | Tax/invoice config. |
| webhookController | boolean | Register POST /webhooks/:provider. Default true. |
forRoot(options)
Identical, except the gateway is passed in rather than read from the environment:
| Option | Type | Description |
|---|---|---|
| gateways | PaymentGateway[] | Required. Configured adapter instances. |
| defaultGateway | GatewayProvider | Fallback provider. Defaults to gateways[0]. |
Money is always expressed in the currency's minor unit (paise/cents) as integers, to avoid floating-point drift.
Development
npm install
npm run build # nest build (tsc) → dist/
npm run start:dev # run the local dev harness (src/app.module.ts)
npm run test # jest
npm run lintThe repo includes a small dev harness (app.module.ts, main.ts) showing the library wired up; it's excluded from the published package (files: ["dist"]).
What's implemented vs. left to you
| Implemented | Left for you (clearly stubbed) |
|---|---|
| Gateway interface, registry, adapter scaffolds | Adapter API calls for Razorpay / Stripe / Cashfree / PayU |
| PhonePe adapter end-to-end — orders, status, refunds, callbacks | |
| Webhook signature verify + normalize + route | Concrete storage port implementations |
| Payment/refund/subscription/invoice service logic | InvoiceService.renderPdf() |
| GST/tax calculation, money & HMAC utils | Provider credentials & DB |
| Typed events + event bus | |
Stubs throw a descriptive error rather than silently no-op'ing, so partially-wired adapters fail loudly.
License
UNLICENSED (update before publishing).
