npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 PaymentGateway interface; 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 @WebhookEvent handlers, 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 init

This 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 rxjs

After 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=xxx
import { 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, utils

The 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-describing GatewayException until 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 page

2. 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_IDextra.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 lint

The 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).