@ambushsoftworks/nestjs-payments-graphql 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,90 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.2] - 2026-04-19
11
+
12
+ ### Fixed
13
+ - **CI publish pipeline** — switched the publish job to `node:24`, which ships with npm 11.x and supports OIDC trusted publishing natively. The previous config ran `npm install -g npm@latest` on top of node:22's bundled npm 10.9.x, which failed with `MODULE_NOT_FOUND: promise-retry` during npm's self-upgrade. 0.1.2 is the first version since 0.1.0 to actually land on the npm registry.
14
+
15
+ 0.1.2 contains all the 0.1.1 content — README, CHANGELOG, CONTRIBUTING, LICENSE, `prisma/payment-models.prisma`, Jest config and tests, and the `package.json` metadata additions.
16
+
17
+ ## [0.1.1] - 2026-04-19
18
+
19
+ > **Note:** Tagged in git but never published to npm — the CI publish stage failed on an upstream `npm install -g npm@latest` self-upgrade bug. Superseded by 0.1.2 with an identical feature set.
20
+
21
+ Docs and tooling release. No runtime code changed — compiled `dist/` output is identical to 0.1.0.
22
+
23
+ ### Added
24
+ - `README.md` — full public-facing documentation (design principles, install, quick-start, features, configuration reference, reserved GraphQL type names, exception table, critical integration requirements, local development workflow).
25
+ - `CHANGELOG.md`, `CONTRIBUTING.md`, `LICENSE` (MIT).
26
+ - `prisma/payment-models.prisma` — documentation-only reference schema shipped inside the package tarball. Covers every model and enum (core, payment plans, recurring invoices, payment customers, webhook idempotency). Consumer-owned relations marked `// CONSUMER:` for easy copy-paste into a host `schema.prisma`.
27
+ - Jest configuration (inline in `package.json`) and the first test suites: `recurring-interval.util.spec.ts` (19 cases covering DAYS/MONTHS math, month-end clamp, leap years, year rollover, round-tripping with `computeCycleCount`) and `invoice-number.service.spec.ts` (7 cases covering first-number, increment, zero-padding past 10k, unparseable-max fallback, and global numbering across divisions). `.gitlab-ci.yml`'s `test` stage now exercises real code instead of silently passing.
28
+ - `package.json` metadata: `keywords`, `homepage`, `bugs` URL. `CHANGELOG.md` added to `files[]`.
29
+
30
+ ### Changed
31
+ - `.gitignore` now excludes `*.tsbuildinfo` (TypeScript incremental build cache was previously tracked by accident) and `docs/archive/` (local-only planning docs).
32
+
33
+ ## [0.1.0] - 2026-04-19
34
+
35
+ ### Added
36
+
37
+ Initial public release. Extracted from the Ariadne monorepo (`apps/api/src/payments/core/`) into a standalone, ORM-agnostic NestJS package.
38
+
39
+ #### Core features
40
+
41
+ - **Invoicing** — `InvoiceService` creates DRAFT invoices with optional line items, tax rate resolution, metadata, and globally unique invoice numbers (via `InvoiceNumberService` with retry-on-collision).
42
+ - **Payments** — `PaymentService` records manual payments, online/Stripe payments, and e-transfer payments. Uses row-level locking and sum-of-pending-confirmation checks to prevent over-payment.
43
+ - **Refunds** — `RefundService` supports full and partial refunds, Stripe-backed and manual, with automatic invoice status transitions.
44
+ - **Payment plans** — `PaymentPlanService` splits invoices into installments with per-installment due dates and status tracking.
45
+ - **Recurring invoices** — `RecurringInvoiceService` auto-generates invoices on a schedule with anchor-date interval math (`DAYS`/`MONTHS`), optional auto-charge via stored `PaymentCustomer`, and consecutive-failure auto-pause.
46
+ - **E-transfer** — `ETransferService` generates prefixed codes + security answers and GraphQL instructions for Canadian Interac e-transfer flows.
47
+ - **Composable email notifications** — `IPaymentEmailSender` + `IPaymentEmailTemplateRenderer` + `IPaymentEmailBrandingResolver` + `IPaymentClientResolver`. Ships `StaticBrandingResolver` and `DefaultPaymentEmailTemplateRenderer`.
48
+ - **Event listeners** — consumer-registered `IPaymentEventListener` with hooks for `onInvoicePaid`, `onInvoiceRefunded`, `onRecurringInvoiceGenerated`, `onRecurringPaymentFailed`, `onRecurringInvoicePaused`. Event payloads include invoice metadata and full line items.
49
+
50
+ #### Stripe integration
51
+
52
+ - Bundled `StripeGateway` plus `StripeWebhookController` mounted at `POST /webhooks/stripe`.
53
+ - `@PaymentsWebhook()` decorator (also sets `isPublic` + `skipTenant` metadata) and `PAYMENTS_WEBHOOK` string constant for consumer auth-guard exemption.
54
+ - Webhook idempotency via `IWebhookIdempotencyRepository`.
55
+ - Signature verification requires `rawBody: true` in `NestFactory.create()`.
56
+ - Payment-succeeded retry loop (3 attempts, 2s delay) for out-of-order webhook deliveries.
57
+ - Optional `webhook.errorReporter` hook for Sentry/GlitchTip/etc.
58
+ - `stripe@^17.0.0` bundled as a hard dependency.
59
+
60
+ #### Module & DI
61
+
62
+ - `PaymentsModule.forRootAsync()` dynamic module registered as `global: true`.
63
+ - Instance-based DI for all repositories and adapters.
64
+ - Startup validation throws when feature flags are enabled without their backing repositories or config.
65
+ - All tokens are exported as `Symbol` constants (`INVOICE_REPOSITORY`, `PAYMENT_REPOSITORY`, `TRANSACTION_MANAGER`, …).
66
+ - Internal services use `ModuleRef.get()` instead of `@Optional() @Inject()` to work around a NestJS DI bug affecting optional tokens with `forwardRef()` imports.
67
+
68
+ #### GraphQL types
69
+
70
+ - 17 `@ObjectType` classes, 8 `@registerEnumType` enums, and 15+ `@InputType` classes exported. Enums are registered as a side effect of importing `PaymentsModule` — consumers must not re-register them.
71
+
72
+ #### Exceptions
73
+
74
+ - Domain exceptions (`InvalidInvoiceStateException`, `PaymentAmountExceededException`, `PaymentGatewayException`, `DuplicatePaymentException`, `InvoiceNotPayableException`, `InvoiceNumberExhaustedException`, `RefundNotAllowedException`, `InvalidPaymentStateException`, `InvalidRecurringInvoiceStateException`, `UniqueConstraintViolationException`) all extend `PaymentException` with stable `.code` strings.
75
+
76
+ #### Utilities
77
+
78
+ - `computeNextInvoiceDate` / `computeCycleCount` — anchor-based recurring-interval math.
79
+ - `StaticBrandingResolver` — single-tenant branding resolver.
80
+ - `DefaultPaymentEmailTemplateRenderer` — responsive inline-styled email HTML.
81
+
82
+ ### CI/CD
83
+
84
+ - GitLab pipeline at `.gitlab-ci.yml`.
85
+ - `test` stage runs on merge requests and tags.
86
+ - `publish` stage runs on git tags, publishing to npm via OIDC provenance (`npm publish --provenance --access public`). No static npm token required.
87
+
88
+ ### Infrastructure
89
+
90
+ - Currently running in production on the Ariadne API (Railway deployment, consumes `@ambushsoftworks/nestjs-payments-graphql@^0.1.0` from npm).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ambush Softworks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,609 @@
1
+ # @ambushsoftworks/nestjs-payments-graphql
2
+
3
+ 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.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Design Principles](#design-principles)
8
+ - [Installation](#installation)
9
+ - [Quick Start](#quick-start)
10
+ - [Architecture](#architecture)
11
+ - [Features](#features)
12
+ - [Invoicing](#invoicing)
13
+ - [Stripe Gateway & Webhooks](#stripe-gateway--webhooks)
14
+ - [Refunds](#refunds)
15
+ - [Payment Plans](#payment-plans)
16
+ - [Recurring Invoices](#recurring-invoices)
17
+ - [E-Transfer](#e-transfer)
18
+ - [Email Notifications](#email-notifications)
19
+ - [Event Listeners](#event-listeners)
20
+ - [Transactions](#transactions)
21
+ - [Configuration Reference](#configuration-reference)
22
+ - [Required Options](#required-options)
23
+ - [Optional Repositories](#optional-repositories)
24
+ - [Stripe Options](#stripe-options)
25
+ - [Email Options](#email-options)
26
+ - [Defaults & Feature Flags](#defaults--feature-flags)
27
+ - [DI Tokens](#di-tokens)
28
+ - [Reserved GraphQL Type Names](#reserved-graphql-type-names)
29
+ - [Exceptions](#exceptions)
30
+ - [Critical Integration Requirements](#critical-integration-requirements)
31
+ - [Local Development](#local-development)
32
+ - [License](#license)
33
+
34
+ ---
35
+
36
+ ## Design Principles
37
+
38
+ - **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.
39
+ - **Instance-based DI** — Repositories and adapters are injected as instances via `useFactory`, not as classes.
40
+ - **Framework-agnostic core** — Services are pure business logic. Resolvers, schedulers, and Prisma adapters live in your application.
41
+ - **Consumer owns GraphQL decoration** — The package ships `@ObjectType`/`@InputType` classes. You build resolvers that call services and return these types.
42
+ - **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.
43
+ - **Stripe is bundled, not required** — The Stripe gateway and webhook controller ship with the package, but are only activated when `stripe` config is provided.
44
+
45
+ ---
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ npm install @ambushsoftworks/nestjs-payments-graphql
51
+ # or
52
+ pnpm add @ambushsoftworks/nestjs-payments-graphql
53
+ ```
54
+
55
+ ### Peer Dependencies
56
+
57
+ ```bash
58
+ npm install @nestjs/common @nestjs/core @nestjs/graphql \
59
+ class-transformer class-validator graphql graphql-type-json \
60
+ reflect-metadata rxjs
61
+ ```
62
+
63
+ `stripe` is bundled as a hard dependency of this package — you do not need to install it separately.
64
+
65
+ ---
66
+
67
+ ## Quick Start
68
+
69
+ A minimal setup: invoices, manual payments, and a Stripe gateway.
70
+
71
+ ### 1. Implement the required repositories
72
+
73
+ 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`](./prisma/payment-models.prisma) and ships inside the npm tarball — it contains every model and enum you need, with consumer-owned relations marked `// CONSUMER:`.
74
+
75
+ ```typescript
76
+ // prisma-invoice.repository.ts
77
+ import { Injectable } from '@nestjs/common';
78
+ import { PrismaService } from './prisma.service';
79
+ import {
80
+ IInvoiceRepository,
81
+ UniqueConstraintViolationException,
82
+ } from '@ambushsoftworks/nestjs-payments-graphql';
83
+
84
+ @Injectable()
85
+ export class PrismaInvoiceRepository implements IInvoiceRepository {
86
+ constructor(private readonly prisma: PrismaService) {}
87
+
88
+ async create(data) {
89
+ try {
90
+ return await this.prisma.invoice.create({ data });
91
+ } catch (e: any) {
92
+ if (e.code === 'P2002') throw new UniqueConstraintViolationException();
93
+ throw e;
94
+ }
95
+ }
96
+
97
+ async findById(id) { return this.prisma.invoice.findUnique({ where: { id } }); }
98
+ // ... implement the rest of IInvoiceRepository
99
+ async withTransaction(fn) {
100
+ return this.prisma.$transaction(async (tx) => {
101
+ const scoped = new PrismaInvoiceRepository({ ...this.prisma, invoice: tx.invoice } as any);
102
+ return fn(scoped);
103
+ });
104
+ }
105
+ }
106
+ ```
107
+
108
+ Repositories you must implement:
109
+
110
+ | Interface | Purpose |
111
+ |-----------|---------|
112
+ | `IInvoiceRepository` | Invoice + line item persistence |
113
+ | `IPaymentRepository` | Payment records |
114
+ | `IRefundRepository` | Refund records |
115
+ | `IPaymentConfigRepository` | Per-division payment config + providers |
116
+ | `IWebhookIdempotencyRepository` | Dedupe Stripe webhook deliveries |
117
+ | `ITransactionManager` | Multi-repo transaction coordinator |
118
+
119
+ Opt-in interfaces:
120
+
121
+ | Interface | Required when |
122
+ |-----------|---------------|
123
+ | `IPaymentPlanRepository` | `features.paymentPlans = true` |
124
+ | `IRecurringInvoiceRepository` | `features.recurringInvoices = true` |
125
+ | `IPaymentCustomerRepository` | `features.recurringInvoices = true` |
126
+
127
+ ### 2. Register the module
128
+
129
+ ```typescript
130
+ import { Module } from '@nestjs/common';
131
+ import { ConfigModule, ConfigService } from '@nestjs/config';
132
+ import { PaymentsModule } from '@ambushsoftworks/nestjs-payments-graphql';
133
+
134
+ @Module({
135
+ imports: [
136
+ ConfigModule.forRoot(),
137
+ PaymentsModule.forRootAsync({
138
+ imports: [ConfigModule, MyRepositoriesModule, MyAdaptersModule],
139
+ inject: [
140
+ PrismaInvoiceRepository,
141
+ PrismaPaymentRepository,
142
+ PrismaRefundRepository,
143
+ PrismaPaymentConfigRepository,
144
+ PrismaWebhookIdempotencyRepository,
145
+ PrismaTransactionManager,
146
+ ConfigService,
147
+ ],
148
+ useFactory: (invoices, payments, refunds, config, webhooks, tx, cfg) => ({
149
+ invoiceRepositoryInstance: invoices,
150
+ paymentRepositoryInstance: payments,
151
+ refundRepositoryInstance: refunds,
152
+ paymentConfigRepositoryInstance: config,
153
+ webhookIdempotencyRepositoryInstance: webhooks,
154
+ transactionManagerInstance: tx,
155
+
156
+ stripe: {
157
+ secretKey: cfg.get('STRIPE_SECRET_KEY'),
158
+ webhookSecret: cfg.get('STRIPE_WEBHOOK_SECRET'),
159
+ },
160
+
161
+ defaultCurrency: 'USD',
162
+ }),
163
+ }),
164
+ ],
165
+ })
166
+ export class AppModule {}
167
+ ```
168
+
169
+ ### 3. Enable `rawBody` for Stripe signature verification
170
+
171
+ Stripe's webhook signature check requires the unparsed request body.
172
+
173
+ ```typescript
174
+ // main.ts
175
+ const app = await NestFactory.create(AppModule, { rawBody: true });
176
+ ```
177
+
178
+ ### 4. Exempt the webhook path from auth
179
+
180
+ The controller is registered at `POST /webhooks/stripe` and marked with `@PaymentsWebhook()`. Your auth/tenant guards must respect this metadata. Either:
181
+
182
+ - Have guards check `Reflector.get(PAYMENTS_WEBHOOK, handler)` and bail out early, **or**
183
+ - Have guards check the route path against the exported `PAYMENTS_WEBHOOK` string constant.
184
+
185
+ See [Critical Integration Requirements](#critical-integration-requirements).
186
+
187
+ ### 5. Build a resolver
188
+
189
+ ```typescript
190
+ import { Resolver, Mutation, Args } from '@nestjs/graphql';
191
+ import {
192
+ InvoiceService,
193
+ InvoiceModel,
194
+ CreateInvoiceInput,
195
+ } from '@ambushsoftworks/nestjs-payments-graphql';
196
+
197
+ @Resolver()
198
+ export class InvoiceResolver {
199
+ constructor(private readonly invoices: InvoiceService) {}
200
+
201
+ @Mutation(() => InvoiceModel)
202
+ async createInvoice(
203
+ @Args('input') input: CreateInvoiceInput,
204
+ // resolve divisionId + userId from your auth/tenant context
205
+ ) {
206
+ return this.invoices.createInvoice(divisionId, input, userId);
207
+ }
208
+ }
209
+ ```
210
+
211
+ ---
212
+
213
+ ## Architecture
214
+
215
+ ```
216
+ ┌──────────────────────────────────────────────────────────────┐
217
+ │ Consumer App (e.g. Ariadne API) │
218
+ │ ────────────────────────────── │
219
+ │ • Prisma repositories (implements IInvoiceRepository, ...) │
220
+ │ • Resolvers (GraphQL queries/mutations) │
221
+ │ • Schedulers (@nestjs/schedule) │
222
+ │ • Auth guards (respect @PaymentsWebhook metadata) │
223
+ │ • Event listener (implements IPaymentEventListener) │
224
+ │ • Email adapters (IPaymentEmailSender, IPaymentClient...) │
225
+ │ │
226
+ │ Wires together via PaymentsModule.forRootAsync({ ... }) │
227
+ └──────────────────────────────────────────────────────────────┘
228
+ ↓ imports
229
+ ┌──────────────────────────────────────────────────────────────┐
230
+ │ @ambushsoftworks/nestjs-payments-graphql │
231
+ │ ───────────────────────────────────────── │
232
+ │ • Services (pure business logic) │
233
+ │ • Interfaces (what consumer must implement) │
234
+ │ • DI tokens (Symbols) │
235
+ │ • GraphQL models + input types │
236
+ │ • StripeGateway + StripeWebhookController (opt-in) │
237
+ │ • Exceptions │
238
+ └──────────────────────────────────────────────────────────────┘
239
+ ```
240
+
241
+ The dynamic module is registered as `global: true` — any module in your app can inject services and tokens without re-importing.
242
+
243
+ ---
244
+
245
+ ## Features
246
+
247
+ ### Invoicing
248
+
249
+ - Create DRAFT invoices with optional line items.
250
+ - Line item edits allowed only in DRAFT; totals auto-recalculate on mutation.
251
+ - `InvoiceNumberService` generates globally-unique numbers against a per-division prefix, retrying on unique-constraint collisions up to 5 times.
252
+ - Tax rate resolution: use the value passed on `createInvoice` if provided, otherwise `PaymentConfig.defaultTaxRate` for the division, otherwise `0`.
253
+ - Status transitions: `DRAFT → SENT → {PARTIALLY_PAID, PAID, OVERDUE, VOID, REFUNDED}`.
254
+ - Invoice `metadata` is a Stripe-style `Record<string, string>` for consumer-specific context.
255
+
256
+ ### Stripe Gateway & Webhooks
257
+
258
+ When `stripe` config is provided, the module registers `StripeGateway` and mounts `POST /webhooks/stripe`. Webhook security relies entirely on Stripe signature verification. Behaviour:
259
+
260
+ - **Idempotency** — every event ID is recorded via `IWebhookIdempotencyRepository` before the handler runs; replays no-op.
261
+ - **Retries** — `payment_intent.succeeded` events retry up to 3 times with a 2-second delay if the corresponding `Payment` row has not yet been created.
262
+ - **Error reporting** — configure `webhook.errorReporter` to pipe exceptions into Sentry/GlitchTip/etc.
263
+
264
+ `GatewayRegistryService` keeps a runtime lookup of registered gateways so the package can work without `@Optional() @Inject()`.
265
+
266
+ ### Refunds
267
+
268
+ Full and partial refunds through Stripe (when the originating payment went through Stripe) or manual refund records. All amounts in cents.
269
+
270
+ ### Payment Plans
271
+
272
+ Split an invoice into installments with per-installment due dates.
273
+
274
+ Enable:
275
+
276
+ ```typescript
277
+ features: { paymentPlans: true },
278
+ paymentPlanRepositoryInstance: paymentPlanRepo,
279
+ ```
280
+
281
+ Each installment can optionally reference a generated invoice; the service tracks `PENDING → INVOICED → PAID → OVERDUE → WAIVED → CANCELLED`.
282
+
283
+ ### Recurring Invoices
284
+
285
+ Auto-generates invoices on a recurring schedule. Supports `DAYS` and `MONTHS` interval units anchored to an original date (handles month-end correctly via `computeNextInvoiceDate`).
286
+
287
+ Enable:
288
+
289
+ ```typescript
290
+ features: { recurringInvoices: true },
291
+ recurringInvoiceRepositoryInstance: recurringRepo,
292
+ paymentCustomerRepositoryInstance: paymentCustomerRepo,
293
+ ```
294
+
295
+ 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`).
296
+
297
+ Utility helpers exported:
298
+
299
+ ```typescript
300
+ import {
301
+ computeNextInvoiceDate,
302
+ computeCycleCount,
303
+ } from '@ambushsoftworks/nestjs-payments-graphql';
304
+ ```
305
+
306
+ The consumer provides the scheduler (typically `@nestjs/schedule`) that calls `RecurringInvoiceService.generateDue()` and friends on a cron.
307
+
308
+ ### E-Transfer
309
+
310
+ Canadian Interac e-transfer flow: `ETransferService` generates human-readable codes (`<prefix>-<invoice>-<random>`) and security answers, then exposes GraphQL instructions for the client.
311
+
312
+ Enable:
313
+
314
+ ```typescript
315
+ features: { eTransfer: true },
316
+ eTransferCodePrefix: 'ARD-', // whatever short prefix makes sense for your brand
317
+ ```
318
+
319
+ ### Email Notifications
320
+
321
+ Composable: transport + rendering + branding + client lookup.
322
+
323
+ ```typescript
324
+ import {
325
+ PaymentsModule,
326
+ StaticBrandingResolver,
327
+ DefaultPaymentEmailTemplateRenderer,
328
+ } from '@ambushsoftworks/nestjs-payments-graphql';
329
+
330
+ PaymentsModule.forRootAsync({
331
+ useFactory: (..., emailSender, clientResolver) => ({
332
+ // ... required deps
333
+ features: { emailNotifications: true },
334
+ email: {
335
+ senderInstance: emailSender,
336
+ clientResolverInstance: clientResolver,
337
+ brandingResolverInstance: new StaticBrandingResolver({
338
+ appName: 'My App',
339
+ primaryColor: '#1976D2',
340
+ fromEmail: 'billing@example.com',
341
+ fromName: 'My App Billing',
342
+ companyName: 'My App Inc.',
343
+ supportEmail: 'support@example.com',
344
+ }),
345
+ templateRendererInstance: new DefaultPaymentEmailTemplateRenderer(),
346
+ },
347
+ }),
348
+ });
349
+ ```
350
+
351
+ You implement:
352
+
353
+ - **`IPaymentEmailSender`** — a `send()` method that posts to your transport (Resend, SendGrid, SES, etc.).
354
+ - **`IPaymentClientResolver`** — returns `{ email, name } | null` for a `clientDetailsId`. Returning `null` skips that email silently.
355
+
356
+ You can override:
357
+
358
+ - **`IPaymentEmailBrandingResolver`** — per-division branding. Use `StaticBrandingResolver` for single-tenant apps.
359
+ - **`IPaymentEmailTemplateRenderer`** — full HTML control (e.g. React Email, MJML, Handlebars). `DefaultPaymentEmailTemplateRenderer` ships inline-styled responsive templates.
360
+
361
+ Covered emails: invoice sent, e-transfer instructions, payment confirmation, overdue reminder, refund confirmation.
362
+
363
+ ### Event Listeners
364
+
365
+ Register a single `IPaymentEventListener` to react to domain events without touching services. All handlers except `onInvoicePaid` are optional.
366
+
367
+ ```typescript
368
+ paymentEventListenerInstance: {
369
+ async onInvoicePaid(event) { /* fulfill the order, confirm the booking, ... */ },
370
+ async onInvoiceRefunded(event) { /* undo fulfillment */ },
371
+ async onRecurringInvoiceGenerated(event) { /* notify, audit */ },
372
+ async onRecurringPaymentFailed(event) { /* alert, retry */ },
373
+ async onRecurringInvoicePaused(event) { /* notify billing team */ },
374
+ }
375
+ ```
376
+
377
+ Event payloads include `metadata` from the invoice plus the full `lineItems` snapshot so consumers don't need to re-query.
378
+
379
+ ### Transactions
380
+
381
+ `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.
382
+
383
+ ```typescript
384
+ await transactionManager.runInTransaction(async (repos) => {
385
+ const invoice = await repos.invoices.findByIdForUpdate(id);
386
+ await repos.payments.create({ ... });
387
+ await repos.invoices.update(id, { amountPaid: invoice.amountPaid + amount });
388
+ });
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Configuration Reference
394
+
395
+ ### Required Options
396
+
397
+ | Option | Type | Description |
398
+ |--------|------|-------------|
399
+ | `invoiceRepositoryInstance` | `IInvoiceRepository` | Invoice + line item persistence |
400
+ | `paymentRepositoryInstance` | `IPaymentRepository` | Payment records |
401
+ | `refundRepositoryInstance` | `IRefundRepository` | Refund records |
402
+ | `paymentConfigRepositoryInstance` | `IPaymentConfigRepository` | Per-division payment config |
403
+ | `webhookIdempotencyRepositoryInstance` | `IWebhookIdempotencyRepository` | Stripe webhook dedupe |
404
+ | `transactionManagerInstance` | `ITransactionManager` | Multi-repo transactions |
405
+
406
+ ### Optional Repositories
407
+
408
+ | Option | Required when |
409
+ |--------|---------------|
410
+ | `paymentPlanRepositoryInstance` | `features.paymentPlans` is enabled |
411
+ | `recurringInvoiceRepositoryInstance` | `features.recurringInvoices` is enabled |
412
+ | `paymentCustomerRepositoryInstance` | `features.recurringInvoices` is enabled |
413
+
414
+ ### Stripe Options
415
+
416
+ ```typescript
417
+ stripe: {
418
+ secretKey: string;
419
+ webhookSecret: string;
420
+ }
421
+ ```
422
+
423
+ Omit the entire `stripe` key for non-Stripe deployments. A warning logs at startup if no gateway is configured.
424
+
425
+ ### Email Options
426
+
427
+ ```typescript
428
+ email: {
429
+ senderInstance: IPaymentEmailSender; // required
430
+ clientResolverInstance: IPaymentClientResolver; // required
431
+ brandingResolverInstance?: IPaymentEmailBrandingResolver; // optional
432
+ templateRendererInstance?: IPaymentEmailTemplateRenderer; // optional
433
+ }
434
+ ```
435
+
436
+ Required when `features.emailNotifications` is enabled.
437
+
438
+ ### Defaults & Feature Flags
439
+
440
+ | Option | Type | Default | Description |
441
+ |--------|------|---------|-------------|
442
+ | `defaultCurrency` | `string` | `'USD'` | ISO 4217 currency used when an invoice omits one |
443
+ | `eTransferCodePrefix` | `string` | `''` | Short prefix prepended to generated e-transfer codes |
444
+ | `webhook.errorReporter` | `(err, ctx) => void` | — | Forward webhook errors to Sentry/GlitchTip/etc. |
445
+
446
+ | Flag | Default | Description |
447
+ |------|---------|-------------|
448
+ | `features.paymentPlans` | `false` | Enable payment-plan services + GraphQL types |
449
+ | `features.recurringInvoices` | `false` | Enable recurring invoice services + auto-charge |
450
+ | `features.eTransfer` | `false` | Enable e-transfer code generation and instruction emails |
451
+ | `features.emailNotifications` | `false` | Enable outbound email; requires `email` config |
452
+
453
+ Validation runs when the module boots. Enabling a feature without providing the backing repository or config throws at startup.
454
+
455
+ ---
456
+
457
+ ## DI Tokens
458
+
459
+ All DI tokens are exported from the package as `Symbol`s. Use them with `ModuleRef.get()` (see [Critical Integration Requirements](#critical-integration-requirements)).
460
+
461
+ ```typescript
462
+ import {
463
+ INVOICE_REPOSITORY,
464
+ PAYMENT_REPOSITORY,
465
+ REFUND_REPOSITORY,
466
+ PAYMENT_PLAN_REPOSITORY,
467
+ PAYMENT_CONFIG_REPOSITORY,
468
+ WEBHOOK_IDEMPOTENCY_REPOSITORY,
469
+ RECURRING_INVOICE_REPOSITORY,
470
+ PAYMENT_CUSTOMER_REPOSITORY,
471
+ TRANSACTION_MANAGER,
472
+ PAYMENT_EVENT_LISTENER,
473
+ PAYMENT_EMAIL_SENDER,
474
+ PAYMENT_CLIENT_RESOLVER,
475
+ PAYMENT_EMAIL_BRANDING_RESOLVER,
476
+ PAYMENT_EMAIL_TEMPLATE_RENDERER,
477
+ WEBHOOK_ERROR_REPORTER,
478
+ DEFAULT_CURRENCY,
479
+ E_TRANSFER_CODE_PREFIX,
480
+ } from '@ambushsoftworks/nestjs-payments-graphql';
481
+ ```
482
+
483
+ ---
484
+
485
+ ## Reserved GraphQL Type Names
486
+
487
+ The package registers these names on module import. Consumers **must not** redefine or re-register them, or GraphQL will throw `"type already registered"`.
488
+
489
+ **Object types:** `Invoice`, `InvoiceLineItem`, `InvoiceClient`, `Payment`, `Refund`, `PaymentPlan`, `PaymentPlanInstallment`, `PaymentConfig`, `PaymentProvider`, `PaymentMethodSetup`, `ETransferInstructions`, `OnlinePaymentSession`, `RecurringInvoice`, `RecurringInvoiceLineItem`, `PaginatedInvoices`, `PaginatedPaymentPlans`, `PaginatedRecurringInvoices`
490
+
491
+ **Enums:** `InvoiceStatus`, `PaymentMethod`, `PaymentStatus`, `RefundStatus`, `PaymentPlanStatus`, `InstallmentStatus`, `RecurringIntervalUnit`, `RecurringInvoiceStatus`
492
+
493
+ **Input types:** `CreateInvoiceInput`, `CreateLineItemInput`, `CreateRefundInput`, `CreateInstallmentInput`, `CreatePaymentPlanInput`, `CreateRecurringInvoiceInput`, `CreateRecurringLineItemInput`, `RecordManualPaymentInput`, `UpdatePaymentConfigInput`, `UpdateInvoiceMetadataInput`, `UpdateRecurringTemplateInput`, `UpsertPaymentProviderInput`, `InvoiceFilterInput`, `PaymentPlanFilterInput`, `RecurringInvoiceFilterInput`
494
+
495
+ ---
496
+
497
+ ## Exceptions
498
+
499
+ All exceptions extend `PaymentException` (a plain `Error` subclass) with a stable `.code` string so consumers can map them to GraphQL/HTTP status codes.
500
+
501
+ | Exception | `.code` |
502
+ |-----------|---------|
503
+ | `InvalidInvoiceStateException` | `INVALID_INVOICE_STATE` |
504
+ | `PaymentAmountExceededException` | `PAYMENT_AMOUNT_EXCEEDED` |
505
+ | `PaymentGatewayException` | `PAYMENT_GATEWAY_ERROR` |
506
+ | `DuplicatePaymentException` | `DUPLICATE_PAYMENT` |
507
+ | `InvoiceNotPayableException` | `INVOICE_NOT_PAYABLE` |
508
+ | `InvoiceNumberExhaustedException` | `INVOICE_NUMBER_EXHAUSTED` |
509
+ | `RefundNotAllowedException` | `REFUND_NOT_ALLOWED` |
510
+ | `InvalidPaymentStateException` | `INVALID_PAYMENT_STATE` |
511
+ | `InvalidRecurringInvoiceStateException` | `INVALID_RECURRING_INVOICE_STATE` |
512
+ | `UniqueConstraintViolationException` | `UNIQUE_CONSTRAINT_VIOLATION` |
513
+
514
+ Repository adapters are responsible for translating ORM-specific unique-constraint errors (e.g. Prisma `P2002`) into `UniqueConstraintViolationException` so core services stay ORM-agnostic.
515
+
516
+ ---
517
+
518
+ ## Critical Integration Requirements
519
+
520
+ ### 1. Use `ModuleRef.get()` for package tokens
521
+
522
+ 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:**
523
+
524
+ ```typescript
525
+ // BAD — will fail with "Nest can't resolve dependencies"
526
+ constructor(@Inject(WEBHOOK_IDEMPOTENCY_REPOSITORY) private readonly repo: IWebhookIdempotencyRepository) {}
527
+
528
+ // GOOD — resolve lazily in onModuleInit
529
+ constructor(private readonly moduleRef: ModuleRef) {}
530
+ onModuleInit() {
531
+ this.repo = this.moduleRef.get(WEBHOOK_IDEMPOTENCY_REPOSITORY, { strict: false });
532
+ }
533
+ ```
534
+
535
+ ### 2. Do not re-register GraphQL enums
536
+
537
+ `PaymentsModule` imports `register-payment-enums.ts` as a side effect, which calls `registerEnumType()` for every enum listed under [Reserved GraphQL Type Names](#reserved-graphql-type-names). Remove any duplicate `registerEnumType()` calls from your app.
538
+
539
+ ### 3. Exempt `/webhooks/stripe` from auth
540
+
541
+ The webhook controller mounts at a fixed path and is decorated with `@PaymentsWebhook()`. Your auth/tenant guards must either:
542
+
543
+ - Check for the `PAYMENTS_WEBHOOK` metadata key via `Reflector`, **or**
544
+ - Skip the route by path (use the exported `PAYMENTS_WEBHOOK` constant if you match on string keys).
545
+
546
+ `@PaymentsWebhook()` also sets `isPublic` and `skipTenant` metadata, so guards that honour those keys work out of the box.
547
+
548
+ ### 4. Enable `rawBody` in `NestFactory.create()`
549
+
550
+ Stripe signature verification requires the unparsed request body:
551
+
552
+ ```typescript
553
+ const app = await NestFactory.create(AppModule, { rawBody: true });
554
+ ```
555
+
556
+ ### 5. Translate ORM errors to `UniqueConstraintViolationException`
557
+
558
+ Repository adapters must catch ORM-specific unique constraint errors and rethrow as `UniqueConstraintViolationException` so core services (especially `InvoiceNumberService`) can apply their retry logic.
559
+
560
+ ### 6. Use `"moduleResolution": "nodenext"` friendly imports
561
+
562
+ 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.
563
+
564
+ ---
565
+
566
+ ## Local Development
567
+
568
+ ### Developing the package alongside a consumer
569
+
570
+ Terminal 1 — package watch mode:
571
+
572
+ ```bash
573
+ cd nestjs-payments-graphql
574
+ pnpm link --global
575
+ pnpm run build:watch
576
+ ```
577
+
578
+ Terminal 2 — consumer:
579
+
580
+ ```bash
581
+ cd my-app
582
+ pnpm link --global @ambushsoftworks/nestjs-payments-graphql
583
+ pnpm start:dev
584
+ ```
585
+
586
+ Before committing or deploying the consumer:
587
+
588
+ ```bash
589
+ cd my-app
590
+ pnpm uninstall @ambushsoftworks/nestjs-payments-graphql
591
+ pnpm install
592
+ ```
593
+
594
+ **Do 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.
595
+
596
+ ### Publishing
597
+
598
+ ```bash
599
+ npm version patch # or minor/major
600
+ git push --follow-tags
601
+ ```
602
+
603
+ GitLab CI publishes to npm via OIDC provenance on tag push. See `.gitlab-ci.yml` for the pipeline.
604
+
605
+ ---
606
+
607
+ ## License
608
+
609
+ MIT — see [LICENSE](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ambushsoftworks/nestjs-payments-graphql",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "NestJS payments module with GraphQL support — invoicing, payment processing, recurring billing, and email notifications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -14,7 +14,8 @@
14
14
  "dist",
15
15
  "prisma",
16
16
  "README.md",
17
- "LICENSE"
17
+ "LICENSE",
18
+ "CHANGELOG.md"
18
19
  ],
19
20
  "scripts": {
20
21
  "build": "tsc -p tsconfig.build.json",
@@ -65,6 +66,44 @@
65
66
  "type": "git",
66
67
  "url": "https://gitlab.com/ambushworks/nestjs-payments-graphql.git"
67
68
  },
69
+ "jest": {
70
+ "moduleFileExtensions": [
71
+ "js",
72
+ "json",
73
+ "ts"
74
+ ],
75
+ "rootDir": "src",
76
+ "testRegex": ".*\\.spec\\.ts$",
77
+ "transform": {
78
+ "^.+\\.(t|j)s$": "ts-jest"
79
+ },
80
+ "collectCoverageFrom": [
81
+ "**/*.(t|j)s"
82
+ ],
83
+ "coverageDirectory": "../coverage",
84
+ "testEnvironment": "node"
85
+ },
86
+ "homepage": "https://gitlab.com/ambushworks/nestjs-payments-graphql#readme",
87
+ "bugs": {
88
+ "url": "https://gitlab.com/ambushworks/nestjs-payments-graphql/-/issues"
89
+ },
90
+ "keywords": [
91
+ "nestjs",
92
+ "graphql",
93
+ "payments",
94
+ "invoicing",
95
+ "stripe",
96
+ "stripe-webhook",
97
+ "refunds",
98
+ "payment-plans",
99
+ "recurring-invoices",
100
+ "recurring-billing",
101
+ "subscriptions",
102
+ "e-transfer",
103
+ "interac",
104
+ "billing",
105
+ "prisma"
106
+ ],
68
107
  "author": "Ambush Softworks",
69
108
  "license": "MIT"
70
109
  }
@@ -0,0 +1,504 @@
1
+ // =============================================================================
2
+ // @ambushsoftworks/nestjs-payments-graphql — Reference Prisma schema
3
+ // =============================================================================
4
+ //
5
+ // This file is a DOCUMENTATION FRAGMENT. It is not loaded by Prisma at runtime
6
+ // and it is not imported automatically anywhere. Copy the models and enums
7
+ // below into your own `schema.prisma` and wire up the consumer-owned relations
8
+ // (marked with `// CONSUMER:`) to the models in your application.
9
+ //
10
+ // Field shapes are fixed by the package's repository interfaces
11
+ // (IInvoiceRepository, IPaymentRepository, etc.). You may:
12
+ // - Rename models freely (your repo adapter is the mapping layer).
13
+ // - Add columns (e.g. a soft-delete flag, additional audit fields).
14
+ // - Swap column types for compatible ones (e.g. `@db.VarChar(255)` on strings).
15
+ //
16
+ // You may NOT:
17
+ // - Change the cardinality of a field (e.g. make a required field optional
18
+ // without reflecting that in your repository adapter).
19
+ // - Drop tracked enum values (listed below).
20
+ //
21
+ // The package is database-agnostic — PostgreSQL examples are shown (e.g.
22
+ // `@db.Decimal(5, 4)`); swap for your provider's equivalents as needed.
23
+ //
24
+ // -----------------------------------------------------------------------------
25
+ // Model checklist
26
+ // -----------------------------------------------------------------------------
27
+ //
28
+ // Always required:
29
+ // - Invoice
30
+ // - InvoiceLineItem
31
+ // - Payment
32
+ // - Refund
33
+ // - PaymentConfig
34
+ // - PaymentProvider
35
+ // - ProcessedWebhookEvent
36
+ //
37
+ // Required when `features.paymentPlans = true`:
38
+ // - PaymentPlan
39
+ // - PaymentPlanInstallment
40
+ //
41
+ // Required when `features.recurringInvoices = true`:
42
+ // - RecurringInvoice
43
+ // - RecurringInvoiceLineItem
44
+ // - PaymentCustomer
45
+ //
46
+ // All enums are required if you use the corresponding model.
47
+ // =============================================================================
48
+
49
+
50
+ // -----------------------------------------------------------------------------
51
+ // Enums
52
+ // -----------------------------------------------------------------------------
53
+ // These enum value sets must match exactly — core services compare against
54
+ // these strings. Add new values at your own risk; the package will not
55
+ // recognize them.
56
+
57
+ enum InvoiceStatus {
58
+ DRAFT
59
+ SENT
60
+ PARTIALLY_PAID
61
+ PAID
62
+ OVERDUE
63
+ VOID
64
+ REFUNDED
65
+ }
66
+
67
+ enum PaymentMethod {
68
+ CREDIT_CARD
69
+ DEBIT
70
+ E_TRANSFER
71
+ BANK_TRANSFER
72
+ CHEQUE
73
+ CASH
74
+ OTHER
75
+ }
76
+
77
+ enum PaymentStatus {
78
+ PENDING
79
+ PENDING_CONFIRMATION
80
+ PROCESSING
81
+ SUCCEEDED
82
+ FAILED
83
+ CANCELLED
84
+ REJECTED
85
+ REFUNDED
86
+ PARTIALLY_REFUNDED
87
+ }
88
+
89
+ enum RefundStatus {
90
+ PENDING
91
+ PROCESSING
92
+ SUCCEEDED
93
+ FAILED
94
+ }
95
+
96
+ enum PaymentPlanStatus {
97
+ ACTIVE
98
+ COMPLETED
99
+ CANCELLED
100
+ DEFAULTED
101
+ }
102
+
103
+ enum InstallmentStatus {
104
+ PENDING
105
+ INVOICED
106
+ PAID
107
+ OVERDUE
108
+ WAIVED
109
+ CANCELLED
110
+ }
111
+
112
+ enum RecurringIntervalUnit {
113
+ DAYS
114
+ MONTHS
115
+ }
116
+
117
+ enum RecurringInvoiceStatus {
118
+ ACTIVE
119
+ PAUSED
120
+ CANCELLED
121
+ }
122
+
123
+
124
+ // -----------------------------------------------------------------------------
125
+ // Core models (always required)
126
+ // -----------------------------------------------------------------------------
127
+
128
+ model PaymentConfig {
129
+ id String @id @default(uuid())
130
+ divisionId String @unique // CONSUMER: tenant/org/division scope — unique per tenant
131
+
132
+ // E-transfer settings
133
+ eTransferEnabled Boolean @default(false)
134
+ eTransferEmail String?
135
+ eTransferRecipient String? // Display name for e-transfer recipient
136
+ eTransferAutoDeposit Boolean @default(true)
137
+
138
+ // Defaults applied when an invoice omits currency or tax rate
139
+ defaultCurrency String @default("USD")
140
+ defaultTaxRate Decimal? @db.Decimal(5, 4) // e.g. 0.1300 for 13%
141
+
142
+ createdAt DateTime @default(now())
143
+ updatedAt DateTime @updatedAt
144
+
145
+ providers PaymentProvider[]
146
+
147
+ // CONSUMER: add @relation to your Tenant/Division/Organization model
148
+ // division Division @relation(fields: [divisionId], references: [id], onDelete: Cascade)
149
+ }
150
+
151
+ model PaymentProvider {
152
+ id String @id @default(uuid())
153
+ paymentConfigId String
154
+ providerType String // e.g. "stripe"
155
+ isActive Boolean @default(true)
156
+ config Json? // Non-secret config (account IDs, webhook endpoint IDs)
157
+
158
+ createdAt DateTime @default(now())
159
+ updatedAt DateTime @updatedAt
160
+
161
+ paymentConfig PaymentConfig @relation(fields: [paymentConfigId], references: [id], onDelete: Cascade)
162
+
163
+ @@unique([paymentConfigId, providerType])
164
+ }
165
+
166
+ model Invoice {
167
+ id String @id @default(uuid())
168
+ divisionId String // CONSUMER: tenant/org scope
169
+ invoiceNumber String @unique
170
+ status InvoiceStatus @default(DRAFT)
171
+
172
+ // CONSUMER: opaque foreign key to your client/customer/contact record.
173
+ // The package treats this as an opaque string.
174
+ clientDetailsId String
175
+
176
+ // Polymorphic entity reference — what this invoice is FOR (booking, order,
177
+ // enrollment, etc.). Both fields nullable so invoices may stand alone.
178
+ referenceType String?
179
+ referenceId String?
180
+
181
+ // Payment plan link (populated when an invoice is generated from an installment)
182
+ paymentPlanId String?
183
+ installmentNumber Int?
184
+
185
+ // Recurring invoice link (populated when an invoice is generated from a recurring config)
186
+ recurringInvoiceId String?
187
+
188
+ // Financial amounts — ALL IN CENTS (integer minor units).
189
+ // taxRate is a decimal fraction: 0.1300 = 13%.
190
+ subtotal Int @default(0)
191
+ taxRate Decimal @default(0) @db.Decimal(5, 4)
192
+ taxAmount Int @default(0)
193
+ discount Int @default(0)
194
+ total Int @default(0)
195
+ amountPaid Int @default(0)
196
+ amountDue Int @default(0)
197
+
198
+ currency String @default("USD")
199
+
200
+ // E-transfer — code is the client-visible confirmation string, answer is
201
+ // the security answer the client must provide to the bank.
202
+ eTransferCode String? @unique
203
+ eTransferAnswer String?
204
+
205
+ issuedAt DateTime?
206
+ dueAt DateTime?
207
+
208
+ notes String? // Internal staff notes (never exposed to clients)
209
+ clientNotes String? // Visible on invoice to client
210
+
211
+ // Consumer-defined key-value pairs (opaque to the package)
212
+ metadata Json?
213
+
214
+ // Audit fields
215
+ createdBy String // CONSUMER: accountId/userId who created this invoice
216
+ voidedBy String?
217
+ voidedAt DateTime?
218
+ voidReason String?
219
+
220
+ createdAt DateTime @default(now())
221
+ updatedAt DateTime @updatedAt
222
+
223
+ // Relations
224
+ lineItems InvoiceLineItem[]
225
+ payments Payment[]
226
+ // Reverse relation for PaymentPlanInstallment.invoiceId one-to-one
227
+ installment PaymentPlanInstallment?
228
+ paymentPlan PaymentPlan? @relation("InvoicePlan", fields: [paymentPlanId], references: [id], onDelete: SetNull)
229
+ recurringInvoice RecurringInvoice? @relation(fields: [recurringInvoiceId], references: [id])
230
+
231
+ // CONSUMER: add relations to your tenant and client models.
232
+ // Use `onDelete: Restrict` on financial relations — data-retention rules
233
+ // typically forbid cascade-deleting invoices when a client is removed.
234
+ // division Division @relation(fields: [divisionId], references: [id], onDelete: Restrict)
235
+ // clientDetails Details @relation(fields: [clientDetailsId], references: [id], onDelete: Restrict)
236
+
237
+ @@index([divisionId, status])
238
+ @@index([clientDetailsId])
239
+ @@index([referenceType, referenceId])
240
+ @@index([paymentPlanId])
241
+ @@index([recurringInvoiceId])
242
+ @@index([status, dueAt]) // For overdue scheduler
243
+ }
244
+
245
+ model InvoiceLineItem {
246
+ id String @id @default(uuid())
247
+ invoiceId String
248
+
249
+ description String
250
+ quantity Int @default(1)
251
+ unitPrice Int // cents
252
+ sortOrder Int @default(0)
253
+ isTaxable Boolean @default(true)
254
+
255
+ // Optional polymorphic reference — what this line item represents
256
+ referenceType String?
257
+ referenceId String?
258
+
259
+ // Consumer-defined key-value pairs (opaque to the package)
260
+ metadata Json?
261
+
262
+ createdAt DateTime @default(now())
263
+ updatedAt DateTime @updatedAt
264
+
265
+ invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
266
+
267
+ @@index([invoiceId, sortOrder])
268
+ }
269
+
270
+ model Payment {
271
+ id String @id @default(uuid())
272
+ invoiceId String
273
+ amount Int // cents
274
+ currency String @default("USD")
275
+ method PaymentMethod
276
+ status PaymentStatus @default(PENDING)
277
+
278
+ // Provider fields (both null for manual payments, both set for online)
279
+ provider String? // e.g. "stripe"
280
+ providerPaymentId String?
281
+ providerData Json? // Provider-specific metadata — NEVER log raw card data
282
+ receiptUrl String?
283
+
284
+ // Manual payment fields
285
+ referenceNumber String? // cheque number, e-transfer confirmation, etc.
286
+ notes String?
287
+ processedAt DateTime? // When payment was actually processed (may differ from createdAt)
288
+ receivedBy String? // CONSUMER: accountId who recorded the manual payment
289
+
290
+ // Reconciliation — set true when gateway cancellation fails during voidInvoice
291
+ needsReconciliation Boolean @default(false)
292
+
293
+ // Audit
294
+ createdBy String // CONSUMER: accountId/userId who created the payment
295
+
296
+ createdAt DateTime @default(now())
297
+ updatedAt DateTime @updatedAt
298
+
299
+ invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Restrict)
300
+ refunds Refund[]
301
+
302
+ // Note: null-null is valid (manual payments) — PostgreSQL unique allows multiple nulls.
303
+ @@unique([provider, providerPaymentId])
304
+ @@index([invoiceId, status])
305
+ }
306
+
307
+ model Refund {
308
+ id String @id @default(uuid())
309
+ paymentId String
310
+ amount Int // cents
311
+ currency String @default("USD")
312
+ status RefundStatus @default(PENDING)
313
+ reason String?
314
+
315
+ // Provider fields (null for manual refunds)
316
+ providerRefundId String?
317
+ providerData Json?
318
+
319
+ // Audit
320
+ createdBy String // CONSUMER: accountId/userId who created the refund
321
+ processedBy String? // CONSUMER: accountId who processed a manual refund
322
+
323
+ createdAt DateTime @default(now())
324
+ updatedAt DateTime @updatedAt
325
+
326
+ payment Payment @relation(fields: [paymentId], references: [id], onDelete: Restrict)
327
+
328
+ @@index([paymentId])
329
+ }
330
+
331
+ model ProcessedWebhookEvent {
332
+ // Stripe event IDs are their own primary key.
333
+ // The package's IWebhookIdempotencyRepository uses this to no-op on replays.
334
+ eventId String @id
335
+ processedAt DateTime @default(now())
336
+
337
+ @@index([processedAt]) // For TTL cleanup via cleanupBefore()
338
+ }
339
+
340
+
341
+ // -----------------------------------------------------------------------------
342
+ // Payment plan models (required when features.paymentPlans = true)
343
+ // -----------------------------------------------------------------------------
344
+
345
+ model PaymentPlan {
346
+ id String @id @default(uuid())
347
+ divisionId String // CONSUMER: tenant/org scope
348
+ status PaymentPlanStatus @default(ACTIVE)
349
+
350
+ clientDetailsId String // CONSUMER: opaque client ID
351
+
352
+ // Polymorphic entity reference
353
+ referenceType String?
354
+ referenceId String?
355
+
356
+ totalAmount Int // cents
357
+ currency String @default("USD")
358
+
359
+ name String?
360
+ description String?
361
+
362
+ createdBy String // CONSUMER: accountId
363
+ cancelledBy String?
364
+ cancelledAt DateTime?
365
+ cancelReason String?
366
+
367
+ createdAt DateTime @default(now())
368
+ updatedAt DateTime @updatedAt
369
+
370
+ installments PaymentPlanInstallment[]
371
+ invoices Invoice[] @relation("InvoicePlan")
372
+
373
+ // CONSUMER: add relations to your tenant and client models.
374
+ // division Division @relation(fields: [divisionId], references: [id], onDelete: Restrict)
375
+ // clientDetails Details @relation(fields: [clientDetailsId], references: [id], onDelete: Restrict)
376
+
377
+ @@index([divisionId, status])
378
+ @@index([clientDetailsId])
379
+ @@index([referenceType, referenceId])
380
+ }
381
+
382
+ model PaymentPlanInstallment {
383
+ id String @id @default(uuid())
384
+ paymentPlanId String
385
+ sequenceOrder Int
386
+ amount Int // cents
387
+ status InstallmentStatus @default(PENDING)
388
+ dueAt DateTime?
389
+
390
+ // One-to-one link to the generated invoice (null until invoiced).
391
+ // INVARIANT: when set, Invoice.paymentPlanId and Invoice.installmentNumber
392
+ // must also be set atomically by the consumer repository adapter.
393
+ invoiceId String? @unique
394
+
395
+ createdAt DateTime @default(now())
396
+ updatedAt DateTime @updatedAt
397
+
398
+ paymentPlan PaymentPlan @relation(fields: [paymentPlanId], references: [id], onDelete: Cascade)
399
+ invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull)
400
+
401
+ @@index([paymentPlanId, sequenceOrder])
402
+ }
403
+
404
+
405
+ // -----------------------------------------------------------------------------
406
+ // Recurring invoice models (required when features.recurringInvoices = true)
407
+ // -----------------------------------------------------------------------------
408
+
409
+ model RecurringInvoice {
410
+ id String @id @default(uuid())
411
+ divisionId String // CONSUMER: tenant/org scope
412
+
413
+ // Schedule configuration
414
+ intervalUnit RecurringIntervalUnit
415
+ intervalValue Int
416
+ anchorDate DateTime // Immutable after creation — all next dates are computed from this
417
+ nextInvoiceAt DateTime
418
+ lastGeneratedAt DateTime? // Null = never generated; set after each successful generation
419
+
420
+ status RecurringInvoiceStatus @default(ACTIVE)
421
+ autoSend Boolean @default(true)
422
+
423
+ // Invoice template fields
424
+ clientDetailsId String // CONSUMER: opaque client ID
425
+ currency String @default("USD")
426
+ taxRate Decimal @default(0) @db.Decimal(5, 4)
427
+ notes String?
428
+ clientNotes String?
429
+
430
+ // Polymorphic entity reference
431
+ referenceType String?
432
+ referenceId String?
433
+
434
+ // Auto-charge (Phase 2) — uses the stored PaymentCustomer for the client
435
+ autoCharge Boolean @default(false)
436
+ metadata Json?
437
+ consecutiveFailures Int @default(0)
438
+ lastFailureReason String?
439
+
440
+ // Lifecycle tracking
441
+ pausedAt DateTime?
442
+ pausedBy String?
443
+ cancelledAt DateTime?
444
+ cancelledBy String?
445
+ cancelReason String?
446
+
447
+ createdBy String
448
+ createdAt DateTime @default(now())
449
+ updatedAt DateTime @updatedAt
450
+
451
+ templateLineItems RecurringInvoiceLineItem[]
452
+ generatedInvoices Invoice[]
453
+
454
+ // CONSUMER: add relations to your tenant and client models.
455
+ // division Division @relation(fields: [divisionId], references: [id], onDelete: Restrict)
456
+ // clientDetails Details @relation(fields: [clientDetailsId], references: [id], onDelete: Restrict)
457
+
458
+ @@index([status, nextInvoiceAt]) // Used by the generator claim query
459
+ @@index([divisionId, status])
460
+ }
461
+
462
+ model RecurringInvoiceLineItem {
463
+ id String @id @default(uuid())
464
+ recurringInvoiceId String
465
+
466
+ description String
467
+ quantity Int @default(1)
468
+ unitPrice Int // cents
469
+ sortOrder Int @default(0)
470
+ isTaxable Boolean @default(true)
471
+
472
+ referenceType String?
473
+ referenceId String?
474
+
475
+ metadata Json?
476
+
477
+ createdAt DateTime @default(now())
478
+ updatedAt DateTime @updatedAt
479
+
480
+ recurringInvoice RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
481
+
482
+ @@index([recurringInvoiceId, sortOrder])
483
+ }
484
+
485
+ model PaymentCustomer {
486
+ // Maps a (division, client, provider) triple to a provider-side customer ID
487
+ // (e.g. Stripe Customer). Powers auto-charge on recurring invoices and
488
+ // future saved-payment-method flows.
489
+ id String @id @default(uuid())
490
+ divisionId String
491
+ clientDetailsId String
492
+ provider String // "stripe"
493
+ providerCustomerId String
494
+
495
+ createdAt DateTime @default(now())
496
+ updatedAt DateTime @updatedAt
497
+
498
+ // CONSUMER: add relations to your tenant and client models.
499
+ // division Division @relation(fields: [divisionId], references: [id], onDelete: Restrict)
500
+ // clientDetails Details @relation(fields: [clientDetailsId], references: [id], onDelete: Restrict)
501
+
502
+ @@unique([divisionId, clientDetailsId, provider])
503
+ @@index([providerCustomerId])
504
+ }