@ambushsoftworks/nestjs-payments-graphql
v0.1.2
Published
NestJS payments module with GraphQL support — invoicing, payment processing, recurring billing, and email notifications
Downloads
259
Maintainers
Readme
@ambushsoftworks/nestjs-payments-graphql
Production-grade payments module for NestJS with GraphQL support. Invoicing, Stripe payment processing, refunds, payment plans, recurring invoicing with auto-charge, e-transfer, and composable email notifications — with zero database coupling.
Table of Contents
- Design Principles
- Installation
- Quick Start
- Architecture
- Features
- Configuration Reference
- DI Tokens
- Reserved GraphQL Type Names
- Exceptions
- Critical Integration Requirements
- Local Development
- License
Design Principles
- Interface-driven persistence — All storage is behind interfaces (
IInvoiceRepository,IPaymentRepository,IRefundRepository, etc.). Bring your own ORM; Prisma is the expected default, but any ORM works. - Instance-based DI — Repositories and adapters are injected as instances via
useFactory, not as classes. - Framework-agnostic core — Services are pure business logic. Resolvers, schedulers, and Prisma adapters live in your application.
- Consumer owns GraphQL decoration — The package ships
@ObjectType/@InputTypeclasses. You build resolvers that call services and return these types. - Composable email — A sender (transport) plus a renderer (HTML) plus a branding resolver plus a client resolver. All but the first two are optional with sane defaults.
- Stripe is bundled, not required — The Stripe gateway and webhook controller ship with the package, but are only activated when
stripeconfig is provided.
Installation
npm install @ambushsoftworks/nestjs-payments-graphql
# or
pnpm add @ambushsoftworks/nestjs-payments-graphqlPeer Dependencies
npm install @nestjs/common @nestjs/core @nestjs/graphql \
class-transformer class-validator graphql graphql-type-json \
reflect-metadata rxjsstripe is bundled as a hard dependency of this package — you do not need to install it separately.
Quick Start
A minimal setup: invoices, manual payments, and a Stripe gateway.
1. Implement the required repositories
Each repository is a pure TypeScript interface backed by your ORM. Here is the sketch for Prisma. A ready-to-copy reference schema lives at prisma/payment-models.prisma and ships inside the npm tarball — it contains every model and enum you need, with consumer-owned relations marked // CONSUMER:.
// prisma-invoice.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import {
IInvoiceRepository,
UniqueConstraintViolationException,
} from '@ambushsoftworks/nestjs-payments-graphql';
@Injectable()
export class PrismaInvoiceRepository implements IInvoiceRepository {
constructor(private readonly prisma: PrismaService) {}
async create(data) {
try {
return await this.prisma.invoice.create({ data });
} catch (e: any) {
if (e.code === 'P2002') throw new UniqueConstraintViolationException();
throw e;
}
}
async findById(id) { return this.prisma.invoice.findUnique({ where: { id } }); }
// ... implement the rest of IInvoiceRepository
async withTransaction(fn) {
return this.prisma.$transaction(async (tx) => {
const scoped = new PrismaInvoiceRepository({ ...this.prisma, invoice: tx.invoice } as any);
return fn(scoped);
});
}
}Repositories you must implement:
| Interface | Purpose |
|-----------|---------|
| IInvoiceRepository | Invoice + line item persistence |
| IPaymentRepository | Payment records |
| IRefundRepository | Refund records |
| IPaymentConfigRepository | Per-division payment config + providers |
| IWebhookIdempotencyRepository | Dedupe Stripe webhook deliveries |
| ITransactionManager | Multi-repo transaction coordinator |
Opt-in interfaces:
| Interface | Required when |
|-----------|---------------|
| IPaymentPlanRepository | features.paymentPlans = true |
| IRecurringInvoiceRepository | features.recurringInvoices = true |
| IPaymentCustomerRepository | features.recurringInvoices = true |
2. Register the module
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PaymentsModule } from '@ambushsoftworks/nestjs-payments-graphql';
@Module({
imports: [
ConfigModule.forRoot(),
PaymentsModule.forRootAsync({
imports: [ConfigModule, MyRepositoriesModule, MyAdaptersModule],
inject: [
PrismaInvoiceRepository,
PrismaPaymentRepository,
PrismaRefundRepository,
PrismaPaymentConfigRepository,
PrismaWebhookIdempotencyRepository,
PrismaTransactionManager,
ConfigService,
],
useFactory: (invoices, payments, refunds, config, webhooks, tx, cfg) => ({
invoiceRepositoryInstance: invoices,
paymentRepositoryInstance: payments,
refundRepositoryInstance: refunds,
paymentConfigRepositoryInstance: config,
webhookIdempotencyRepositoryInstance: webhooks,
transactionManagerInstance: tx,
stripe: {
secretKey: cfg.get('STRIPE_SECRET_KEY'),
webhookSecret: cfg.get('STRIPE_WEBHOOK_SECRET'),
},
defaultCurrency: 'USD',
}),
}),
],
})
export class AppModule {}3. Enable rawBody for Stripe signature verification
Stripe's webhook signature check requires the unparsed request body.
// main.ts
const app = await NestFactory.create(AppModule, { rawBody: true });4. Exempt the webhook path from auth
The controller is registered at POST /webhooks/stripe and marked with @PaymentsWebhook(). Your auth/tenant guards must respect this metadata. Either:
- Have guards check
Reflector.get(PAYMENTS_WEBHOOK, handler)and bail out early, or - Have guards check the route path against the exported
PAYMENTS_WEBHOOKstring constant.
See Critical Integration Requirements.
5. Build a resolver
import { Resolver, Mutation, Args } from '@nestjs/graphql';
import {
InvoiceService,
InvoiceModel,
CreateInvoiceInput,
} from '@ambushsoftworks/nestjs-payments-graphql';
@Resolver()
export class InvoiceResolver {
constructor(private readonly invoices: InvoiceService) {}
@Mutation(() => InvoiceModel)
async createInvoice(
@Args('input') input: CreateInvoiceInput,
// resolve divisionId + userId from your auth/tenant context
) {
return this.invoices.createInvoice(divisionId, input, userId);
}
}Architecture
┌──────────────────────────────────────────────────────────────┐
│ Consumer App (e.g. Ariadne API) │
│ ────────────────────────────── │
│ • Prisma repositories (implements IInvoiceRepository, ...) │
│ • Resolvers (GraphQL queries/mutations) │
│ • Schedulers (@nestjs/schedule) │
│ • Auth guards (respect @PaymentsWebhook metadata) │
│ • Event listener (implements IPaymentEventListener) │
│ • Email adapters (IPaymentEmailSender, IPaymentClient...) │
│ │
│ Wires together via PaymentsModule.forRootAsync({ ... }) │
└──────────────────────────────────────────────────────────────┘
↓ imports
┌──────────────────────────────────────────────────────────────┐
│ @ambushsoftworks/nestjs-payments-graphql │
│ ───────────────────────────────────────── │
│ • Services (pure business logic) │
│ • Interfaces (what consumer must implement) │
│ • DI tokens (Symbols) │
│ • GraphQL models + input types │
│ • StripeGateway + StripeWebhookController (opt-in) │
│ • Exceptions │
└──────────────────────────────────────────────────────────────┘The dynamic module is registered as global: true — any module in your app can inject services and tokens without re-importing.
Features
Invoicing
- Create DRAFT invoices with optional line items.
- Line item edits allowed only in DRAFT; totals auto-recalculate on mutation.
InvoiceNumberServicegenerates globally-unique numbers against a per-division prefix, retrying on unique-constraint collisions up to 5 times.- Tax rate resolution: use the value passed on
createInvoiceif provided, otherwisePaymentConfig.defaultTaxRatefor the division, otherwise0. - Status transitions:
DRAFT → SENT → {PARTIALLY_PAID, PAID, OVERDUE, VOID, REFUNDED}. - Invoice
metadatais a Stripe-styleRecord<string, string>for consumer-specific context.
Stripe Gateway & Webhooks
When stripe config is provided, the module registers StripeGateway and mounts POST /webhooks/stripe. Webhook security relies entirely on Stripe signature verification. Behaviour:
- Idempotency — every event ID is recorded via
IWebhookIdempotencyRepositorybefore the handler runs; replays no-op. - Retries —
payment_intent.succeededevents retry up to 3 times with a 2-second delay if the correspondingPaymentrow has not yet been created. - Error reporting — configure
webhook.errorReporterto pipe exceptions into Sentry/GlitchTip/etc.
GatewayRegistryService keeps a runtime lookup of registered gateways so the package can work without @Optional() @Inject().
Refunds
Full and partial refunds through Stripe (when the originating payment went through Stripe) or manual refund records. All amounts in cents.
Payment Plans
Split an invoice into installments with per-installment due dates.
Enable:
features: { paymentPlans: true },
paymentPlanRepositoryInstance: paymentPlanRepo,Each installment can optionally reference a generated invoice; the service tracks PENDING → INVOICED → PAID → OVERDUE → WAIVED → CANCELLED.
Recurring Invoices
Auto-generates invoices on a recurring schedule. Supports DAYS and MONTHS interval units anchored to an original date (handles month-end correctly via computeNextInvoiceDate).
Enable:
features: { recurringInvoices: true },
recurringInvoiceRepositoryInstance: recurringRepo,
paymentCustomerRepositoryInstance: paymentCustomerRepo,Phase 2 auto-charge: when autoCharge: true, the scheduler pulls the stored PaymentCustomer for the client, issues a charge via the gateway, and fires either onRecurringInvoiceGenerated or onRecurringPaymentFailed. Consecutive failures can auto-pause the schedule (onRecurringInvoicePaused).
Utility helpers exported:
import {
computeNextInvoiceDate,
computeCycleCount,
} from '@ambushsoftworks/nestjs-payments-graphql';The consumer provides the scheduler (typically @nestjs/schedule) that calls RecurringInvoiceService.generateDue() and friends on a cron.
E-Transfer
Canadian Interac e-transfer flow: ETransferService generates human-readable codes (<prefix>-<invoice>-<random>) and security answers, then exposes GraphQL instructions for the client.
Enable:
features: { eTransfer: true },
eTransferCodePrefix: 'ARD-', // whatever short prefix makes sense for your brandEmail Notifications
Composable: transport + rendering + branding + client lookup.
import {
PaymentsModule,
StaticBrandingResolver,
DefaultPaymentEmailTemplateRenderer,
} from '@ambushsoftworks/nestjs-payments-graphql';
PaymentsModule.forRootAsync({
useFactory: (..., emailSender, clientResolver) => ({
// ... required deps
features: { emailNotifications: true },
email: {
senderInstance: emailSender,
clientResolverInstance: clientResolver,
brandingResolverInstance: new StaticBrandingResolver({
appName: 'My App',
primaryColor: '#1976D2',
fromEmail: '[email protected]',
fromName: 'My App Billing',
companyName: 'My App Inc.',
supportEmail: '[email protected]',
}),
templateRendererInstance: new DefaultPaymentEmailTemplateRenderer(),
},
}),
});You implement:
IPaymentEmailSender— asend()method that posts to your transport (Resend, SendGrid, SES, etc.).IPaymentClientResolver— returns{ email, name } | nullfor aclientDetailsId. Returningnullskips that email silently.
You can override:
IPaymentEmailBrandingResolver— per-division branding. UseStaticBrandingResolverfor single-tenant apps.IPaymentEmailTemplateRenderer— full HTML control (e.g. React Email, MJML, Handlebars).DefaultPaymentEmailTemplateRendererships inline-styled responsive templates.
Covered emails: invoice sent, e-transfer instructions, payment confirmation, overdue reminder, refund confirmation.
Event Listeners
Register a single IPaymentEventListener to react to domain events without touching services. All handlers except onInvoicePaid are optional.
paymentEventListenerInstance: {
async onInvoicePaid(event) { /* fulfill the order, confirm the booking, ... */ },
async onInvoiceRefunded(event) { /* undo fulfillment */ },
async onRecurringInvoiceGenerated(event) { /* notify, audit */ },
async onRecurringPaymentFailed(event) { /* alert, retry */ },
async onRecurringInvoicePaused(event) { /* notify billing team */ },
}Event payloads include metadata from the invoice plus the full lineItems snapshot so consumers don't need to re-query.
Transactions
ITransactionManager.runInTransaction(fn) gives the callback a TransactionRepositories bag with all repositories bound to the same ORM transaction. The package uses this internally when recording payments and mutating totals.
await transactionManager.runInTransaction(async (repos) => {
const invoice = await repos.invoices.findByIdForUpdate(id);
await repos.payments.create({ ... });
await repos.invoices.update(id, { amountPaid: invoice.amountPaid + amount });
});Configuration Reference
Required Options
| Option | Type | Description |
|--------|------|-------------|
| invoiceRepositoryInstance | IInvoiceRepository | Invoice + line item persistence |
| paymentRepositoryInstance | IPaymentRepository | Payment records |
| refundRepositoryInstance | IRefundRepository | Refund records |
| paymentConfigRepositoryInstance | IPaymentConfigRepository | Per-division payment config |
| webhookIdempotencyRepositoryInstance | IWebhookIdempotencyRepository | Stripe webhook dedupe |
| transactionManagerInstance | ITransactionManager | Multi-repo transactions |
Optional Repositories
| Option | Required when |
|--------|---------------|
| paymentPlanRepositoryInstance | features.paymentPlans is enabled |
| recurringInvoiceRepositoryInstance | features.recurringInvoices is enabled |
| paymentCustomerRepositoryInstance | features.recurringInvoices is enabled |
Stripe Options
stripe: {
secretKey: string;
webhookSecret: string;
}Omit the entire stripe key for non-Stripe deployments. A warning logs at startup if no gateway is configured.
Email Options
email: {
senderInstance: IPaymentEmailSender; // required
clientResolverInstance: IPaymentClientResolver; // required
brandingResolverInstance?: IPaymentEmailBrandingResolver; // optional
templateRendererInstance?: IPaymentEmailTemplateRenderer; // optional
}Required when features.emailNotifications is enabled.
Defaults & Feature Flags
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| defaultCurrency | string | 'USD' | ISO 4217 currency used when an invoice omits one |
| eTransferCodePrefix | string | '' | Short prefix prepended to generated e-transfer codes |
| webhook.errorReporter | (err, ctx) => void | — | Forward webhook errors to Sentry/GlitchTip/etc. |
| Flag | Default | Description |
|------|---------|-------------|
| features.paymentPlans | false | Enable payment-plan services + GraphQL types |
| features.recurringInvoices | false | Enable recurring invoice services + auto-charge |
| features.eTransfer | false | Enable e-transfer code generation and instruction emails |
| features.emailNotifications | false | Enable outbound email; requires email config |
Validation runs when the module boots. Enabling a feature without providing the backing repository or config throws at startup.
DI Tokens
All DI tokens are exported from the package as Symbols. Use them with ModuleRef.get() (see Critical Integration Requirements).
import {
INVOICE_REPOSITORY,
PAYMENT_REPOSITORY,
REFUND_REPOSITORY,
PAYMENT_PLAN_REPOSITORY,
PAYMENT_CONFIG_REPOSITORY,
WEBHOOK_IDEMPOTENCY_REPOSITORY,
RECURRING_INVOICE_REPOSITORY,
PAYMENT_CUSTOMER_REPOSITORY,
TRANSACTION_MANAGER,
PAYMENT_EVENT_LISTENER,
PAYMENT_EMAIL_SENDER,
PAYMENT_CLIENT_RESOLVER,
PAYMENT_EMAIL_BRANDING_RESOLVER,
PAYMENT_EMAIL_TEMPLATE_RENDERER,
WEBHOOK_ERROR_REPORTER,
DEFAULT_CURRENCY,
E_TRANSFER_CODE_PREFIX,
} from '@ambushsoftworks/nestjs-payments-graphql';Reserved GraphQL Type Names
The package registers these names on module import. Consumers must not redefine or re-register them, or GraphQL will throw "type already registered".
Object types: Invoice, InvoiceLineItem, InvoiceClient, Payment, Refund, PaymentPlan, PaymentPlanInstallment, PaymentConfig, PaymentProvider, PaymentMethodSetup, ETransferInstructions, OnlinePaymentSession, RecurringInvoice, RecurringInvoiceLineItem, PaginatedInvoices, PaginatedPaymentPlans, PaginatedRecurringInvoices
Enums: InvoiceStatus, PaymentMethod, PaymentStatus, RefundStatus, PaymentPlanStatus, InstallmentStatus, RecurringIntervalUnit, RecurringInvoiceStatus
Input types: CreateInvoiceInput, CreateLineItemInput, CreateRefundInput, CreateInstallmentInput, CreatePaymentPlanInput, CreateRecurringInvoiceInput, CreateRecurringLineItemInput, RecordManualPaymentInput, UpdatePaymentConfigInput, UpdateInvoiceMetadataInput, UpdateRecurringTemplateInput, UpsertPaymentProviderInput, InvoiceFilterInput, PaymentPlanFilterInput, RecurringInvoiceFilterInput
Exceptions
All exceptions extend PaymentException (a plain Error subclass) with a stable .code string so consumers can map them to GraphQL/HTTP status codes.
| Exception | .code |
|-----------|---------|
| InvalidInvoiceStateException | INVALID_INVOICE_STATE |
| PaymentAmountExceededException | PAYMENT_AMOUNT_EXCEEDED |
| PaymentGatewayException | PAYMENT_GATEWAY_ERROR |
| DuplicatePaymentException | DUPLICATE_PAYMENT |
| InvoiceNotPayableException | INVOICE_NOT_PAYABLE |
| InvoiceNumberExhaustedException | INVOICE_NUMBER_EXHAUSTED |
| RefundNotAllowedException | REFUND_NOT_ALLOWED |
| InvalidPaymentStateException | INVALID_PAYMENT_STATE |
| InvalidRecurringInvoiceStateException | INVALID_RECURRING_INVOICE_STATE |
| UniqueConstraintViolationException | UNIQUE_CONSTRAINT_VIOLATION |
Repository adapters are responsible for translating ORM-specific unique-constraint errors (e.g. Prisma P2002) into UniqueConstraintViolationException so core services stay ORM-agnostic.
Critical Integration Requirements
1. Use ModuleRef.get() for package tokens
There is a known NestJS bug where @Optional() @Inject(TOKEN) combined with forwardRef() module imports delivers null even when the provider exists. The package itself works around this internally. Consumer code that needs a package token must do the same:
// BAD — will fail with "Nest can't resolve dependencies"
constructor(@Inject(WEBHOOK_IDEMPOTENCY_REPOSITORY) private readonly repo: IWebhookIdempotencyRepository) {}
// GOOD — resolve lazily in onModuleInit
constructor(private readonly moduleRef: ModuleRef) {}
onModuleInit() {
this.repo = this.moduleRef.get(WEBHOOK_IDEMPOTENCY_REPOSITORY, { strict: false });
}2. Do not re-register GraphQL enums
PaymentsModule imports register-payment-enums.ts as a side effect, which calls registerEnumType() for every enum listed under Reserved GraphQL Type Names. Remove any duplicate registerEnumType() calls from your app.
3. Exempt /webhooks/stripe from auth
The webhook controller mounts at a fixed path and is decorated with @PaymentsWebhook(). Your auth/tenant guards must either:
- Check for the
PAYMENTS_WEBHOOKmetadata key viaReflector, or - Skip the route by path (use the exported
PAYMENTS_WEBHOOKconstant if you match on string keys).
@PaymentsWebhook() also sets isPublic and skipTenant metadata, so guards that honour those keys work out of the box.
4. Enable rawBody in NestFactory.create()
Stripe signature verification requires the unparsed request body:
const app = await NestFactory.create(AppModule, { rawBody: true });5. Translate ORM errors to UniqueConstraintViolationException
Repository adapters must catch ORM-specific unique constraint errors and rethrow as UniqueConstraintViolationException so core services (especially InvoiceNumberService) can apply their retry logic.
6. Use "moduleResolution": "nodenext" friendly imports
The package.json has an explicit exports field, so nodenext resolution is supported. If you use node16/nodenext, you do not need to opt into any special import syntax.
Local Development
Developing the package alongside a consumer
Terminal 1 — package watch mode:
cd nestjs-payments-graphql
pnpm link --global
pnpm run build:watchTerminal 2 — consumer:
cd my-app
pnpm link --global @ambushsoftworks/nestjs-payments-graphql
pnpm start:devBefore committing or deploying the consumer:
cd my-app
pnpm uninstall @ambushsoftworks/nestjs-payments-graphql
pnpm installDo not commit package.json or pnpm-lock.yaml with a link: or workspace: reference to this package. BuildKit-based deploys (e.g. Railway) cannot resolve those references.
Publishing
npm version patch # or minor/major
git push --follow-tagsGitLab CI publishes to npm via OIDC provenance on tag push. See .gitlab-ci.yml for the pipeline.
License
MIT — see LICENSE.
