@codecanva/payment-platform
v0.2.0
Published
Reusable NestJS payment platform: gateways, payments, webhooks, subscriptions, invoicing.
Readme
@codecanva/payment-platform
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:
@codecanva/payment-platform.
Features
- Gateway abstraction — one
PaymentGatewayinterface; adapters for Razorpay, Stripe, Cashfree, PayU. 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 @codecanva/payment-platform initThis installs the package + peer deps, scaffolds a payment/ module (wiring + an example controller + webhook handlers), seeds .env.example, and wires PaymentModule into your AppModule plus { rawBody: true } and the exception filter into main.ts (writing *.bak backups). Pick a gateway with --gateway stripe|razorpay|cashfree|payu; see npx @codecanva/payment-platform init --help for all flags.
Manual install
npm install @codecanva/payment-platform
# 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
import { Module } from '@nestjs/common';
import {
PaymentPlatformModule,
RazorpayAdapter,
GatewayProvider,
} from '@codecanva/payment-platform';
@Module({
imports: [
PaymentPlatformModule.forRoot({
gateways: [
new RazorpayAdapter({
apiKey: process.env.RAZORPAY_KEY_ID,
apiSecret: process.env.RAZORPAY_KEY_SECRET,
webhookSecret: process.env.RAZORPAY_WEBHOOK_SECRET,
}),
],
defaultGateway: GatewayProvider.RAZORPAY,
// Optional — bind only the ports you use:
storage: { payment: new MyPaymentStore() },
// Optional — invoice/tax config:
invoice: { sellerGstin: '29ABCDE1234F1Z5', defaultTaxRate: 18 },
}),
],
})
export class AppModule {}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 '@codecanva/payment-platform';
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 '@codecanva/payment-platform';
@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
@codecanva/payment-platform
├── 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: the signature-verification methods are fully implemented. The API calls (
createOrder,capturePayment,refund,fetchPayment) throw a self-describingGatewayExceptionuntil you wire each provider's SDK/HTTP client into the adapter.
Add your own gateway by extending BaseGatewayAdapter and passing the instance to gateways: [...].
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 '@codecanva/payment-platform';
@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 '@codecanva/payment-platform';
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
PaymentPlatformModule.forRoot(options):
| Option | Type | Description |
|---|---|---|
| gateways | PaymentGateway[] | Required. Configured adapter instances. |
| defaultGateway | GatewayProvider | Fallback provider. Defaults to gateways[0]. |
| storage | { payment?, subscription?, invoice? } | Persistence implementations (all optional). |
| invoice | { sellerGstin?, defaultTaxRate?, intraStateByDefault? } | Tax/invoice config. |
| webhookController | boolean | Register POST /webhooks/:provider. Default true. |
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 (real provider SDK/HTTP) |
| 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).
