@ambushsoftworks/nestjs-payments-graphql 0.2.1 → 0.4.0-rc.1
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 +147 -0
- package/README.md +396 -7
- package/dist/dto/create-invoice.input.d.ts +2 -0
- package/dist/dto/create-invoice.input.js +8 -0
- package/dist/dto/create-invoice.input.js.map +1 -1
- package/dist/dto/create-tax-component.input.d.ts +7 -0
- package/dist/dto/create-tax-component.input.js +56 -0
- package/dist/dto/create-tax-component.input.js.map +1 -0
- package/dist/dto/index.d.ts +1 -0
- package/dist/dto/index.js +3 -1
- package/dist/dto/index.js.map +1 -1
- package/dist/dto/update-payment-config.input.d.ts +1 -0
- package/dist/dto/update-payment-config.input.js +7 -0
- package/dist/dto/update-payment-config.input.js.map +1 -1
- package/dist/exceptions/index.d.ts +15 -0
- package/dist/exceptions/index.js +33 -1
- package/dist/exceptions/index.js.map +1 -1
- package/dist/gateways/gateway-registry.service.d.ts +5 -0
- package/dist/gateways/gateway-registry.service.js +9 -0
- package/dist/gateways/gateway-registry.service.js.map +1 -1
- package/dist/gateways/payment-gateway.interface.d.ts +23 -0
- package/dist/gateways/stripe/stripe-config-verifier.service.d.ts +19 -0
- package/dist/gateways/stripe/stripe-config-verifier.service.js +107 -0
- package/dist/gateways/stripe/stripe-config-verifier.service.js.map +1 -0
- package/dist/gateways/stripe/stripe.gateway.d.ts +15 -2
- package/dist/gateways/stripe/stripe.gateway.js +204 -2
- package/dist/gateways/stripe/stripe.gateway.js.map +1 -1
- package/dist/gateways/stripe/types.d.ts +19 -0
- package/dist/gateways/stripe/types.js +26 -1
- package/dist/gateways/stripe/types.js.map +1 -1
- package/dist/gateways/stripe/verify-stripe-configuration.d.ts +41 -0
- package/dist/gateways/stripe/verify-stripe-configuration.js +281 -0
- package/dist/gateways/stripe/verify-stripe-configuration.js.map +1 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +24 -2
- package/dist/index.js.map +1 -1
- package/dist/interfaces/invoice-repository.interface.d.ts +3 -1
- package/dist/interfaces/payment-repository.interface.d.ts +1 -0
- package/dist/interfaces/recurring-invoice-repository.interface.d.ts +3 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.js +3 -1
- package/dist/models/index.js.map +1 -1
- package/dist/models/inline-payment-intent.model.d.ts +10 -0
- package/dist/models/inline-payment-intent.model.js +49 -0
- package/dist/models/inline-payment-intent.model.js.map +1 -0
- package/dist/models/invoice-tax-component.model.d.ts +12 -0
- package/dist/models/invoice-tax-component.model.js +60 -0
- package/dist/models/invoice-tax-component.model.js.map +1 -0
- package/dist/models/invoice.model.d.ts +2 -0
- package/dist/models/invoice.model.js +5 -0
- package/dist/models/invoice.model.js.map +1 -1
- package/dist/models/payment-config.model.d.ts +1 -0
- package/dist/models/payment-config.model.js +4 -0
- package/dist/models/payment-config.model.js.map +1 -1
- package/dist/models/payment-intent-status.enum.d.ts +7 -0
- package/dist/models/payment-intent-status.enum.js +12 -0
- package/dist/models/payment-intent-status.enum.js.map +1 -0
- package/dist/models/stripe-public-config.model.d.ts +3 -0
- package/dist/models/stripe-public-config.model.js +24 -0
- package/dist/models/stripe-public-config.model.js.map +1 -0
- package/dist/payments.module.d.ts +3 -0
- package/dist/payments.module.js +7 -1
- package/dist/payments.module.js.map +1 -1
- package/dist/register-payment-enums.js +2 -0
- package/dist/register-payment-enums.js.map +1 -1
- package/dist/services/default-payment-email-templates.js +20 -0
- package/dist/services/default-payment-email-templates.js.map +1 -1
- package/dist/services/invoice.service.d.ts +17 -0
- package/dist/services/invoice.service.js +123 -14
- package/dist/services/invoice.service.js.map +1 -1
- package/dist/services/payment-config.service.d.ts +1 -0
- package/dist/services/payment-config.service.js +1 -0
- package/dist/services/payment-config.service.js.map +1 -1
- package/dist/services/payment.service.d.ts +3 -0
- package/dist/services/payment.service.js +109 -16
- package/dist/services/payment.service.js.map +1 -1
- package/dist/services/recurring-invoice.service.d.ts +4 -1
- package/dist/services/recurring-invoice.service.js +66 -2
- package/dist/services/recurring-invoice.service.js.map +1 -1
- package/dist/types/index.d.ts +24 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/recurring-invoice.types.d.ts +20 -0
- package/package.json +1 -1
- package/prisma/payment-models.prisma +105 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,10 +5,157 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## Reading this file
|
|
9
|
+
|
|
10
|
+
Alongside the standard Keep-a-Changelog sections, releases may carry:
|
|
11
|
+
|
|
12
|
+
### ⚠ External configuration required
|
|
13
|
+
|
|
14
|
+
Steps that must be performed **outside your repository** — a Stripe Dashboard
|
|
15
|
+
setting, DNS, a third-party console — to make the release work.
|
|
16
|
+
|
|
17
|
+
These get their own heading because they are a different species from API
|
|
18
|
+
changes. A breaking export, a new required field, a changed signature: your
|
|
19
|
+
compiler, your tests or a code review will catch all of them. A webhook
|
|
20
|
+
endpoint that was never subscribed to a required event is invisible to every
|
|
21
|
+
one of those, and the failure shows up as money moving at the provider while
|
|
22
|
+
your invoices quietly stay open.
|
|
23
|
+
|
|
24
|
+
Grep for `⚠ External configuration required` across the versions you are
|
|
25
|
+
skipping to build an upgrade checklist mechanically, rather than reading
|
|
26
|
+
prose for buried obligations. The marker was introduced in v0.4.0 and
|
|
27
|
+
**retro-applied** to earlier releases, so it is reliable for the whole file.
|
|
28
|
+
|
|
8
29
|
## [Unreleased]
|
|
9
30
|
|
|
31
|
+
## [0.4.0] - 2026-08-20
|
|
32
|
+
|
|
33
|
+
> **Upgrade notes.** Two database changes, both in your own schema:
|
|
34
|
+
>
|
|
35
|
+
> 1. **If you copied the reference schema**, widen three columns —
|
|
36
|
+
> `Invoice.taxRate`, `RecurringInvoice.taxRate`, `PaymentConfig.defaultTaxRate`
|
|
37
|
+
> — from `numeric(5,4)` to `numeric(6,5)`. Non-destructive; see the first
|
|
38
|
+
> entry under Fixed. Skip if you chose your own column types.
|
|
39
|
+
> 2. **Only if you want invoices carrying more than one tax**, add the
|
|
40
|
+
> `InvoiceTaxComponent` table (and `RecurringInvoiceTaxComponent` if you use
|
|
41
|
+
> recurring billing) and implement the matching optional repository methods.
|
|
42
|
+
> Single-rate invoices need neither, and behave exactly as they do today.
|
|
43
|
+
>
|
|
44
|
+
> Includes everything in 0.3.1, which was never published separately. If you
|
|
45
|
+
> are on 0.3.0 and running Checkout alongside inline PaymentIntent flows, read
|
|
46
|
+
> the 0.3.1 notes below before moving traffic onto this — the double-application
|
|
47
|
+
> fix has an audit step attached to it.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
- **Tax rates lost precision at 4 decimal places, systematically over-charging.** `Invoice.taxRate`, `RecurringInvoice.taxRate` and `PaymentConfig.defaultTaxRate` were declared `Decimal(5, 4)` in `prisma/payment-models.prisma`, which cannot hold Quebec's 9.975% QST: `0.09975` stores as `0.0998`. The error is not random — it rounds up, so every affected invoice over-charges by the same margin (5¢ per $1,000). All three are now `Decimal(6, 5)`.
|
|
51
|
+
- **This is a reference-schema fix, not a package-code fix.** `DecimalLike` always handled five decimal places, and `recalculateTotals` has carried a comment about *"rates like 9.975%"* since before the column existed — we anticipated the rate and then shipped a column that could not hold it. Consumers who chose their own column types were never affected.
|
|
52
|
+
- **Migration required if you copied the reference schema.** Widening a numeric scale is non-destructive and no package behaviour changes, but values already truncated on write stay truncated: re-save any tax rate with more than four decimal places after migrating. `ALTER TABLE "Invoice" ALTER COLUMN "taxRate" TYPE numeric(6,5);` and likewise for `RecurringInvoice.taxRate` and `PaymentConfig.defaultTaxRate`.
|
|
53
|
+
- A spec now asserts the precision of every tax-rate column in the reference schema, and that every money column stays `Int`, so a future re-sync cannot narrow them back silently.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
- **Invoices can carry more than one tax.** `Invoice.taxRate` models a sale attracting exactly one tax, which is all most sales are — and cannot express one attracting two. A Quebec sale carries 5% GST *and* 9.975% QST: two taxes, levied by two governments, remitted separately. Blending them to 14.975% produces an invoice the client cannot claim input tax credits from and the supplier cannot reconcile two remittances against. US state + county + city sales tax stacks the same way. `CreateInvoiceInput.taxComponents` now accepts a list of `{ name, rate, authority?, registrationNumber?, sortOrder? }`, stored as `InvoiceTaxComponent` rows.
|
|
57
|
+
- **Nothing changes for single-rate invoices**, which remain the default and the entire behaviour when `taxComponents` is omitted. `taxRate` keeps working exactly as before, including against repository adapters that never implement the new optional methods.
|
|
58
|
+
- **The consumer owns the rate table; the package owns the arithmetic.** You decide which taxes apply and at what rate — that is tax law and it changes without warning. The package computes each component's `amount` from the invoice's taxable subtotal and re-prices them whenever line items change, so a component's amount cannot go stale against the invoice it belongs to.
|
|
59
|
+
- **Components are authoritative.** `taxAmount` is their sum, not a blended rate applied to the subtotal — each component is remitted to a different authority, so the invoice total is derived from them rather than the other way round. `taxRate` becomes the sum of the component rates, kept as a rollup for callers reading one number; re-deriving `taxAmount` from it can differ by a cent, and when it does, the components are right.
|
|
60
|
+
- **`IInvoiceRepository.findTaxComponentsByInvoice` / `replaceTaxComponents`** — new OPTIONAL methods, so v0.3.x adapters keep compiling untouched. Implement both or neither. Supplying components to a repository missing them throws `InvalidTaxConfigurationException` rather than silently dropping them, which would write an invoice whose stated tax covers taxes it cannot list.
|
|
61
|
+
- **`InvalidTaxConfigurationException`** (`INVALID_TAX_CONFIGURATION`) — thrown for `taxRate` and `taxComponents` together, an empty component list, an unnamed or duplicately-named component, or a negative/non-finite rate. Each of those would otherwise resolve to a silently wrong figure on a financial document; failing to issue the invoice is the better outcome.
|
|
62
|
+
- **`InvoiceTaxComponent` GraphQL type** and **`CreateTaxComponentInput`** are registered by the package — see "Reserved GraphQL Type Names". `InvoiceModel.taxComponents` exposes them, consumer-populated like `lineItems` (the package ships no `@ResolveField`).
|
|
63
|
+
- **Recurring templates carry tax components too.** `RecurringInvoiceService.create` accepts `taxComponents`, stored as `RecurringInvoiceTaxComponent` rows and copied onto every invoice the template generates, priced against that invoice's own subtotal. Without this, a template billing a client in a multi-tax province could only hold the blended rollup rate, and every invoice it generated would misstate which taxes were collected — silently, on a schedule, for as long as the template ran. That is a worse failure than the single-invoice case, not a lesser one.
|
|
64
|
+
- The template rows deliberately have **no `amount` column**: a template has no subtotal to price against. Amounts exist only on the generated invoices.
|
|
65
|
+
- **`IRecurringInvoiceRepository.findTaxComponentsByRecurringInvoice` / `replaceTaxComponents`** — new OPTIONAL methods, same contract as the invoice-level pair.
|
|
66
|
+
- **`PaymentConfig.taxRegistrationNumber`** — the supplier's tax registration number, rendered on invoices so the recipient can claim input tax credits; a Canadian invoice without the supplier's GST/HST number is not fully usable by the client. Stored and displayed verbatim, not validated — the format differs by jurisdiction. Per-tax overrides live on `InvoiceTaxComponent.registrationNumber`, because a supplier's GST and QST numbers are different. Optional on the interface so existing `PaymentConfig` adapters keep compiling. Settable through `UpdatePaymentConfigInput` and readable on `PaymentConfigModel`.
|
|
67
|
+
- **Refunds do not apportion tax across components**, and now say so. A refund reduces `amountPaid` and recalculates invoice status; it does not write back a reduced `taxAmount` or adjust component amounts. The components continue to state the tax charged on the original sale, which is what a credit note references. Whether a partial refund releases tax pro-rata or tax-last is a tax-law question, so the package does not answer it — documented under "Tax" in the README rather than left for someone to discover.
|
|
68
|
+
- **The default invoice email now shows a totals block** — subtotal, each tax on its own line with its name and registration number, discount, total. It previously showed only the amount due, so no tax was visible at all. Invoices without components print a single `Tax` line as they always effectively did. Consumers using their own `IPaymentEmailTemplateRenderer` receive `taxComponents` on the invoice and can render them however they like; the package still ships no PDF renderer.
|
|
69
|
+
- **`STRIPE_WEBHOOK_EVENTS`** — the Stripe webhook events this package routes, exported so consumers can assert a live Dashboard subscription list against the version they actually run. Split into `always` (every Stripe deployment) and `inlinePaymentIntent` (`payment_intent.succeeded`, needed only when calling `createPaymentIntent`), so a Checkout-only consumer is not told to subscribe to an event it never receives. Compute your own required set from your own configuration; see "Stripe Gateway & Webhooks" in the README for a preflight example, including handling `enabled_events: ['*']`.
|
|
70
|
+
- **`ALL_STRIPE_WEBHOOK_EVENTS`** — both groups flattened, for consumers using Checkout and inline flows together.
|
|
71
|
+
- **`webhook-events.spec.ts`** — asserts the constant against the gateway in **both** directions: every named event is really routed (driving `handleWebhook` end to end), and every event the routing switch handles is really named. The second is the one that matters — adding a `case` to `normalizeWebhookEvent` without updating the constant would ship consumers an incomplete list and quietly recreate the failure the constant exists to prevent.
|
|
72
|
+
|
|
73
|
+
- **`verifyStripeConfiguration(opts)`** — checks a Stripe deployment against what this version of the package needs and returns `StripeConfigFinding[]`. Verifies that the secret key authenticates (reporting the account id and display name), that the account has `charges_enabled`, that the publishable key is a `pk_` key belonging to the same account and mode as the secret, that a webhook endpoint exists for `webhookUrl` and is enabled, that it is subscribed to everything in `STRIPE_WEBHOOK_EVENTS`, and that its `api_version` matches the version the package pins. **Returns findings and never throws** — including for bad keys, permission errors and network failures — so consumers choose the policy: fail CI, fail boot, print a report, expose a health check.
|
|
74
|
+
- **Supplying `publishableKey` is treated as a declaration of intent.** It means the deployment uses inline PaymentIntent flows, which is what makes `payment_intent.succeeded` mandatory. A missing subscription for it is an `error` when the key is set and `info` when it is not, because a Checkout-only deployment genuinely never receives that event.
|
|
75
|
+
- **A check that could not run is reported, never passed.** A restricted key without webhook read permission yields `webhook_endpoints_unreadable` rather than an empty result, and an endpoint returned without its event list yields `webhook_events_unreadable` rather than being read as "subscribed to nothing" — absent is not empty. Keys that do not embed an account id yield `publishable_key_account_undetermined` rather than a guess, since a false mismatch would fail a correct deployment's CI. `webhook_endpoint_not_found` is a `warning` rather than an `error` because Stripe's v2 event destinations and CLI listeners are not returned by the endpoints API and would otherwise look missing. `enabled_events: ['*']` is treated as satisfying everything, and where several endpoints share a URL the enabled one is reported on.
|
|
76
|
+
- **The same-account check calibrates instead of assuming a key layout.** The secret key has just authenticated as the account, so if it embeds that account's id the property holds for these keys and the publishable key not embedding it is real evidence of a different account; if it does not, no conclusion is drawn. An earlier draft compared a fixed-width prefix of each key, which bled past a shorter account body into the random tail and produced a false `error` on a correct pair.
|
|
77
|
+
- Match on `code` (typed as `StripeConfigFindingCode`), not on `message`. Codes are public API; messages are written for humans and may be reworded.
|
|
78
|
+
- `client` accepts a `StripeVerificationClient` — a narrow structural slice of the SDK — so the function is testable without a live account and usable with a client configured for Connect or a proxy.
|
|
79
|
+
- **`STRIPE_API_VERSION`** — the Stripe API version the SDK client is pinned to, now a shared constant. `StripeGateway` and `verifyStripeConfiguration` both read it, so the version the package parses payloads as and the version it checks endpoints against cannot drift apart.
|
|
80
|
+
|
|
81
|
+
- **`stripe.verifyOnBoot?: boolean`** — runs `verifyStripeConfiguration` at application bootstrap and logs each finding at its severity. Defaults to `false` so nobody pays a network call at boot without asking. **Never throws and never blocks startup**: a check that fails or exceeds a 10-second timeout is logged as `UNVERIFIED` — explicitly not as a pass — so an unreachable Stripe API cannot stall a deploy. Consumers wanting a hard gate call `verifyStripeConfiguration` in a preflight or CI job, where failing is free; refusing to boot a running service because Stripe was briefly unreachable trades a configuration problem for an outage.
|
|
82
|
+
- **`stripe.webhookUrl?: string`** — the public URL of this app's webhook endpoint, used *only* by `verifyOnBoot`. The package does not route on it; the controller still mounts at the fixed path `POST /webhooks/stripe`. Without it, boot verification checks the keys and the account but cannot check the webhook subscription.
|
|
83
|
+
- **Setting `publishableKey` is now treated as a declaration of intent.** Previously the package held that signal at module init and did nothing with it: the key gates `createPaymentIntent`, so configuring it means the deployment uses inline PaymentIntent flows, which is exactly what makes `payment_intent.succeeded` mandatory. With `verifyOnBoot` on, a webhook endpoint missing that subscription now logs at **error** naming the consequence in operator terms — *inline payments will succeed at Stripe and the invoice will never close* — instead of at `info`. Without a publishable key the same gap stays informational, because a Checkout-only deployment genuinely never receives that event.
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
- **The CHANGELOG now marks external configuration with its own `### ⚠ External configuration required` heading**, and the marker has been **retro-applied to 0.2.1 and 0.3.0** so it is reliable across the whole file — anyone upgrading from 0.1.x reads those entries, and a convention that only exists going forward does not help them. Requirements satisfied outside the consumer's repository are a different species from API changes: a compiler, a test and a code review all catch a changed signature, and none of them can see an unsubscribed webhook endpoint. The 0.2.1 requirement was previously the second-to-last sentence of a seven-line paragraph under `Fixed`, which read as a description of what was fixed rather than as a task. Consumers can now grep the versions they are skipping and build an upgrade checklist mechanically. Convention documented for readers at the top of this file and for maintainers in `CONTRIBUTING.md`.
|
|
87
|
+
|
|
88
|
+
### Testing
|
|
89
|
+
- **293 specs across 14 suites** (up from 100 at 0.3.0). Beyond the per-feature coverage above, three of those suites exist to stop a class of silent drift rather than to test a behaviour, and a fourth exercises the module as a consumer would get it:
|
|
90
|
+
- `webhook-events.spec.ts` — the exported webhook-event list against the gateway's routing switch, in both directions.
|
|
91
|
+
- `prisma-schema.spec.ts` — tax-rate precision and integer money columns in the reference schema, which ships in the tarball and is what consumers copy from.
|
|
92
|
+
- `published-docs.spec.ts` — the README's exception table and reserved GraphQL type names against what the package actually exports, and that the packaged version has a CHANGELOG entry. Both lists are load-bearing: a missing exception code leaves consumers unable to map an error, and a missing reserved type name means a consumer registers the same name and their application crashes at boot.
|
|
93
|
+
- `payments.module.spec.ts` boots the real module against stub repositories, because unit tests cannot see a provider registered in the wrong place or a lifecycle hook that never fires — those surface at a consumer's boot rather than ours.
|
|
94
|
+
|
|
95
|
+
## [0.3.1] - 2026-08-20
|
|
96
|
+
|
|
97
|
+
> **Upgrade note.** This release repairs invoice totals going forward but does
|
|
98
|
+
> not touch rows already written. If you run Checkout alongside inline
|
|
99
|
+
> PaymentIntent flows, or use recurring auto-charge, audit your invoices for
|
|
100
|
+
> the double-application described below before upgrading traffic onto it —
|
|
101
|
+
> the corruption is confined to invoice totals, and the `Payment` ledger is
|
|
102
|
+
> intact, so affected invoices can be recomputed from it.
|
|
103
|
+
|
|
104
|
+
### Fixed
|
|
105
|
+
- **A payment could be applied to its invoice twice, inflating `amountPaid`.** One Stripe payment emits two success events — `checkout.session.completed` (keyed by the Checkout Session id, `cs_…`) and `payment_intent.succeeded` (keyed by the PaymentIntent id, `pi_…`). They carry different event ids, so the `IWebhookIdempotencyRepository` dedup did not catch them, and `handlePaymentSucceeded` had no guard on the payment's current status: it transitioned an already-SUCCEEDED payment to SUCCEEDED again and re-ran `applyPaymentToInvoice`, adding the amount a second time. `handlePaymentFailed` and `handlePaymentProcessing` already gated on status; only the success path did not.
|
|
106
|
+
- **Who was affected.** Consumers subscribed to **both** events — which is every consumer running inline PaymentIntent flows alongside Checkout, since 0.2.1 requires `payment_intent.succeeded` for the inline surface. Full payments were saved by accident: the invoice reached `PAID`, which is not in `PAYABLE_STATUSES`, so the second application hit the non-payable warn branch. **Partial payments were not** — `PARTIALLY_PAID` is payable, so deposits and payment-plan installments double-counted, and an invoice could flip to `PAID` on half the money.
|
|
107
|
+
- **Detecting corrupted invoices.** Only the invoice totals drift — the `Payment` ledger stays correct, because the double application updated one existing row rather than inserting a second. So the corruption is detectable, and repairable, from the ledger: `amountPaid` should equal `SUM(payments WHERE status IN ('SUCCEEDED','PARTIALLY_REFUNDED','REFUNDED')) - SUM(succeeded refunds)`, and any invoice whose `amountPaid` **exceeds** that sum has drifted. Recompute `amountPaid`/`amountDue`/`status` from it; `RefundService.recalculateInvoiceAmounts` implements exactly this formula. Re-check the invoice `status` afterwards — an invoice pushed to `PAID` on a partial payment needs to go back to `PARTIALLY_PAID` and, if its due date has passed, `OVERDUE`.
|
|
108
|
+
- **A flagged invoice is not proof this defect reached you.** `amountPaid` is net of refunds, so *any* cause of drift between an invoice and its ledger shows up identically — most commonly a refund recorded without recalculating the invoice it belonged to. Before concluding it was double-applied, compare the gap: a double-application overshoots by **exactly one SUCCEEDED card payment's amount** (and that payment carries a `provider`), whereas refund drift overshoots by the **sum of that invoice's SUCCEEDED refunds**. The repair is the same either way — recompute from the ledger — but the cause, and whether you need to audit anything else, is not. (Reported by a consumer whose only flagged row turned out to be a seeded fixture.)
|
|
109
|
+
- **Duplicate confirmation emails.** The second application also re-sent the payment confirmation email to the customer. Fixed by the same change.
|
|
110
|
+
- **Recurring auto-charges were affected too.** `recordAutoChargePayment` creates its `Payment` row already `SUCCEEDED` and keyed by the PaymentIntent id, then applies the amount. Stripe fires `payment_intent.succeeded` for that same off-session charge, which the old handler treated as a fresh success and applied again. Any consumer using recurring billing *and* subscribed to `payment_intent.succeeded` double-counted every auto-charged partial payment — this was not limited to the Checkout + inline combination.
|
|
111
|
+
- **`checkout.session.completed` could fail permanently when `payment_intent.succeeded` arrived first.** The intent event could not find the row stored under the session id, so it took the create-from-metadata path and inserted its own record under the PaymentIntent id. The checkout event then tried to swap the original row to that same id and hit `@@unique([provider, providerPaymentId])` — throwing through all three controller retries and leaving the original row stranded in `PENDING` forever, where the reconciliation sweep and `findReusablePendingByInvoiceId` would both keep picking it up. (The invoice itself was credited once and correctly, by the webhook-created row — this path stuck the webhook rather than corrupting totals. It requires the consumer to have put `invoiceId` in the PaymentIntent metadata via `gatewayOptions.paymentIntentData`; without it the intent event simply logged and skipped.) `handlePaymentSucceeded` now resolves the target row **canonically** — by the PaymentIntent id, which both events carry in `providerData` — so the outcome no longer depends on delivery order, and the leftover pre-swap row is marked `CANCELLED` with an explanatory note instead of stranded.
|
|
112
|
+
- **Concurrent delivery of both events no longer double-applies.** The payment row is now re-read under `findByIdForUpdate` *inside* the transaction before its status is checked, so two simultaneous deliveries cannot both observe the pre-transition status. Lock order is payment-then-invoice, matching `confirmPayment`, so the two cannot deadlock. When both transactions race past the lookup, the `@@unique([provider, providerPaymentId])` constraint on `Payment` makes the loser roll back — un-recording its idempotency row with it — and Stripe's retry re-runs it against the settled record. That constraint (already relied on by `createPendingPayment`) is load-bearing; adapters must not drop it.
|
|
113
|
+
|
|
114
|
+
### Changed
|
|
115
|
+
- **`handlePaymentSucceeded` now gates on payment status.** The amount is applied only from `PENDING`, `PROCESSING`, or `FAILED`. `FAILED` is included deliberately: Stripe reuses one PaymentIntent across retries, so a declined attempt followed by a successful retry on the same intent is a real money-moving flow, and `handlePaymentFailed` never touched invoice amounts. `SUCCEEDED` (already applied), `REFUNDED`/`PARTIALLY_REFUNDED` (would silently undo a refund on the invoice), `PENDING_CONFIRMATION`/`REJECTED` (the e-transfer lifecycle, owned by `confirmPayment`/`rejectPayment`), and `CANCELLED` are skipped. Skips still record the event id so Stripe stops retrying, and log at a severity matching how surprising they are — the already-applied case is routine for dual-subscribed endpoints and logs at `log`; the rest log at `warn`.
|
|
116
|
+
- **Payments created from a webhook are now keyed by the canonical PaymentIntent id** rather than the event's own `providerPaymentId`, so a later duplicate event resolves to the same row. Affects only the create-from-metadata path (provider-dashboard charges); rows created by `createPendingPayment` are unchanged until the existing session-id → intent-id swap.
|
|
117
|
+
- **`handlePaymentSucceeded` now records and credits the amount the provider actually charged**, rather than the amount the pending `Payment` row was created with. A pending row holds an *intention*; the success event holds what was taken. Previously the invoice was credited with the intention, so an under-charged payment could still mark the invoice `PAID` on money that never arrived. The row's `amount` is re-stated at the same time, so the payment ledger and the invoice agree and `RefundService.recalculateInvoiceAmounts` recomputes from the real figure. The divergence is reachable via `createPaymentSession`, whose `gatewayOptions` are spread at the top level of the Checkout Session call — a consumer enabling `allow_promotion_codes` or `automatic_tax` lets Stripe settle on a total the pending row never saw. Any drift is logged at `warn`.
|
|
118
|
+
- Two cases deliberately keep the recorded amount. **A currency mismatch** logs at `error` and credits the recorded amount — the package will not invent an exchange rate, and a human has to resolve it. **A non-positive event amount** logs at `warn` and keeps the recorded amount: `handleCheckoutSessionCompleted` falls back to `session.amount_total ?? 0` when its PaymentIntent retrieve fails, so zero means "unknown", not "free".
|
|
119
|
+
- **+28 specs** covering `PaymentService.handlePaymentSucceeded`, total now 128 across 7 suites. They run against an in-memory store that enforces the `Payment` unique constraint and rolls back on throw, so the ordering, concurrency and rollback behaviour above is exercised rather than asserted in prose. The suite also pins the *opposite* failure mode — that legitimate repeat payments (a second PaymentIntent on the same invoice, a card payment on top of a manual one) still apply in full.
|
|
120
|
+
|
|
121
|
+
## [0.3.0] - 2026-05-28
|
|
122
|
+
|
|
123
|
+
### ⚠ External configuration required
|
|
124
|
+
|
|
125
|
+
- **Stripe Dashboard:** subscribe your webhook endpoint to `payment_intent.succeeded` before enabling the inline PaymentIntent surface. Carried forward from 0.2.1 and load-bearing here: setting `stripe.publishableKey` and calling `createPaymentIntent` moves you onto a flow whose success signal is this event, not `checkout.session.completed`. Miss it and inline payments succeed at Stripe while the invoice stays open with a live "Pay now" button. *(Marker retro-applied in 0.4.0.)*
|
|
126
|
+
|
|
127
|
+
### Added
|
|
128
|
+
- **`PaymentGateway.createPaymentIntent(params)`** — new (optional) interface method. Returns `{ providerPaymentId, clientSecret, status, reused, providerStatus? }` for an inline payment flow (Stripe Elements / `flutter_stripe` / equivalents). The Stripe implementation reuses an existing PaymentIntent when the consumer's payment repository has a still-confirmable PENDING row for the same invoice (via the new optional `IPaymentRepository.findReusablePendingByInvoiceId`). Idempotent on the Stripe side via a stable `idempotencyKey` per `(invoiceId, attemptId)`; pass a request-scoped UUID as `attemptId` for the most robust idempotency, or omit and the gateway falls back to invoice-only. Non-Stripe gateways without an implementation throw `PaymentIntentOperationNotSupportedException`. Use `GatewayRegistryService.getInlineCapable('stripe')` to obtain a type-narrowed gateway where the methods are non-optional.
|
|
129
|
+
- **`PaymentGateway.cancelPaymentIntent(providerPaymentId)`** — new (optional) interface method. Cancels a still-cancellable PaymentIntent and returns `{ success, alreadyTerminal }`. Distinct from `cancelPayment` (which is the refund-adjacent variant used by `voidInvoice` cleanup). Throws `PaymentIntentNotCancellableException` when the intent is mid-3DS — callers should retry after the challenge resolves or expires.
|
|
130
|
+
- **`InlinePaymentIntentModel`, `StripePublicConfigModel`** — new GraphQL `@ObjectType` classes (`InlinePaymentIntent`, `StripePublicConfig`). The package does not ship mutations or resolvers (per the "no resolvers" rule); consumers wire a thin wrapper resolver — see the new "Inline PaymentIntent (Stripe Elements)" subsection of the README for the sample.
|
|
131
|
+
- **`PaymentIntentStatusEnum`** — vendor-neutral GraphQL enum (`REQUIRES_PAYMENT_METHOD | REQUIRES_CONFIRMATION | REQUIRES_ACTION | PROCESSING | SUCCEEDED`). Registered as a side effect of `register-payment-enums.ts` under the GraphQL name `PaymentIntentStatus`. The result type's nullable `providerStatus: string` field carries the raw Stripe status verbatim for debug/provider-specific UX.
|
|
132
|
+
- **`StripeGateway.publishableKey: string | null` getter** — read-only access to the configured publishable key. Consumers read this from a Query resolver to populate `StripePublicConfigModel`. Returns `null` when not configured, so the consumer's resolver can return `null` to mean "inline not available; fall back to Checkout-redirect".
|
|
133
|
+
- **`PaymentsModuleOptions.stripe.publishableKey?: string`** — new optional config field. Not validated at boot; first call to `createPaymentIntent` (or any future surface that consumes the publishable key) throws `PublishableKeyNotConfiguredException` if missing. Consumers using only Checkout-redirect never need to set this.
|
|
134
|
+
- **`IPaymentRepository.findReusablePendingByInvoiceId?(invoiceId, provider)`** — new OPTIONAL repository method. Adapters implementing it enable the durable reuse-if-exists path in `createPaymentIntent`; adapters skipping it still get Stripe-level idempotency protection. Optional → non-breaking against v0.2.x adapters.
|
|
135
|
+
- **`StripePaymentIntentGatewayOptions`** + **`STRIPE_PAYMENT_INTENT_RESERVED_GATEWAY_KEYS`** — typed shape and reserved-keys list for the `gatewayOptions` passthrough on `createPaymentIntent`. Distinct from `StripeGatewayOptions` (which targets `createPaymentSession`). Reserved keys: `metadata.invoiceId`, `amount`, `currency`, `automatic_payment_methods`, `confirm`, `payment_method`, `return_url`, `customer`, `off_session`. Reserving `customer` and `off_session` now keeps the future save-card-for-future-use surface clean.
|
|
136
|
+
- **New exceptions:** `PublishableKeyNotConfiguredException` (`PUBLISHABLE_KEY_NOT_CONFIGURED`), `PaymentIntentNotReusableException` (`PAYMENT_INTENT_NOT_REUSABLE`), `PaymentIntentNotCancellableException` (`PAYMENT_INTENT_NOT_CANCELLABLE`), `PaymentIntentOperationNotSupportedException` (`PAYMENT_INTENT_OPERATION_NOT_SUPPORTED`). All extend `PaymentException` with stable `.code` strings — see README "Exceptions".
|
|
137
|
+
- **`GatewayRegistryService.getInlineCapable(providerType)`** + **`InlineCapableGateway` type** — narrows the gateway so `createPaymentIntent` and `cancelPaymentIntent` are non-optional. Throws `PaymentIntentOperationNotSupportedException` when the gateway lacks either method.
|
|
138
|
+
|
|
139
|
+
### Notes
|
|
140
|
+
- **Requires v0.2.1+** for the `payment_intent.succeeded` webhook handler. Inline-PI flows succeed at Stripe but cannot close the local invoice without it — the v0.3.0 README adds the event to the documented Stripe Dashboard subscription list explicitly.
|
|
141
|
+
- **No changes to `prisma/payment-models.prisma`**; no database migrations. Consumers using inline-PI at scale SHOULD add an index on `(invoiceId, provider, status)` on their `Payment` table for the reuse-path query — flagged in the README as a consumer concern.
|
|
142
|
+
- **No breaking changes to existing surfaces.** All v0.3.0 additions are strictly additive: new exports, new optional methods, new optional config field, new optional repo method. Existing custom `PaymentGateway` implementations from v0.2.x continue to satisfy the interface unmodified.
|
|
143
|
+
|
|
144
|
+
### Audit-pass refinements (post-implementation review, same `0.3.0` tag)
|
|
145
|
+
- **`StripeGateway.createPaymentIntent` now validates `amount` and `currency` against the existing intent on the reuse path.** If the invoice's terms drifted between attempts (admin edit applied between modal opens), the stale intent is discarded with a `warn` log rather than silently returning a `client_secret` that would charge the wrong amount.
|
|
146
|
+
- **Stale `providerPaymentId` no longer surfaces as `PaymentGatewayException`.** If `paymentIntents.retrieve` returns `resource_missing` (the stored intent was deleted at Stripe), the gateway logs `warn` and falls through to fresh-create — consumers no longer see a confusing "Stripe rejected the PaymentIntent" error.
|
|
147
|
+
- **`StripeGateway.cancelPaymentIntent` no longer classifies `processing` as `alreadyTerminal: true`.** `processing` and `requires_action` are both in-flight (non-terminal) and now both throw `PaymentIntentNotCancellableException`. A stale-booking cleanup CRON receives a "retry me later" signal instead of "this work is done". `alreadyTerminal: true` is now reserved for `succeeded` and `canceled` only.
|
|
148
|
+
- **`assertNoReservedIntentGatewayOptions` now consumes `STRIPE_PAYMENT_INTENT_RESERVED_GATEWAY_KEYS` directly** instead of carrying a duplicate inline list. A "stays in sync" spec iterates the exported constant and asserts each key is rejected.
|
|
149
|
+
- **`maybeReuseExistingIntent` logs `debug` for unknown future Stripe statuses** before falling through to fresh-create (forward compat).
|
|
150
|
+
- **JSDoc and README polish.** `cancelPaymentIntent` implementation JSDoc gained `@throws`. README's "Inline PaymentIntent" section now exhaustively documents the four-condition reuse criteria, the amount/currency drift behavior, the stale-row fall-through, and the cross-day `attemptId`-omitted idempotency-cache trap (recommendation: always pass `attemptId`). Sample wrapper resolver now imports `PaymentIntentStatusEnum` and uses `as PaymentIntentStatusEnum` instead of `as any`.
|
|
151
|
+
- **+10 specs**, total now 100 across 6 suites.
|
|
152
|
+
|
|
10
153
|
## [0.2.1] - 2026-05-28
|
|
11
154
|
|
|
155
|
+
### ⚠ External configuration required
|
|
156
|
+
|
|
157
|
+
- **Stripe Dashboard:** subscribe your webhook endpoint to `payment_intent.succeeded`. Required for direct-PaymentIntent flows — anything not going through Checkout, including the inline Stripe Elements surface added in 0.3.0 and any consumer calling `paymentIntents.create` directly. Without it those payments succeed at Stripe and the invoice never closes. *(Marker retro-applied in 0.4.0; the requirement itself shipped with this release, stated inside the `Fixed` entry below.)*
|
|
158
|
+
|
|
12
159
|
### Fixed
|
|
13
160
|
- **`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.
|
|
14
161
|
|