@ambushsoftworks/nestjs-payments-graphql 0.5.0-rc.2 → 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 (92) hide show
  1. package/CHANGELOG.md +394 -10
  2. package/README.md +389 -10
  3. package/dist/constants.d.ts +1 -0
  4. package/dist/constants.js +2 -1
  5. package/dist/constants.js.map +1 -1
  6. package/dist/exceptions/index.d.ts +9 -0
  7. package/dist/exceptions/index.js +19 -1
  8. package/dist/exceptions/index.js.map +1 -1
  9. package/dist/gateways/gateway-registry.service.d.ts +7 -0
  10. package/dist/gateways/gateway-registry.service.js +14 -0
  11. package/dist/gateways/gateway-registry.service.js.map +1 -1
  12. package/dist/gateways/payment-gateway.interface.d.ts +2 -1
  13. package/dist/gateways/recurring-payment-gateway.interface.d.ts +25 -0
  14. package/dist/gateways/stripe/stripe-webhook.controller.d.ts +1 -0
  15. package/dist/gateways/stripe/stripe-webhook.controller.js +34 -1
  16. package/dist/gateways/stripe/stripe-webhook.controller.js.map +1 -1
  17. package/dist/gateways/stripe/stripe.gateway.d.ts +29 -0
  18. package/dist/gateways/stripe/stripe.gateway.js +195 -11
  19. package/dist/gateways/stripe/stripe.gateway.js.map +1 -1
  20. package/dist/gateways/stripe/types.d.ts +1 -1
  21. package/dist/gateways/stripe/types.js +2 -0
  22. package/dist/gateways/stripe/types.js.map +1 -1
  23. package/dist/graphql/index.d.ts +18 -0
  24. package/dist/graphql/index.js +40 -0
  25. package/dist/graphql/index.js.map +1 -0
  26. package/dist/index.d.ts +14 -21
  27. package/dist/index.js +16 -40
  28. package/dist/index.js.map +1 -1
  29. package/dist/interfaces/payment-customer-repository.interface.d.ts +1 -0
  30. package/dist/interfaces/payment-event-listener.interface.d.ts +12 -0
  31. package/dist/models/payment-method-setup.model.js +3 -1
  32. package/dist/models/payment-method-setup.model.js.map +1 -1
  33. package/dist/models/saved-payment-method.model.d.ts +10 -0
  34. package/dist/models/saved-payment-method.model.js +65 -0
  35. package/dist/models/saved-payment-method.model.js.map +1 -0
  36. package/dist/payments.module.d.ts +1 -0
  37. package/dist/payments.module.js +20 -2
  38. package/dist/payments.module.js.map +1 -1
  39. package/dist/prisma/assert-payments-schema.d.ts +1 -0
  40. package/dist/prisma/assert-payments-schema.js +10 -3
  41. package/dist/prisma/assert-payments-schema.js.map +1 -1
  42. package/dist/prisma/columns.d.ts +3 -1
  43. package/dist/prisma/columns.js +9 -2
  44. package/dist/prisma/columns.js.map +1 -1
  45. package/dist/prisma/prisma-payment-customer.repository.d.ts +1 -0
  46. package/dist/prisma/prisma-payment-customer.repository.js +7 -0
  47. package/dist/prisma/prisma-payment-customer.repository.js.map +1 -1
  48. package/dist/prisma/prisma-repository-classes.js.map +1 -1
  49. package/dist/services/customer.service.d.ts +7 -2
  50. package/dist/services/customer.service.js +47 -11
  51. package/dist/services/customer.service.js.map +1 -1
  52. package/dist/services/invoice.service.d.ts +2 -0
  53. package/dist/services/invoice.service.js +93 -48
  54. package/dist/services/invoice.service.js.map +1 -1
  55. package/dist/services/money.util.d.ts +7 -0
  56. package/dist/services/money.util.js +22 -0
  57. package/dist/services/money.util.js.map +1 -0
  58. package/dist/services/payment-method.service.d.ts +21 -0
  59. package/dist/services/payment-method.service.js +150 -0
  60. package/dist/services/payment-method.service.js.map +1 -0
  61. package/dist/services/payment-plan.service.d.ts +2 -1
  62. package/dist/services/payment-plan.service.js +54 -32
  63. package/dist/services/payment-plan.service.js.map +1 -1
  64. package/dist/services/payment.service.d.ts +9 -0
  65. package/dist/services/payment.service.js +198 -29
  66. package/dist/services/payment.service.js.map +1 -1
  67. package/dist/services/recurring-interval.util.d.ts +1 -0
  68. package/dist/services/recurring-interval.util.js +12 -0
  69. package/dist/services/recurring-interval.util.js.map +1 -1
  70. package/dist/services/recurring-invoice.service.d.ts +2 -1
  71. package/dist/services/recurring-invoice.service.js +57 -34
  72. package/dist/services/recurring-invoice.service.js.map +1 -1
  73. package/dist/services/refund.service.js +6 -3
  74. package/dist/services/refund.service.js.map +1 -1
  75. package/dist/testing/in-memory-repositories.d.ts +21 -0
  76. package/dist/testing/in-memory-repositories.js +198 -0
  77. package/dist/testing/in-memory-repositories.js.map +1 -0
  78. package/dist/testing/in-memory-store.d.ts +21 -0
  79. package/dist/testing/in-memory-store.js +68 -0
  80. package/dist/testing/in-memory-store.js.map +1 -0
  81. package/dist/testing/index.d.ts +4 -0
  82. package/dist/testing/index.js +11 -0
  83. package/dist/testing/index.js.map +1 -0
  84. package/dist/types/index.d.ts +1 -0
  85. package/dist/types/index.js +1 -0
  86. package/dist/types/index.js.map +1 -1
  87. package/dist/types/payment-customer.types.d.ts +1 -0
  88. package/dist/types/payment-method.types.d.ts +10 -0
  89. package/dist/types/payment-method.types.js +3 -0
  90. package/dist/types/payment-method.types.js.map +1 -0
  91. package/package.json +15 -1
  92. package/prisma/payment-models.prisma +33 -4
package/CHANGELOG.md CHANGED
@@ -28,12 +28,261 @@ prose for buried obligations. The marker was introduced in v0.4.0 and
28
28
 
29
29
  ## [Unreleased]
30
30
 
31
+ ## [0.6.0] - 2026-09-19
32
+
33
+ > **Upgrade notes.** One optional database column; nothing else to migrate.
34
+ >
35
+ > 1. **`CreateRefundInput`, `RecordManualPaymentInput`, `CreateInvoiceInput`,
36
+ > `CreatePaymentPlanInput`, `CreateRecurringInvoiceInput` and
37
+ > `UpdateRecurringTemplateInput` now mean the *service* interfaces.** The
38
+ > GraphQL `@InputType` classes of the same names moved to the `/graphql`
39
+ > subpath. If you use them for `@Args()`, change the import; if you were
40
+ > fighting the types when calling a service, that is what this fixes — the
41
+ > type you needed was not exported at all.
42
+ > 2. **If you want saved-card management**, add
43
+ > `defaultPaymentMethodId String?` to `PaymentCustomer`, implement
44
+ > `IPaymentCustomerRepository.setDefaultPaymentMethod`, and set
45
+ > `features.cardManagement`. The module refuses to boot without both. Leave
46
+ > the flag off and nothing changes.
47
+ > 3. **A Stripe gateway that cannot be constructed now fails the boot.** It
48
+ > used to log and continue, leaving every payment to fail at request time.
49
+ > If your deployment has been starting with a bad key, it will now stop.
50
+ > 4. **Recurring templates no longer backfill missed cycles.** A template whose
51
+ > schedule has stalled generates one invoice on the next run, not one per
52
+ > missed cycle. If you were relying on the old behaviour to catch up, you
53
+ > will need to trigger those invoices explicitly.
54
+ > 5. **Delayed-notification payments no longer close an invoice on session
55
+ > completion.** ACH, SEPA and Bacs now record as processing and close when
56
+ > they settle. If you report on invoices closing, expect the lag to appear.
57
+ > 6. Services now reject non-integer and negative money. Anything that was
58
+ > silently storing fractional cents will start throwing.
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.**
74
+ > If you lose a `PaymentCustomer` row while the provider customer survives,
75
+ > a new one is created and the client re-saves their card. In exchange,
76
+ > whether two divisions share a wallet is now entirely your repository's
77
+ > decision — see "Provider customers and the scope key" in the README. No
78
+ > action needed if you use the bundled adapter or scope per division; that
79
+ > behaviour is unchanged.
80
+
81
+ ### ⚠ External configuration required
82
+
83
+ - **Stripe Dashboard:** subscribe your webhook endpoint to
84
+ `charge.dispute.created` and `charge.dispute.closed`. Without them a
85
+ chargeback pulls the funds with no local record at all — the payment stays
86
+ `SUCCEEDED`, the invoice stays `PAID`, and nothing in your system knows the
87
+ money has gone. `STRIPE_WEBHOOK_EVENTS.always` carries both, so a preflight
88
+ built from that constant will tell you if the subscription is missing.
89
+
90
+ ### Added
91
+ - **Dispute handling.** `charge.dispute.created` and `charge.dispute.closed`
92
+ are routed; the package annotates the payment, sets `needsReconciliation`,
93
+ and fires the new optional `IPaymentEventListener.onPaymentDisputed`. It
94
+ deliberately does **not** adjust the invoice — writing it off, re-invoicing,
95
+ or pursuing the client is a business decision, and a payments library
96
+ guessing at it would be worse than saying nothing. A dispute that cannot be
97
+ matched to a payment is still announced, with `paymentId: null` and an error
98
+ log: money has left the account, and silence is the worst outcome.
99
+ - **`PaymentMethodService`** — saved-card management, gated behind
100
+ `features.cardManagement`. `listPaymentMethods`, `setDefaultPaymentMethod`,
101
+ `removePaymentMethod`, `confirmSetup`, `resolveChargeablePaymentMethod` and
102
+ `requireChargeablePaymentMethod`. Keyed by `(divisionId, clientDetailsId)`
103
+ rather than a customer id, so tenant scope is structural; every method naming
104
+ a `paymentMethodId` verifies ownership and throws
105
+ `PaymentMethodNotOwnedException` otherwise. Requested by the jobsites team.
106
+ - **`PaymentCustomer.defaultPaymentMethodId`** — optional, a read-through cache
107
+ of the provider's own default. The provider stays authoritative for what gets
108
+ charged; this exists so a consumer can render the current card without a
109
+ round trip. Requires `features.cardManagement`.
110
+ - **Four optional members on `RecurringPaymentGateway`** —
111
+ `listPaymentMethods`, `detachPaymentMethod`, `setDefaultPaymentMethod`,
112
+ `getDefaultPaymentMethod` — reached through
113
+ `GatewayRegistryService.getPaymentMethodCapable()`.
114
+ - **`@ambushsoftworks/nestjs-payments-graphql/testing`** — in-memory
115
+ repositories and a transaction manager, so a consumer can test an integration
116
+ without a database. They enforce the unique constraints the reference schema
117
+ declares, because the package's recovery paths depend on them. They do not
118
+ model row locking; for that, run against PostgreSQL.
119
+ - **`@ambushsoftworks/nestjs-payments-graphql/graphql`** — the `@InputType`
120
+ classes, moved off the main entry point.
121
+ - **New exceptions:** `PaymentMethodNotOwnedException`
122
+ (`PAYMENT_METHOD_NOT_OWNED`), `PaymentMethodOperationNotSupportedException`
123
+ (`PAYMENT_METHOD_OPERATION_NOT_SUPPORTED`),
124
+ `NoChargeablePaymentMethodException` (`NO_CHARGEABLE_PAYMENT_METHOD`).
125
+ - **`SavedPaymentMethod`** GraphQL object type and TypeScript interface.
126
+ - **`computeNextInvoiceDateAfter`** and **`assertMoneyAmount`**, both exported.
127
+ - **Five README sections that did not exist:** Services, Tenancy and scoping,
128
+ Error handling, Operations, and Upgrading. Three exported services had never
129
+ been mentioned in the README at all, and neither had the manual-payment
130
+ confirmation flow the Quick Start promises.
131
+
132
+ ### Fixed
133
+ - **Two writers of `amountPaid`/`amountDue` disagreed on the invariant.**
134
+ `RefundService` derived `amountDue` from the clamped `amountPaid` and
135
+ documented that the two must sum to the total; `applyPaymentToInvoice` wrote
136
+ `amountPaid` unclamped and `amountDue` clamped, so an overpayment persisted
137
+ exactly the state the other side's comment forbade. Both now share
138
+ `deriveInvoiceAmounts`: `amountPaid` records reality and never goes negative,
139
+ `amountDue` is always derived from it, and an overpayment is kept and logged
140
+ rather than silently absorbed — it is money somebody has to decide about.
141
+ - **A lost refund response could refund twice.** `stripe.refunds.create` carried
142
+ no idempotency key, so a refund whose HTTP response was lost was
143
+ indistinguishable from one that failed. The operator retries,
144
+ `resolveRefundAmount` reads the local table — which never recorded the first —
145
+ and a second real refund goes out. The `(paymentId, providerRefundId)`
146
+ constraint cannot catch it: two genuinely different provider refunds. Now
147
+ keyed on the payment and amount, so a retry collapses while a deliberate
148
+ second partial refund still goes through.
149
+ - **Who was affected.** Anyone issuing online refunds. The 0.4.0 post-mortem
150
+ measured a refund call at 7.8 seconds; a timeout anywhere in that window is
151
+ all it takes.
152
+ - **A stalled recurring template charged once per missed cycle.** The claim
153
+ advanced `nextInvoiceAt` by one cycle from its own stale value, leaving the
154
+ template still due, so the batch loop re-claimed it and went round again. A
155
+ daily template six weeks behind generated ~42 invoices in one run — and under
156
+ `autoCharge`, 42 distinct invoice ids meant 42 distinct idempotency keys and
157
+ **42 real card charges**. Missed cycles are now skipped, matching `resume()`.
158
+ - `computeNextInvoiceDateAfter` also corrects an overshoot in `resume()`
159
+ itself: rounding the elapsed cycles could step over a date that had not
160
+ happened yet, so a Jan 15 anchor resumed on May 3 moved to June 15 rather
161
+ than May 15.
162
+ - **ACH and other delayed payments closed the invoice before settling.**
163
+ `checkout.session.completed` was treated as success regardless of
164
+ `payment_status`, so a session completing `unpaid` with the intent still
165
+ `processing` marked the payment SUCCEEDED, closed the invoice and emailed a
166
+ receipt. There was no way back — `handlePaymentFailed` only transitions from
167
+ PENDING/PROCESSING, so a later ACH return could not reopen it. The webhook now
168
+ emits `payment.processing` unless the session is genuinely paid.
169
+ - **Voiding an invoice left its payment session live.** `voidInvoice`'s comment
170
+ said cancellation was "handled separately by the caller"; no such call
171
+ existed, and `PaymentGateway.cancelPayment` was implemented and invoked
172
+ nowhere. The client's open checkout tab stayed payable: they pay, the webhook
173
+ declines to apply it, and the money sits at the provider with no refund path.
174
+ Voiding now cancels pending provider sessions, outside any transaction, and
175
+ flags `needsReconciliation` when the provider cannot be reached.
176
+ - **A declined Checkout card was never recorded as failed.**
177
+ `handlePaymentFailed` and `handlePaymentProcessing` looked up the event's own
178
+ id, but a Checkout row is keyed by the session id while
179
+ `payment_intent.payment_failed` carries the intent id. The lookup never
180
+ matched, the idempotency row was written so Stripe stopped retrying, and no
181
+ listener heard anything. Both now resolve canonically and fall back to the
182
+ invoice the event names.
183
+ - **`sendInvoice` could send twice and store a mismatched e-transfer pair.**
184
+ Read-then-write with no lock let two callers both observe DRAFT and proceed:
185
+ two "your invoice" emails, and both ran `assignCode`, so the stored
186
+ `eTransferCode` and `eTransferAnswer` could come from different writers. The
187
+ client was emailed a pair that did not match the row and their transfer
188
+ bounced. The transition is now claimed under a row lock. `ensureSent` is
189
+ documented as the call to make before opening a payment session, so a
190
+ double-clicked Pay button was the ordinary way in.
191
+ - **A rolled-back payment could leave an instalment permanently PAID.**
192
+ `applyPaymentToInvoice` ran inside the caller's transaction but notified
193
+ `PaymentPlanService` through the module-scoped repository, which committed on
194
+ a separate connection. The instalment update now happens after the
195
+ transaction commits.
196
+ - **Cancelling a payment plan voided invoices outside its own transaction**, and
197
+ since voiding now calls the provider, would have made a provider call inside
198
+ one. The cancellation is transactional; the invoice voids follow it.
199
+ - **A failed line item restarted invoice creation and orphaned the first
200
+ invoice.** The retry caught `UniqueConstraintViolationException` from a block
201
+ spanning creation, every line item, tax components and the totals update, then
202
+ retried the whole thing with a fresh number — leaving a numbered row with
203
+ partial line items and `total: 0`, indistinguishable from a real invoice. Only
204
+ the `create` is retried now.
205
+ - **Refunding a payment on a VOID invoice made it payable again.** Status was
206
+ derived from amounts alone, so a partial refund moved a voided invoice to
207
+ `PARTIALLY_PAID` — which is in `PAYABLE_STATUSES`. VOID is now terminal.
208
+ - **The services accepted money that was not money.** `amount: -5000` passed
209
+ `recordManualPayment`'s only check and left the invoice owing more than its
210
+ total; `amount: 10.5` stored fractional cents that propagated into every later
211
+ sum. The only guards were in the GraphQL DTOs, which the documented
212
+ architecture does not require anyone to use.
213
+ - **A Stripe gateway that failed to construct was swallowed.** The consumer
214
+ booted cleanly, every payment threw at request time, and the webhook answered
215
+ 400 — so Stripe retried every real payment event for three days and then
216
+ disabled the endpoint. Supplying `stripe` config now means a failure to build
217
+ the gateway fails the boot.
218
+ - **`checkout.session.completed` cast `payment_intent` to a string.** Stripe
219
+ sends null for a zero-amount or fully-discounted session; the cast fed null to
220
+ `paymentIntents.retrieve`, whose failure was logged as a receipt-URL problem.
221
+ - **Documentation corrections.** The auth-exemption instructions told readers to
222
+ compare a route path against `PAYMENTS_WEBHOOK`, which is a metadata key — a
223
+ guard written as documented never matches and every webhook 401s. Invoice
224
+ numbering was described as per-division and is global. The e-transfer prefix
225
+ sample produced `ARD--A1B2-C3D4` because the service adds its own separator,
226
+ and the documented code format named an invoice component that does not
227
+ exist. The webhook controller was described as mounting conditionally; it
228
+ mounts always. Design Principles contradicted the Email Options table on which
229
+ pieces are required.
230
+
231
+ ### Changed
232
+ - **The scope key for provider customers belongs to your repository.**
233
+ `StripeGateway.createOrRetrieveCustomer` no longer searches the provider for
234
+ a customer to adopt; it creates. `IPaymentCustomerRepository.findByClientAndProvider`
235
+ is the only index, so filtering on `divisionId` gives each division its own
236
+ wallet and ignoring it gives one client one wallet across all of them —
237
+ whichever is right for what a division means in your product.
238
+
239
+ This **supersedes the 0.5.0 fix below rather than relaxing it.** 0.5.0
240
+ narrowed *which* provider customers could be adopted, from "anyone with this
241
+ email" to "one this package created for this division and client". That made
242
+ it safe and also nearly pointless: if the package created it, the package
243
+ wrote a row, and the repository lookup one step earlier would have found it.
244
+ Removing adoption entirely closes the same hole harder, and hands the
245
+ decision to the layer that can actually make it. Email was never identity —
246
+ a company billing address is shared by several clients, and clients change
247
+ email.
248
+
249
+ Requested by the jobsites team, whose `divisionId` is a job site: one
250
+ recruiter, one card, three sites. Note that a shared wallet means every
251
+ division can change the default card the others charge, and is only safe when
252
+ `clientDetailsId` is globally unique. Both are documented.
253
+ - **`createInMemoryRepositories` takes `customerKey: 'division' | 'client'`**
254
+ (default `'division'`). The in-memory double enforced the reference schema's
255
+ three-column constraint unconditionally, which made it *more permissive than
256
+ the database* of a consumer scoped per client — the one failure mode a fake
257
+ must not have.
258
+ - **`InvoiceService` and `PaymentService` gained a shared money validator**, and
259
+ `PaymentService.cancelPendingPaymentsForInvoice` is new and public.
260
+ - **`PaymentGateway.createRefund` takes an optional `idempotencyKey`**, and
261
+ `RecurringPaymentGateway` gained the four optional card members above. All
262
+ optional, so a gateway written against v0.5.x still satisfies both interfaces.
263
+ - **`assertPaymentsSchema` takes `cardManagement`.** The `PaymentCustomer`
264
+ requirement is now driven by either `recurringInvoices` or `cardManagement`,
265
+ and the new column is only required by the latter — so an existing consumer's
266
+ schema still passes unchanged.
267
+
268
+ ### Testing
269
+ - **+87 specs, 551 across 24 suites.** New: `payment-method.service.spec.ts`,
270
+ `recurring-invoice-scheduling.spec.ts`, `money-validation.spec.ts`, plus
271
+ saved-card coverage on the Stripe gateway and the catch-up storm, the
272
+ session-cancellation path, the auto-charge convergence race and the money
273
+ validators. Five new documentation guards fail the build when an exported
274
+ service, a module option or an optional interface member goes undocumented —
275
+ each one encoding drift that had already happened.
276
+
31
277
  ## [0.5.0] - 2026-09-15
32
278
 
33
279
  > **Release candidates.** `0.5.0-rc.1` carries the tax-input changes, the
34
280
  > billing jobs, and the webhook retry and duplicate-delivery fixes. The shared
35
281
  > Prisma repositories (`@ambushsoftworks/nestjs-payments-graphql/prisma`) were
36
282
  > not in rc.1. `0.5.0-rc.2` adds them, with no change to the main entry point.
283
+ > `0.5.0-rc.3` adds the saved-card and auto-charge fixes reported by the
284
+ > jobsites team — retest auto-charge on a recurring template, and saving a card
285
+ > end to end, against Stripe test mode.
37
286
 
38
287
  > **Upgrade notes.** No database migration.
39
288
  >
@@ -53,6 +302,23 @@ prose for buried obligations. The marker was introduced in v0.4.0 and
53
302
  > so two concurrent `invoiceInstallment` calls cannot both link an invoice.
54
303
  > 5. If you construct `InvoiceService` or `RecurringInvoiceService` by hand, pass
55
304
  > `{ requireExplicit: false }` as the new last argument.
305
+ > 6. **If you use auto-charge or saved cards, they did not work before this
306
+ > release** — see the entries under Fixed. Nothing to migrate, but the
307
+ > first successful auto-charge on an existing recurring template may be its
308
+ > first ever. Check `consecutiveFailures` on your templates before upgrading:
309
+ > a template at 3 was auto-paused and stays paused until you resume it.
310
+ > 7. **Provider customers are now scoped to the division that created them.**
311
+ > Existing shared customers are left alone, so nothing breaks on upgrade, but
312
+ > a client billed by two divisions will get a second provider customer the
313
+ > next time `createOrLinkCustomer` runs for the division that did not create
314
+ > the first one — and cards saved against the original are not visible to it.
315
+ > If you bill one client from more than one division and rely on a shared
316
+ > saved card, have them re-save it under the second division.
317
+ > 8. If you construct `CustomerService` by hand, it takes two new constructor
318
+ > arguments: `PaymentConfigService` and the `DEFAULT_CURRENCY` value.
319
+ > 9. **`createSetupSession().providerSetupId` is now the Checkout Session id**
320
+ > (`cs_…`), not the SetupIntent id. It previously returned `null` cast to a
321
+ > string on every call, so nothing can have depended on the old value.
56
322
 
57
323
  ### Added
58
324
  - **`tax.requireExplicit` module option.** With it set,
@@ -122,6 +388,16 @@ prose for buried obligations. The marker was introduced in v0.4.0 and
122
388
  schema, and copies drift.
123
389
 
124
390
  ### Changed
391
+ - **`RecurringPaymentGateway` gained one optional method and three optional
392
+ params.** `adoptSetupPaymentMethod?()` records the card a completed setup
393
+ session collected; `createOffSessionPayment` takes `paymentMethodId?` and
394
+ `idempotencyKey?`; `createSetupSession` takes `currency?`. All optional, so a
395
+ custom gateway written against v0.2.x still satisfies the interface — but one
396
+ that does not implement `adoptSetupPaymentMethod` cannot support saved cards,
397
+ and the webhook logs a warning saying so.
398
+ - **`CustomerService` takes two new constructor arguments**, `PaymentConfigService`
399
+ and the `DEFAULT_CURRENCY` value. This only matters if you construct it by
400
+ hand, in tests for example.
125
401
  - **An empty `taxComponents` list means untaxed.** `createInvoice` and
126
402
  `RecurringInvoiceService.create` treat `taxComponents: []` exactly like
127
403
  `taxRate: 0`, with no component rows and `taxAmount` 0, where it used to
@@ -164,6 +440,114 @@ prose for buried obligations. The marker was introduced in v0.4.0 and
164
440
  unrenamed. A hand-written adapter can still rename anything.
165
441
 
166
442
  ### Fixed
443
+
444
+ Five of these were reported by the jobsites team against `0.2.0`, from running a
445
+ prototype on live cards; the rest were found auditing the same paths before this
446
+ release. They share one property: every one of them is silent. The API returns
447
+ 200, the webhook returns 200, and the logs look healthy.
448
+
449
+ - **Auto-charge took the money and then refused to record it.**
450
+ `generateInvoiceFromConfig` creates a DRAFT invoice, optionally sends it, then
451
+ charges the saved card — and charged *before* checking the invoice could
452
+ accept a payment. `recordAutoChargePayment` then rejected the DRAFT, so the
453
+ charge stood at Stripe while the consumer's listener was told the renewal had
454
+ FAILED. One client was emailed a failure notice while the package held their
455
+ money. `attemptAutoCharge` now re-reads the invoice and refuses to call the
456
+ provider unless it is in `PAYABLE_STATUSES`, recording an
457
+ `invoice_not_payable` failure instead.
458
+ - **Who was affected.** Any consumer using recurring auto-charge. Two
459
+ configurations reach it: `autoCharge: true` with `autoSend: false`, which
460
+ jobsites found, and `autoSend: true` where `sendInvoice` throws — an email
461
+ transport outage, say — which `generateInvoiceFromConfig` swallows as
462
+ non-fatal before charging anyway. The second was not reported and is the
463
+ reason the fix is a status check at the charge site rather than a validation
464
+ on the template.
465
+ - **Detecting affected charges.** Look for provider charges whose invoice is
466
+ still DRAFT, or for `onRecurringPaymentFailed` events whose invoice has no
467
+ corresponding `Payment` row. The money is real; the invoice never moved.
468
+ - **Auto-charge could be charged, paid, and reported as failed at the same time.**
469
+ Stripe fires `payment_intent.succeeded` the moment an off-session charge
470
+ settles, so the webhook races `recordAutoChargePayment` for the same
471
+ `(provider, providerPaymentId)`. The webhook usually won: its writer applied
472
+ the amount and closed the invoice, then `recordAutoChargePayment` hit the
473
+ unique constraint, its transaction rolled back, and the throw reached
474
+ `attemptAutoCharge`'s catch — which recorded a failure and fired
475
+ `onRecurringPaymentFailed`. **The client was charged, the invoice was paid,
476
+ and the consumer was told the renewal had failed.** Three consecutive
477
+ "failures" auto-pause the schedule, so a working subscription could pause
478
+ itself. Both writers now converge on one row, covering either ordering: a
479
+ pre-check for a webhook that already committed (needed because
480
+ `validatePayable` would otherwise throw on the now-PAID invoice before the
481
+ constraint could fire), and unique-violation adoption for one that commits
482
+ mid-transaction. The adopting call announces nothing — the writer that caused
483
+ the transition already did.
484
+ - **Auto-charge billed the invoice total, not the balance.** The payable-status
485
+ check re-read the invoice, but the amount still came from the snapshot taken
486
+ before the invoice was sent. Anything that landed in between — an operator
487
+ recording an advance, or the client paying the link in the auto-send email —
488
+ leaves the invoice `PARTIALLY_PAID`, which *is* payable, so the check passed
489
+ and the full total was charged on top. `recordAutoChargePayment` validates
490
+ payability but never the amount, so nothing downstream caught it, and
491
+ `applyPaymentToInvoice` clamped `amountDue` to zero and marked the invoice
492
+ PAID while the client had been billed twice. It now charges `amountDue`, and
493
+ skips entirely when nothing is outstanding.
494
+ - **`RecurringInvoiceService` threw a `TypeError` when the feature was off.**
495
+ The module provides `RECURRING_INVOICE_REPOSITORY` as `?? null` while the
496
+ service injected it as non-null — the same defect fixed in `PaymentPlanService`
497
+ and `CustomerService` above, and the last of the three. A consumer whose cron
498
+ called `processDueRecurringInvoices` without `features.recurringInvoices` got
499
+ `Cannot read properties of null` inside a scheduler. It now names the missing
500
+ option.
501
+ - **Auto-charge could never charge anyone.** `createOffSessionPayment` built a
502
+ PaymentIntent with a `customer` and no `payment_method`. Stripe applies
503
+ `invoice_settings.default_payment_method` to Invoices and Subscriptions, not
504
+ to a bare PaymentIntent, so confirming one failed every time with *"You cannot
505
+ confirm this PaymentIntent because it's missing a payment method."* The
506
+ gateway now resolves the customer's default method and attaches it explicitly,
507
+ accepts an optional `paymentMethodId` override, and returns
508
+ `failureReason: 'no_payment_method'` without calling Stripe when nothing
509
+ resolves — rather than falling back to whichever card was added most recently.
510
+ - **"Add a card" failed for every client.** `createSetupSession` omitted
511
+ `currency`, which Stripe requires in `mode: 'setup'` when `payment_method_types`
512
+ is not given, so every call raised *"Missing required param: currency."* The
513
+ currency now comes from the division's `PaymentConfig.defaultCurrency`, then
514
+ the module's `defaultCurrency`, and can be overridden per call. This is the
515
+ first thing in the package to read `PaymentConfig.defaultCurrency`.
516
+ - **A saved card was invisible to auto-charge.** Nothing in the package ever
517
+ wrote `invoice_settings.default_payment_method` — the gateway's own docblock
518
+ asserted that Stripe set it on setup-session completion, which Stripe does
519
+ not do. The webhook now records the collected method as the customer's
520
+ default when they have none, leaving an existing default alone so adding a
521
+ second card does not silently change which one gets charged. Together with
522
+ the two entries above, this is the first release in which saving a card and
523
+ charging it later works end to end.
524
+ - **`PaymentPlanService` threw on every successful payment when payment plans
525
+ were off.** `PaymentService` calls `onInvoicePaid` after every invoice reaches
526
+ PAID, and the service dereferenced a repository the module provides as `null`,
527
+ logging `Cannot read properties of null` each time. Cosmetic, but it trains
528
+ people to ignore payment warnings in a module where the other four bugs here
529
+ are silent. `onInvoicePaid` and `markInstallmentOverdue` now return early, and
530
+ consumer-facing methods name the missing option instead of throwing a
531
+ `TypeError`. `CustomerService` had the same latent fault and got the same
532
+ treatment.
533
+ - **Two divisions billing the same person shared one provider customer, and one
534
+ set of saved cards.** `createOrRetrieveCustomer` matched on email across the
535
+ whole Stripe account, ignoring the `divisionId`/`clientDetailsId` metadata it
536
+ then wrote — so it also adopted customers this package never created. It now
537
+ reuses a customer only when both metadata values match, and creates one
538
+ otherwise. Found while fixing the reported bugs, not reported. See upgrade
539
+ note 7.
540
+ - **A retried generation job could charge twice.** The off-session
541
+ `paymentIntents.create` carried no `idempotencyKey`, unlike
542
+ `createPaymentIntent`. It now derives one from the invoice id.
543
+ - **Auto-charge payments never recorded a receipt URL.**
544
+ `createOffSessionPayment` read `latest_charge.receipt_url` without expanding
545
+ `latest_charge`, so the field was an id string and the check silently fell
546
+ through to `undefined` on every charge.
547
+ - **`createSetupSession` returned null as a string.** `providerSetupId` was
548
+ `session.setup_intent`, which Stripe leaves null in setup mode until the
549
+ customer completes the session, feeding a non-nullable GraphQL field. It now
550
+ returns the Checkout Session id.
167
551
  - **`invoiceInstallment` recorded a tax rate it did not charge.** Its single
168
552
  line was non-taxable and it passed no tax, so `PaymentConfig.defaultTaxRate`
169
553
  was stamped on the invoice. With a 13% default, the client received a
@@ -566,16 +950,6 @@ API wrote — and delete the rest. Then recalculate the affected invoices;
566
950
  ### Fixed
567
951
  - **`payment_intent.succeeded` Stripe webhook event is now handled.** Previously the event fell through to the `default → ignored` branch in `StripeGateway.normalizeWebhookEvent`, meaning any PaymentIntent created outside the Checkout flow — chiefly the inline Stripe Elements surface arriving in 0.3.0, but also any consumer calling `stripe.paymentIntents.create` directly — succeeded at Stripe with no corresponding invoice or payment-record update. The new handler mirrors `handleCheckoutSessionCompleted`'s `latest_charge` → `receipt_url` fetch pattern (including the inflated-object vs string-id fork) and emits `type: 'payment.succeeded'` with `providerPaymentId: intent.id`, so `PaymentService.handlePaymentSucceeded` matches the row stored at `createPendingPayment` time without an id-swap step. Consumers using direct-PaymentIntent flows MUST subscribe to `payment_intent.succeeded` in their Stripe Dashboard webhook configuration — see "Stripe Gateway & Webhooks" in the README. This is also a precondition for the inline-PaymentIntent surface arriving in 0.3.0.
568
952
 
569
- ## [0.1.3] - 2026-05-15
570
-
571
- ### Added
572
- - **`NormalizedWebhookEvent` / `IgnoredWebhookEvent` types** exported from the package root. `PaymentGateway.handleWebhook` now returns the discriminated union; the `'ignored'` variant signals "received but no action needed" so the webhook controller responds 200 instead of 400 + Stripe retry loop. See "Stripe Gateway & Webhooks" in the README.
573
-
574
- ### Changed
575
- - **Stripe webhook controller now responds 200 for unrecognized Stripe event types** (e.g. `payment_intent.created`, `charge.updated`, `charge.succeeded`). Previously it threw inside the gateway's `normalizeWebhookEvent` and the controller's signature-verification try/catch re-threw as `BadRequestException('Webhook signature verification failed')`, causing Stripe to retry the delivery for ~3 days and filling logs with misleading "signature verification failed" messages. The new behavior matches Stripe's documented contract: log unrecognized events at `debug`, acknowledge with 200. Real signature failures are unchanged (still 400 with the correct error message).
576
- - **`PaymentService` now logs a WARN when a SUCCEEDED payment lands on an invoice in a non-payable status** (DRAFT, VOID, PAID, REFUNDED, …). Previously this was a silent skip — the consumer's card was charged but the invoice never updated. Affects both `handlePaymentSucceeded` (webhook path) and `reconcilePayment` (reconciliation scheduler path). Most commonly hit when a consumer creates a DRAFT invoice and starts checkout without first calling `InvoiceService.sendInvoice` to transition it to SENT.
577
- - **`PaymentGateway.handleWebhook` return type widened from `Promise<PaymentWebhookEvent>` to `Promise<NormalizedWebhookEvent>`** to express the new `'ignored'` variant. TypeScript covariance: existing custom-gateway implementations whose `handleWebhook` is declared as `Promise<PaymentWebhookEvent>` continue to satisfy the interface without modification (no TS error). Callers who destructure the return value should add a guard on the discriminator (`if (event.type === 'ignored') return;`) before accessing `PaymentWebhookEvent`-only fields.
578
-
579
953
  ## [0.2.0] - 2026-05-15
580
954
 
581
955
  ### Added
@@ -587,6 +961,16 @@ API wrote — and delete the rest. Then recalculate the affected invoices;
587
961
  ### Changed
588
962
  - **`InvoiceModel.payments` and `InvoiceModel.lineItems` are now nullable in the GraphQL schema** (`@Field(() => [...], { nullable: 'itemsAndList' })`, TS properties optional). The package ships no `@ResolveField` for these fields per its "no resolvers in the package" rule; the previous non-nullable declaration caused Apollo to throw `Cannot return null for non-nullable field` for any consumer that hadn't wired up their own resolver. Both fields now carry JSDoc explaining the consumer-population responsibility. **This is a GraphQL schema-shape change**: consumer clients that selected `payments` or `lineItems` and assumed non-null arrays must now handle `null` (defensive `?.` / `?? []`).
589
963
 
964
+ ## [0.1.3] - 2026-05-15
965
+
966
+ ### Added
967
+ - **`NormalizedWebhookEvent` / `IgnoredWebhookEvent` types** exported from the package root. `PaymentGateway.handleWebhook` now returns the discriminated union; the `'ignored'` variant signals "received but no action needed" so the webhook controller responds 200 instead of 400 + Stripe retry loop. See "Stripe Gateway & Webhooks" in the README.
968
+
969
+ ### Changed
970
+ - **Stripe webhook controller now responds 200 for unrecognized Stripe event types** (e.g. `payment_intent.created`, `charge.updated`, `charge.succeeded`). Previously it threw inside the gateway's `normalizeWebhookEvent` and the controller's signature-verification try/catch re-threw as `BadRequestException('Webhook signature verification failed')`, causing Stripe to retry the delivery for ~3 days and filling logs with misleading "signature verification failed" messages. The new behavior matches Stripe's documented contract: log unrecognized events at `debug`, acknowledge with 200. Real signature failures are unchanged (still 400 with the correct error message).
971
+ - **`PaymentService` now logs a WARN when a SUCCEEDED payment lands on an invoice in a non-payable status** (DRAFT, VOID, PAID, REFUNDED, …). Previously this was a silent skip — the consumer's card was charged but the invoice never updated. Affects both `handlePaymentSucceeded` (webhook path) and `reconcilePayment` (reconciliation scheduler path). Most commonly hit when a consumer creates a DRAFT invoice and starts checkout without first calling `InvoiceService.sendInvoice` to transition it to SENT.
972
+ - **`PaymentGateway.handleWebhook` return type widened from `Promise<PaymentWebhookEvent>` to `Promise<NormalizedWebhookEvent>`** to express the new `'ignored'` variant. TypeScript covariance: existing custom-gateway implementations whose `handleWebhook` is declared as `Promise<PaymentWebhookEvent>` continue to satisfy the interface without modification (no TS error). Callers who destructure the return value should add a guard on the discriminator (`if (event.type === 'ignored') return;`) before accessing `PaymentWebhookEvent`-only fields.
973
+
590
974
  ## [0.1.2] - 2026-04-19
591
975
 
592
976
  ### Fixed