@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 +90 -0
- package/LICENSE +21 -0
- package/README.md +609 -0
- package/package.json +41 -2
- package/prisma/payment-models.prisma +504 -0
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.
|
|
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
|
+
}
|