@ambushsoftworks/nestjs-payments-graphql 0.6.0-rc.1 → 0.6.0

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +16 -2
  2. package/README.md +126 -7
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -56,7 +56,21 @@ prose for buried obligations. The marker was introduced in v0.4.0 and
56
56
  > they settle. If you report on invoices closing, expect the lag to appear.
57
57
  > 6. Services now reject non-integer and negative money. Anything that was
58
58
  > silently storing fractional cents will start throwing.
59
- > 7. **The package no longer searches the provider for a customer to adopt.**
59
+ > 7. **If you took Checkout payments before 0.3.1, back them up to the
60
+ > PaymentIntent id before upgrading, or refunds for them are silently
61
+ > skipped.** 0.3.1 fixed how new payments are keyed; it did not migrate
62
+ > existing rows. A pre-0.3.1 Checkout payment can be keyed on the Checkout
63
+ > Session id while refund webhooks quote the PaymentIntent id, and the lookup
64
+ > is an exact match with no fallback — so `RefundService` logs an error and
65
+ > skips, the customer's money goes back, and the invoice stays PAID. The
66
+ > README's "Backfilling Checkout payments taken before 0.3.1" has the SQL.
67
+ > Found by the jobsites team while upgrading from 0.2.0; our own answer to
68
+ > their question about this had said 0.3.1 covered it, which was true only
69
+ > for rows written after it.
70
+ > 8. **Subscribe to `charge.dispute.created` and `charge.dispute.closed`** — see
71
+ > the external-configuration section below. Build the list from
72
+ > `STRIPE_WEBHOOK_EVENTS` rather than prose, or set `stripe.verifyOnBoot`.
73
+ > 9. **The package no longer searches the provider for a customer to adopt.**
60
74
  > If you lose a `PaymentCustomer` row while the provider customer survives,
61
75
  > a new one is created and the client re-saves their card. In exchange,
62
76
  > whether two divisions share a wallet is now entirely your repository's
@@ -252,7 +266,7 @@ prose for buried obligations. The marker was introduced in v0.4.0 and
252
266
  schema still passes unchanged.
253
267
 
254
268
  ### Testing
255
- - **+65 specs, 529 across 23 suites.** New: `payment-method.service.spec.ts`,
269
+ - **+87 specs, 551 across 24 suites.** New: `payment-method.service.spec.ts`,
256
270
  `recurring-invoice-scheduling.spec.ts`, `money-validation.spec.ts`, plus
257
271
  saved-card coverage on the Stripe gateway and the catch-up storm, the
258
272
  session-cancellation path, the auto-charge convergence race and the money
package/README.md CHANGED
@@ -26,6 +26,8 @@ Production-grade payments module for NestJS with GraphQL support. Invoicing (inc
26
26
  - [Email Notifications](#email-notifications)
27
27
  - [Event Listeners](#event-listeners)
28
28
  - [Transactions](#transactions)
29
+ - [Entry points](#entry-points)
30
+ - [Testing your integration](#testing-your-integration)
29
31
  - [Services](#services)
30
32
  - [Manual payments and confirmation](#manual-payments-and-confirmation)
31
33
  - [Money and types](#money-and-types)
@@ -34,6 +36,7 @@ Production-grade payments module for NestJS with GraphQL support. Invoicing (inc
34
36
  - [Error handling](#error-handling)
35
37
  - [Operations](#operations)
36
38
  - [Upgrading](#upgrading)
39
+ - [Backfilling Checkout payments taken before 0.3.1](#backfilling-checkout-payments-taken-before-031)
37
40
  - [Configuration Reference](#configuration-reference)
38
41
  - [Required Options](#required-options)
39
42
  - [Optional Repositories](#optional-repositories)
@@ -239,6 +242,8 @@ The controller is registered at `POST /webhooks/stripe` and marked with `@Paymen
239
242
  - Have guards check `Reflector.get(PAYMENTS_WEBHOOK, handler)` and bail out early, **or**
240
243
  - The controller sets `isPublic` and `skipTenant` metadata via the `@PaymentsWebhook()` decorator; read them with `Reflector` and let the request through. `PAYMENTS_WEBHOOK` is the **metadata key** those are set under — not a path, so comparing it to `request.url` never matches.
241
244
 
245
+ `PaymentsWebhook` is exported, so you can apply the same three metadata keys to a route of your own if you proxy the webhook rather than exposing the package's controller directly. You do not need it otherwise — the controller already carries it.
246
+
242
247
  See [Critical Integration Requirements](#critical-integration-requirements).
243
248
 
244
249
  ### 5. Build a resolver
@@ -248,8 +253,11 @@ import { Resolver, Mutation, Args } from '@nestjs/graphql';
248
253
  import {
249
254
  InvoiceService,
250
255
  InvoiceModel,
251
- CreateInvoiceInput,
252
256
  } from '@ambushsoftworks/nestjs-payments-graphql';
257
+ // The @InputType classes live on the /graphql subpath. The plain names on the
258
+ // root are the *service* interfaces — what you pass to the service, which is a
259
+ // different shape (it carries `createdBy`, for one). See Upgrading.
260
+ import { CreateInvoiceInput } from '@ambushsoftworks/nestjs-payments-graphql/graphql';
253
261
 
254
262
  @Resolver()
255
263
  export class InvoiceResolver {
@@ -853,9 +861,17 @@ Utility helpers exported:
853
861
  import {
854
862
  computeNextInvoiceDate,
855
863
  computeCycleCount,
864
+ computeNextInvoiceDateAfter,
856
865
  } from '@ambushsoftworks/nestjs-payments-graphql';
857
866
  ```
858
867
 
868
+ `computeNextInvoiceDateAfter(anchor, unit, value, after)` is the one to use when
869
+ a schedule has fallen behind: it returns the first scheduled date strictly after
870
+ an instant, staying on the anchor's cadence. `computeCycleCount` rounds, which
871
+ is right for an exact cycle boundary and overshoots for an arbitrary one — a Jan
872
+ 15 anchor evaluated on May 3 rounds to cycle 4 and returns June 15, stepping
873
+ over a May 15 that has not happened yet. Added in v0.6.0.
874
+
859
875
  The consumer provides the scheduler (typically `@nestjs/schedule`) that calls `RecurringInvoiceService.processDueRecurringInvoices(now)` on a cron. See [Scheduled jobs](#scheduled-jobs).
860
876
 
861
877
  ### Saved Cards
@@ -944,6 +960,8 @@ const repositories = createPrismaPaymentRepositories(prisma, Prisma, {
944
960
 
945
961
  Declare `@@unique([clientDetailsId, provider])` on `PaymentCustomer` to match, and read the hazard in the scope-key section before you do.
946
962
 
963
+ **Keep the `divisionId` column** even though you no longer key on it. The bundled adapter still writes it on create and `assertPaymentsSchema` still requires it, so dropping it because "we do not scope by division" fails at boot — or on the first card save if you skip the schema assertion. It stays useful as a record of which division first created the customer.
964
+
947
965
  A custom gateway must implement `adoptSetupPaymentMethod` to support saved cards. It is optional on `RecurringPaymentGateway` so v0.2.x gateways still compile; without it the webhook logs a warning and the card stays unusable for auto-charge.
948
966
 
949
967
  ### E-Transfer
@@ -1033,6 +1051,63 @@ await transactionManager.runInTransaction(async (repos) => {
1033
1051
 
1034
1052
  ---
1035
1053
 
1054
+ ## Entry points
1055
+
1056
+ The package publishes four. The root carries everything you need to call a
1057
+ service; the other three exist so a name means one thing.
1058
+
1059
+ | Import from | Carries |
1060
+ |---|---|
1061
+ | `@ambushsoftworks/nestjs-payments-graphql` | Services, service input interfaces, entity types, exceptions, `@ObjectType` models, DI tokens, `StripeGateway`, utilities |
1062
+ | `…/graphql` | The `@InputType` classes for your resolvers, and the `IsMetadata` validator |
1063
+ | `…/prisma` | The bundled Prisma adapter — repositories, `createPrismaPaymentRepositories`, `assertPaymentsSchema` |
1064
+ | `…/testing` | In-memory repositories for testing your integration without a database |
1065
+
1066
+ **Why `/graphql` is separate.** Six names collided: `CreateRefundInput`,
1067
+ `RecordManualPaymentInput`, `CreateInvoiceInput`, `CreatePaymentPlanInput`,
1068
+ `CreateRecurringInvoiceInput` and `UpdateRecurringTemplateInput` each named both
1069
+ a GraphQL `@InputType` and the service interface of the same purpose — and they
1070
+ are different shapes. The DTO has no `createdBy`; the service requires it. On
1071
+ the root the plain name is now the **service** interface, because that is what a
1072
+ resolver needs to call through. Moved in v0.6.0; see [Upgrading](#upgrading).
1073
+
1074
+ ### Testing your integration
1075
+
1076
+ `/testing` ships the in-memory repositories the package's own suites use, so you
1077
+ can test resolvers and services without a database:
1078
+
1079
+ ```typescript
1080
+ import { createInMemoryRepositories } from '@ambushsoftworks/nestjs-payments-graphql/testing';
1081
+
1082
+ const repos = createInMemoryRepositories(myInvoiceRepository, {
1083
+ // Mirror your own schema. 'division' (the default) matches the reference
1084
+ // schema's @@unique([divisionId, clientDetailsId, provider]); 'client'
1085
+ // matches @@unique([clientDetailsId, provider]).
1086
+ customerKey: 'division',
1087
+ });
1088
+
1089
+ const moduleRef = await Test.createTestingModule({
1090
+ imports: [PaymentsModule.forRootAsync({ useFactory: () => ({ ...repos }) })],
1091
+ }).compile();
1092
+ ```
1093
+
1094
+ The keys match `PaymentsModuleOptions`, so the result spreads straight into your
1095
+ factory. `repos.store` is the raw data if you want to assert on stored rows, and
1096
+ `repos.openTransactions()` lets you assert no transaction is open at a gateway
1097
+ call.
1098
+
1099
+ **They enforce the unique constraints the reference schema declares**, because
1100
+ the package's recovery paths depend on them — `createPendingPayment`,
1101
+ `recordAutoChargePayment`, `createOrLinkCustomer` and the refund converger all
1102
+ catch `UniqueConstraintViolationException` and adopt the existing row. A fake
1103
+ without those constraints is more forgiving than any real database, and a test
1104
+ against it proves less than it appears to. That is also why `customerKey` exists:
1105
+ set it wrong and the double stops matching your schema.
1106
+
1107
+ **They do not model row locking.** `findByIdForUpdate` is a plain read. For
1108
+ anything that turns on locking — concurrent claims, `SKIP LOCKED`, the
1109
+ installment race — run against PostgreSQL.
1110
+
1036
1111
  ## Services
1037
1112
 
1038
1113
  Every service is exported from the package root and injectable anywhere once the module is registered (it is `global: true`). Listed here so you can see the whole surface in one place; the feature sections above cover behaviour.
@@ -1041,7 +1116,7 @@ Every service is exported from the package root and injectable anywhere once the
1041
1116
  |---|---|---|
1042
1117
  | `InvoiceService` | Invoice lifecycle: create, send, line items, tax, void, overdue | `ensureSent` is the one to call before opening a payment session |
1043
1118
  | `InvoiceNumberService` | Invoice number generation | Numbers are **global**, not per-division — see [Invoicing](#invoicing) |
1044
- | `PaymentService` | Recording payments, webhook handling, reconciliation | `recordManualPayment`, `confirmPayment`, `rejectPayment` for the offline flow |
1119
+ | `PaymentService` | Recording payments, webhook handling, reconciliation | `recordManualPayment`, `confirmPayment`, `rejectPayment` for the offline flow; `cancelPendingPaymentsForInvoice` cancels live provider sessions and is called for you when an invoice is voided |
1045
1120
  | `RefundService` | Full and partial refunds, refund webhooks | `createRefund`, `refundInvoice`, `findRefundsByPaymentId` |
1046
1121
  | `PaymentConfigService` | Per-division `PaymentConfig` and active providers | `getConfig` returns `null` for an unconfigured division; `getDefaultProvider` throws |
1047
1122
  | `PaymentPlanService` | Instalment plans and their invoices | `createPaymentPlan`, `invoiceInstallment`, `waiveInstallment`, `cancelPaymentPlan` |
@@ -1051,7 +1126,7 @@ Every service is exported from the package root and injectable anywhere once the
1051
1126
  | `ETransferService` | E-transfer code and answer generation, instruction text | `getInstructions` is pure |
1052
1127
  | `PaymentEmailService` | All outbound email | **Never throws**, and silently does nothing when no sender is configured |
1053
1128
  | `PaymentJobsService` | The three scheduled jobs | See [Scheduled jobs](#scheduled-jobs) |
1054
- | `GatewayRegistryService` | Gateway lookup and capability narrowing | `getRecurring`, `getInlineCapable`, `getPaymentMethodCapable` |
1129
+ | `GatewayRegistryService` | Gateway lookup and capability narrowing | `getRecurring`, `getInlineCapable` → `InlineCapableGateway`, `getPaymentMethodCapable` → `PaymentMethodCapableGateway`. Each returns the gateway with that capability's optional members narrowed to non-optional, or throws. |
1055
1130
 
1056
1131
  ### Manual payments and confirmation
1057
1132
 
@@ -1073,6 +1148,15 @@ Without that step the invoice never closes. If you record e-transfers and see in
1073
1148
 
1074
1149
  **Every monetary value is an integer number of cents.** Since v0.6.0 the services reject anything else rather than storing it — a negative or fractional amount throws `InvalidInvoiceStateException`.
1075
1150
 
1151
+ Two helpers are exported for the same invariant. `assertMoneyAmount(amount, field)`
1152
+ throws `InvalidInvoiceStateException` unless the value is a whole, non-negative
1153
+ number of cents — the services call it at every money entry point, and you can
1154
+ use it at your own. `deriveInvoiceAmounts(total, ledgerAmountPaid)` returns the
1155
+ `{ amountPaid, amountDue, overpaidBy, clampedFromNegative }` an invoice should
1156
+ store: `amountPaid` records reality and never goes negative, `amountDue` is
1157
+ always derived from it, and an overpayment is reported rather than absorbed.
1158
+ Both added in v0.6.0.
1159
+
1076
1160
  **Tax rates are `DecimalLike`, not `number`.** `Invoice.taxRate` and `InvoiceTaxComponent.rate` expose `.toNumber()` and `.toString()`, so a Prisma `Decimal` satisfies them directly. `invoice.taxRate * subtotal` gives `NaN`; use `invoice.taxRate.toNumber()`.
1077
1161
 
1078
1162
  ## Tenancy and scoping
@@ -1159,10 +1243,43 @@ The CHANGELOG carries the detail; this is the map. Every version's own section h
1159
1243
 
1160
1244
  | From | Read | Out-of-repo work |
1161
1245
  |---|---|---|
1162
- | 0.2.x | 0.2.1, 0.3.0, 0.3.1, 0.4.0, 0.5.0, 0.6.0 | Subscribe to `payment_intent.succeeded`; replace `charge.refunded` with `refund.created` + `refund.updated` |
1163
- | 0.3.x | 0.3.1, 0.4.0, 0.5.0, 0.6.0 | Replace `charge.refunded` with `refund.created` + `refund.updated` |
1164
- | 0.4.x | 0.4.0, 0.5.0, 0.6.0 | Widen tax-rate columns to `Decimal(6,5)`; add `@@unique([paymentId, providerRefundId])` to `Refund` |
1165
- | 0.5.x | 0.6.0 | Add `PaymentCustomer.defaultPaymentMethodId` if you want saved cards |
1246
+ | 0.2.x | 0.2.1, 0.3.0, 0.3.1, 0.4.0, 0.5.0, 0.6.0 | Subscribe to `payment_intent.succeeded`, `refund.created`, `refund.updated`, `charge.dispute.created`, `charge.dispute.closed`; drop `charge.refunded`. **Backfill existing Checkout payments — see below.** |
1247
+ | 0.3.x | 0.3.1, 0.4.0, 0.5.0, 0.6.0 | Subscribe to `refund.created`, `refund.updated`, `charge.dispute.created`, `charge.dispute.closed`; drop `charge.refunded`. **Backfill if you took Checkout payments before 0.3.1.** |
1248
+ | 0.4.x | 0.4.0, 0.5.0, 0.6.0 | Subscribe to `charge.dispute.created`, `charge.dispute.closed`. Widen tax-rate columns to `Decimal(6,5)`; add `@@unique([paymentId, providerRefundId])` to `Refund` |
1249
+ | 0.5.x | 0.6.0 | Subscribe to `charge.dispute.created`, `charge.dispute.closed`. Add `PaymentCustomer.defaultPaymentMethodId` if you want saved cards |
1250
+
1251
+ **Do not transcribe that event list by hand.** `STRIPE_WEBHOOK_EVENTS` in `gateways/stripe/types` is the canonical set for the version you are on; build your preflight from it, or set `stripe.verifyOnBoot` and let the verifier tell you what is missing. The jobsites team followed the prose above when it was incomplete and the verifier caught the gap in seconds — which is the argument for not trusting prose here.
1252
+
1253
+ **Swap the refund events add-then-remove, not all at once.** 0.2.0 ignores `refund.created`/`refund.updated` and 0.6.0 ignores `charge.refunded`, so subscribing to all three across the deploy leaves no window where a refund arrives with nothing listening. Remove `charge.refunded` once the new version is live. (Discovered by the jobsites team doing exactly this.)
1254
+
1255
+ ### Backfilling Checkout payments taken before 0.3.1
1256
+
1257
+ **If you took Checkout payments before 0.3.1, refunds for them will be silently skipped until you backfill.** This is the one upgrade step that loses money if you miss it.
1258
+
1259
+ Before 0.3.1 a Checkout payment's row could be keyed on the Checkout **Session** id (`cs_…`). Refund webhooks quote the **PaymentIntent** id (`pi_…`), and `RefundService` looks the payment up by an exact match with no fallback — so it logs an error, skips, and the customer's money goes back while the invoice stays `PAID`. 0.3.1 fixed the keying **for rows written from then on**; it does not migrate what you already have.
1260
+
1261
+ Find them:
1262
+
1263
+ ```sql
1264
+ SELECT COUNT(*) FROM "Payment"
1265
+ WHERE provider = 'stripe'
1266
+ AND "providerPaymentId" LIKE 'cs_%'
1267
+ AND "providerData"->>'paymentIntentId' IS NOT NULL;
1268
+ ```
1269
+
1270
+ Backfill them:
1271
+
1272
+ ```sql
1273
+ UPDATE "Payment"
1274
+ SET "providerPaymentId" = "providerData"->>'paymentIntentId'
1275
+ WHERE provider = 'stripe'
1276
+ AND "providerPaymentId" LIKE 'cs_%'
1277
+ AND "providerData"->>'paymentIntentId' IS NOT NULL;
1278
+ ```
1279
+
1280
+ Run it inside a transaction and check the count first. `Payment` carries `@@unique([provider, providerPaymentId])`, so the update fails rather than corrupting anything if a row already holds the target id — which happens when both success events were processed for one payment. Resolve those individually; the 0.3.1 notes describe how the package retires such a duplicate.
1281
+
1282
+ A row with a `cs_…` id and no `providerData.paymentIntentId` is a zero-amount session with no PaymentIntent at all. Leave it: there is nothing to refund.
1166
1283
 
1167
1284
  Two database constraints are load-bearing wherever you are coming from, because the package's recovery paths depend on them: `Payment` needs `@@unique([provider, providerPaymentId])` and `Refund` needs `@@unique([paymentId, providerRefundId])`. `PaymentCustomer` needs **a** unique constraint for the same reason — `createOrLinkCustomer` adopts the winner of a concurrent create rather than failing, and without one there is no winner to adopt. Which columns is your choice: `[divisionId, clientDetailsId, provider]` for the reference schema's per-division customers, or `[clientDetailsId, provider]` for one wallet per client. See [Provider customers and the scope key](#provider-customers-and-the-scope-key).
1168
1285
 
@@ -1284,6 +1401,8 @@ import {
1284
1401
 
1285
1402
  The package registers these names on module import. Consumers **must not** redefine or re-register them, or GraphQL will throw `"type already registered"`.
1286
1403
 
1404
+ The three `Paginated*` object types are built by the exported `Paginated(ItemModel)` mixin, which returns an `@ObjectType` with `items` and `totalCount`. Call it to build your own paginated wrapper over a package model; each call must produce a distinctly-named type, or GraphQL rejects the duplicate.
1405
+
1287
1406
  **Object types:** `Invoice`, `InvoiceLineItem`, `InvoiceTaxComponent`, `InvoiceClient`, `Payment`, `Refund`, `PaymentPlan`, `PaymentPlanInstallment`, `PaymentConfig`, `PaymentProvider`, `PaymentMethodSetup`, `ETransferInstructions`, `OnlinePaymentSession`, `RecurringInvoice`, `RecurringInvoiceLineItem`, `RecurringInvoiceTaxComponent`, `PaginatedInvoices`, `PaginatedPaymentPlans`, `PaginatedRecurringInvoices`, `InlinePaymentIntent`, `StripePublicConfig`, `SavedPaymentMethod`
1288
1407
 
1289
1408
  **Enums:** `InvoiceStatus`, `PaymentMethod`, `PaymentStatus`, `RefundStatus`, `PaymentPlanStatus`, `InstallmentStatus`, `RecurringIntervalUnit`, `RecurringInvoiceStatus`, `PaymentIntentStatus`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ambushsoftworks/nestjs-payments-graphql",
3
- "version": "0.6.0-rc.1",
3
+ "version": "0.6.0",
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",