awesome-node-checkout 2.0.0 → 2.1.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 (51) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +280 -2
  3. package/dist/adapters/express/index.d.ts +69 -1
  4. package/dist/adapters/express/index.d.ts.map +1 -1
  5. package/dist/adapters/express/index.js +147 -2
  6. package/dist/adapters/express/index.js.map +1 -1
  7. package/dist/checkout-configurator.d.ts +106 -0
  8. package/dist/checkout-configurator.d.ts.map +1 -1
  9. package/dist/checkout-configurator.js +515 -0
  10. package/dist/checkout-configurator.js.map +1 -1
  11. package/dist/events/checkout-event-bus.d.ts +11 -1
  12. package/dist/events/checkout-event-bus.d.ts.map +1 -1
  13. package/dist/events/checkout-event-bus.js.map +1 -1
  14. package/dist/index.d.ts +5 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/interfaces/mandate-provider.interface.d.ts +40 -0
  19. package/dist/interfaces/mandate-provider.interface.d.ts.map +1 -0
  20. package/dist/interfaces/mandate-provider.interface.js +18 -0
  21. package/dist/interfaces/mandate-provider.interface.js.map +1 -0
  22. package/dist/models/checkout-config.model.d.ts +16 -0
  23. package/dist/models/checkout-config.model.d.ts.map +1 -1
  24. package/dist/models/errors.d.ts +1 -1
  25. package/dist/models/errors.d.ts.map +1 -1
  26. package/dist/models/errors.js.map +1 -1
  27. package/dist/models/mandate.model.d.ts +166 -0
  28. package/dist/models/mandate.model.d.ts.map +1 -0
  29. package/dist/models/mandate.model.js +19 -0
  30. package/dist/models/mandate.model.js.map +1 -0
  31. package/dist/providers/nexi/nexi.provider.d.ts +125 -1
  32. package/dist/providers/nexi/nexi.provider.d.ts.map +1 -1
  33. package/dist/providers/nexi/nexi.provider.js +652 -10
  34. package/dist/providers/nexi/nexi.provider.js.map +1 -1
  35. package/dist/providers/paypal/paypal.provider.d.ts +44 -1
  36. package/dist/providers/paypal/paypal.provider.d.ts.map +1 -1
  37. package/dist/providers/paypal/paypal.provider.js +519 -0
  38. package/dist/providers/paypal/paypal.provider.js.map +1 -1
  39. package/dist/providers/satispay/satispay.provider.d.ts +86 -1
  40. package/dist/providers/satispay/satispay.provider.d.ts.map +1 -1
  41. package/dist/providers/satispay/satispay.provider.js +417 -0
  42. package/dist/providers/satispay/satispay.provider.js.map +1 -1
  43. package/dist/utils/redact.d.ts +15 -0
  44. package/dist/utils/redact.d.ts.map +1 -0
  45. package/dist/utils/redact.js +60 -0
  46. package/dist/utils/redact.js.map +1 -0
  47. package/dist/utils/url.d.ts +12 -0
  48. package/dist/utils/url.d.ts.map +1 -0
  49. package/dist/utils/url.js +19 -0
  50. package/dist/utils/url.js.map +1 -0
  51. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ 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
+ ## [2.1.0] - 2026-11-12
9
+
10
+ Recurring payments (mandates): the core contract, configurator methods and Express routes. The built-in providers implement it in #31 (Nexi), #32 (PayPal) and #33 (Satispay). Resolves #30.
11
+
12
+ ### Added
13
+ - **Mandate models** (`src/models/mandate.model.ts`, exported): `MandateSetupRequest`, `MandatePendingState`, `MandateSetupStart`, `Mandate`, `MandateStatus`, `ChargeRequest`, `ChargeResult`, `ChargeStatus`, `MandateSetupOutcome` (with `terminal`), `MandateCallbackInput`, `MandateStatusResult`, `MandateRevokeResult`, and the patterns `MANDATE_REF_PATTERN` (`/^[A-Za-z0-9_-]{5,30}$/`) and `CHARGE_REF_PATTERN` (`/^[A-Za-z0-9-]{2,30}$/`). All are plain JSON: the app persists them, the library keeps no mandate state in memory. Resolves #30.
14
+ - **`IMandateProvider`** (optional provider capability: `createMandate`, `confirmMandate`, `chargeMandate`, `getMandate`, `revokeMandate`) and **`isMandateProvider`**. `IPaymentProvider` is unchanged, so existing custom providers keep compiling. Resolves #30.
15
+ - **`CheckoutConfigurator`**: `supportsMandates`, `createMandate`, `confirmMandate`, `chargeMandate`, `getMandate`, `revokeMandate`. They validate inputs before calling the provider and normalize outcomes fail closed: an unverified outcome is never a success and never terminal; a verified outcome must match the pending setup (`firstCharge.chargeRef === pending.orderId`, `mandate.mandateRef`, `mandate.provider`); an unverified charge "success" and a verified one for another amount or currency become `unknown`, never `failed`; an unverified first charge carries no `providerPaymentId`, `amount` or `currency`; a revoked mandate is not charged; a provider that throws while charging gives `unknown`, whose `raw` (and the `charge.unknown` event's) is only the error's `{ name, message }`, never the thrown object, since SDK errors carry the request headers (bearer token) and body as own properties. `createMandate` returns `success: false` when the provider's `pending` does not match the request, `amount` and `currency` included (minor units, case-insensitive currency; a missing or invalid value never matches), because the first charge's amount check in `confirmMandate` trusts `pending`. `createMandate` requires absolute http(s) `returnUrl`, `cancelUrl` and (when set) `notifyUrl`, so a relative URL is a 400 `INVALID_MANDATE_REQUEST` instead of a provider-side `TypeError`. Resolves #30.
16
+ - **Events** `mandate.setup_started`, `mandate.created`, `mandate.setup_failed`, `mandate.revoked`, `charge.succeeded`, `charge.failed`, `charge.unknown`, `charge.requires_action`. Setup events are emitted only for terminal outcomes, so a forged callback never looks like a failed setup. Charge events carry `orderId = chargeRef` and `paymentId = providerPaymentId`; `data.mandateRef` is on every mandate event; the credential-like `providerMandateId` is never part of an event (scrubbed from the event's copy of `raw` and `error`, where binary values such as a `Buffer` become `'[binary]'` since they cannot be scrubbed; every setup event, `mandate.setup_started`, `mandate.created`, `mandate.setup_failed` and the first charge's `charge.*`, also scrubs `pending.providerRef`, the Satispay token id, from the whole payload, `raw`, `error` and `data` included, unless it is the `orderId`, the `mandateRef` or the first charge's `providerPaymentId`). Events can repeat for the same `orderId` (redirect plus notification, replays): listeners must be idempotent on it. **Listener errors on the events emitted after a provider call** (`mandate.created`, `mandate.setup_failed`, `mandate.revoked`, `charge.*`) never reject `confirmMandate`, `chargeMandate` or `revokeMandate`: the result is returned and the error goes to the new `CheckoutConfig.onListenerError(err, event)` hook, or to `console.error` without it. Every other event, `mandate.setup_started` included, still propagates `CheckoutListenerError`. Resolves #30.
17
+ - **`CheckoutErrorCode`** values `MANDATES_NOT_SUPPORTED` (HTTP 422 through the router) and `INVALID_MANDATE_REQUEST`. Resolves #30.
18
+ - **Express Adapter**: `mandates` option (`buildMandateRequest`, `onStarted`, `loadPending`, `onOutcome`, all required, else `INSECURE_CONFIGURATION` at mount; optional `middleware`) mounting `POST /:provider/mandate` and `GET|POST /:provider/mandate/callback`. The callback is always exempt from the shared `middleware`; `onStarted` runs before the response; the response never carries `pending` or `raw`; the pending state is single-use (documented contract of `loadPending` / `onOutcome`). `GET /:provider/mandate` answers 405 instead of reaching the details route. No route exists for charges or revocations. Exported type `ExpressMandateOptions`. Resolves #30.
19
+ - **`appendMandateRef(url, mandateRef)`** (exported): adds or replaces the `mandate_ref` query parameter, keeping the query and the hash. Resolves #30.
20
+ - **Nexi XPay mandates**: `NexiProvider` implements `IMandateProvider` (EUR only, since `pagamentoRicorrente` accepts only `978`). `createMandate` builds the hosted-page first payment that registers the contract (`num_contratto` = `mandateRef`, `tipo_servizio=paga_multi`, `tipo_richiesta=PP`, `codTrans` = `orderId`, `mandate_ref` appended to `url`, `url_back` and `urlpost`). `confirmMandate` verifies the 7-field outcome MAC and binds the outcome to the pending setup (MAC-covered `codTrans`, `importo`, `divisa`; the unsigned `num_contratto`, `tipo_servizio` and `tipo_richiesta` only as tampering indicators): a forged or mismatched outcome is unverified and non-terminal, OK is an active mandate with a `succeeded` first charge, `KO`, `ANNULLO` and `ERRORE` a terminal refusal, and any other `esito` (e.g. pending) unverified and non-terminal. Deviation from the issue's "if present" wording: an OK outcome without `num_contratto` is unverified and non-terminal, because the contract parameters of the `approvalUrl` are not MAC-covered and, without the echo, no contract may exist (Nexi's own `pagamento-ricorrente` samples read `num_contratto` from the outcome; to be confirmed against `int-ecommerce.nexi.it` before release). A MAC-verified `KO` is terminal, so the terminal must allow a single payment attempt per `codTrans` (XPay can be configured for up to 3, sending a KO and then an OK for the same `codTrans`); the `urlpost` notification must be answered with HTTP 200 (manual, pp. 22-23 and 33), because a terminal set to void on notification failure cancels the authorization otherwise. Also to be confirmed against `int-ecommerce.nexi.it` before release: whether XPay counts a 3xx answer to `urlpost` as a failed notification, and how a multi-attempt terminal notifies a KO followed by an OK. `chargeMandate` posts `pagamentoRicorrente` (`codiceTransazione` = `chargeRef`, MAC with an empty `scadenza=`), after a `situazioneOrdine` replay guard that never charges a paid `chargeRef` twice (any paid report entry for it counts) and charges nothing when the lookup fails; deviation from the issue's "`other` → `failed`", which was written without the manual's state list (v20.4 p. 622): only `Negato`, `Annullato` and `Non Creato` make an existing `chargeRef` a verified `failed`, `In Corso` (still being executed), an unrecognised or missing `stato` is `unknown` (a `failed` there would send the app to dunning with a new `chargeRef` while the original could still end `Autorizzato`, a double charge), and `Contabilizzato Parz.`, `Rimborsato` and `Rimborsato Parz.` count as charged (`succeeded`, `raw.stato` set; to be confirmed against `int-ecommerce.nexi.it` before release); an amount over 8 digits of cents is `failed` without calling Nexi; error 9 is re-read; credential, MAC and technical errors (1, 3, 4, 5, 7, 12, 50, 97-100, and a KO without a numeric code) are `unknown`; 8, 14 and 22 are `failed` with `mandateUnusable`; timeouts, non-2xx and non-JSON bodies are `unknown`, without the body in `error`; response MACs are checked in constant time (a missing one is accepted only on KO). `getMandate` reads `dettagliContratto` (`statoPrimoPag` KO, not found or invalid contract → `revoked`), `revokeMandate` calls `cancellaContratto` (not found or invalid contract count as revoked). The existing `refundPayment(chargeRef, amount)` refunds a mandate charge. Resolves #31.
21
+ - **`NexiProviderConfig.timeoutMs`** (default 30000, `AbortSignal.timeout` on the recurring and contract API calls) and **`contractType`** (`'S'` / `'U'`, sent as `tipo_contratto` and included in the setup MAC, for terminals that do both scheduled and unscheduled contracts). Resolves #31.
22
+ - **PayPal mandates via Vault** (`PayPalProvider implements IMandateProvider`): the PayPal wallet is saved during the first purchase (Orders v2, `store_in_vault: ON_SUCCESS`, `usage_type: MERCHANT`, `usage_pattern: SUBSCRIPTION_PREPAID`), with no PayPal Subscriptions, billing plans or setup tokens. Resolves #32.
23
+ - `createMandate`: one order for the first charge (`invoice_id`/`reference_id` = `orderId`, `custom_id` = `mandateRef`, request id `setup-<orderId>`); `pending.providerRef` is the order id, `approvalUrl` its `payer-action` link; `notifyUrl` is ignored.
24
+ - `confirmMandate`: a `token` other than `pending.providerRef` is refused without any API call; the order is re-read (`getOrder`) **before** capturing and must match `custom_id`, `invoice_id`, amount and currency, else a terminal failure that is never captured; `APPROVED` is captured with request id `capture-<orderId>` (`ORDER_ALREADY_CAPTURED` re-reads the order), `COMPLETED` is used as is (idempotent re-confirm with `{}`), `CREATED`/`SAVED`/`PAYER_ACTION_REQUIRED` are verified but non-terminal (`ORDER_NOT_APPROVED`), `VOIDED` is terminal; read errors and capture errors are non-terminal with an `unknown` first charge. The mandate is `providerMandateId` = vault token id and `providerData.customerId`; a capture without a `VAULTED` token is a terminal `MANDATE_NOT_VAULTED` that still reports the first charge. Outcomes without a mandate (`MANDATE_NOT_VAULTED`, a non-matching order, `VOIDED`, not approved) scrub the vault token id from `raw` (`'[redacted]'`), so it never reaches `mandate.setup_failed`. `firstCharge.providerPaymentId` is the order id, so `refundPayment` works on it.
25
+ - `chargeMandate`: a merchant-initiated order on the vault id (`stored_credential` `MERCHANT`/`SUBSEQUENT`/`SUBSCRIPTION_PREPAID`), request id and `invoice_id` = `chargeRef`. `COMPLETED` → `succeeded` (amount and currency from the capture), `PAYER_ACTION_REQUIRED` → `requires_action`, capture `DECLINED`/`FAILED` and other 4xx → `failed` with PayPal's issue as `providerCode`, 408, 409 and `PREVIOUS_REQUEST_IN_PROGRESS` (a replay while the original request is still in progress, which may still charge) → `unknown`, 422 `DUPLICATE_INVOICE_ID` (a replay after the 6-hour request-id window) → `unknown`, 401/403/429 → `unknown` with `providerCode` `'401'`/`'403'`/`'429'`, 5xx and network errors → `unknown`. Error results carry only PayPal's error body in `raw`, never the SDK error (request headers).
26
+ - `getMandate` reads the vault token (200 `active`, 404 `revoked`, else `unknown` unverified); `revokeMandate` deletes it (2xx and 404 → `revokedAtProvider: true`).
27
+ - New `PayPalProviderConfig.customerType` (`'CONSUMER'` | `'BUSINESS'`, default `'CONSUMER'`).
28
+ - **Satispay mandates** (`SatispayProvider` implements `IMandateProvider`): Automatic Payments, pre-authorized payment tokens, web-redirect flow, EUR only. `createMandate` creates the authorization (`POST /pre_authorized_payment_tokens`, `Idempotency-Key: setup-<orderId>`) with `callback_url` = `notifyUrl` or `serverUrl` + `mandateCallbackPath` plus `mandate_ref` and a raw (not percent-encoded) `token_id={uuid}`, and returns its `redirect_url` as `approvalUrl`; `cancelUrl` is unused. `confirmMandate` binds the callback `token_id` to `pending.providerRef`, re-reads the token server-to-server (`PENDING` verified non-terminal `AUTHORIZATION_PENDING`; `CANCELED` terminal `success: false` with no mandate, after replaying the first payment with the same body and `Idempotency-Key = orderId`: a 4xx gives a verified `failed` first charge ('Authorization canceled'), Satispay's original `ACCEPTED` payment a verified `succeeded` one with error 'Authorization canceled after the first charge' (refund it or keep it as a one-off), and 401/403/408/409/429, 5xx or a network error a non-terminal, unverified outcome with an `unknown` first charge; `ACCEPTED` activates the mandate and makes the first charge with `Idempotency-Key = orderId`, so a second confirm never charges twice). `chargeMandate` makes a synchronous `PRE_AUTHORIZED` payment with `Idempotency-Key = chargeRef` (`ACCEPTED` → `succeeded`; other 4xx → `failed` with `providerCode`, and `mandateUnusable` when the token reads `CANCELED` or 404; 401/403/429, 408/409 (a same-key request possibly still in flight), 5xx, timeouts and a mismatching `external_code` → `unknown`). `getMandate` maps the token status; `revokeMandate` makes no network call (`revokedAtProvider: false`: Satispay documents no merchant-side revocation). New `SatispayProviderConfig` options `timeoutMs` (default 30000, mandate calls only) and `mandateCallbackPath` (default `/checkout/satispay/mandate/callback`). Resolves #33.
29
+
30
+ ### Changed
31
+ - **`@paypal/paypal-server-sdk` raised from `^2.2.0` to `^2.5.0`** (`VaultController`, vault and stored-credential models, `paypalRequestId` on `createOrder`/`captureOrder`). Resolves #32.
32
+
33
+ ### Security
34
+ - **Express Adapter**: the shared `middleware` chain honours `next(err)`. Previously the argument was dropped, so a middleware that rejects with `next(err)` (for example express-jwt's `UnauthorizedError`) let the request through to the route; now the error goes to Express's error handling and the route is not reached. `next('router')` leaves the checkout router; `next('route')` continues with the next middleware, as for `router.use`. Present since 1.0.0; `mandates.middleware`, `refund.middleware`, `execute.middleware` and `details.middleware` were not affected.
35
+
36
+ ### Documentation
37
+ - README: "Recurring payments (mandates)" section (model, configurator guarantees, Express example, single-use pending state, `mandate_ref` rationale, renewal job example, provider prerequisites), mandate rows in the Routes table (including `GET /:provider/mandate` → 405), mandate events (repeats, listener errors). Resolves #30.
38
+ - README and JSDoc: resolving an `unknown` first charge. A terminal setup outcome can report `firstCharge.status === 'unknown'`, even with `success: true` (a PayPal capture still `PENDING`, a Satispay first payment that timed out). The app keeps the consumed pending state and re-runs `checkout.confirmMandate(provider, pending, {})` from a job; it must **never** call `chargeMandate` with the setup's `orderId`, which on PayPal creates a new order (a double charge, or `unknown` forever with `DUPLICATE_INVOICE_ID`). "Retry `unknown` with the same `chargeRef`" now applies explicitly to `chargeMandate` charges only. Providers must keep `confirmMandate(pending, {})` safe to re-run after a terminal outcome (re-read or replay their own idempotency keys, never a second first charge). Listeners dedupe on the event name and `orderId`, since a resolved charge emits `charge.unknown` then `charge.succeeded` or `charge.failed` for the same `orderId`. Resolves #30.
39
+ - README: "Nexi XPay" subsection of "Recurring payments" (terminal enablement and `contractType`, EUR only, `urlpost` form POST needs `express.urlencoded()`, a dunning retry needs a new `chargeRef`, the 12-month contract expiry and the 1-cent charge plus refund that keeps a yearly plan alive, the `urlpost` that must get HTTP 200, the terminal setting "one payment attempt per `codTrans`", what a terminal that voids or keeps the transaction on a failed notification means for the app, and the customer's redirect that answers 404 once the `urlpost` has consumed the setup). The Express example's `onOutcome` redirects only `GET` requests, so a server-to-server POST gets the route's `200 { success }`. Resolves #31.
40
+ - README: "PayPal (Vault)" subsection of "Recurring payments": the reference-transactions prerequisite and the "Block accidental payments" (one payment per invoice ID) preference, the confirm flow, the `MANDATE_NOT_VAULTED` case and what the app should do (refund or keep the first charge as a one-off, start a new setup), the replay semantics (within and after PayPal's 6-hour request-id window, `DUPLICATE_INVOICE_ID`, and a replay while the original is still in progress: `unknown`, retry with the same `chargeRef`), and that revocation deletes the vault token. Resolves #32.
41
+ - README: "Satispay" subsection of "Recurring payments" (merchant enablement prerequisite, EUR only, unused `cancelUrl`, customer-side-only revocation, idempotency replay semantics and key retention, no WebView for `redirect_url`, what a canceled authorization reports after the replayed first payment and what to do with a first charge taken before the cancellation, the behaviours to check on the sandbox before going live). Resolves #33.
42
+
8
43
  ## [2.0.0] - 2026-11-12
9
44
 
10
45
  Every permissive default of `createCheckoutRouter` becomes an explicit, named opt-in. Resolves #29.
package/README.md CHANGED
@@ -188,6 +188,11 @@ A full-featured reference implementation using Express 5, SQLite (`ITransactionS
188
188
  | GET | `/:provider/:id` | Get payment details (*opt-in*: mounted only when `details` option is set; protect it with `details.middleware`)|
189
189
  | POST | `/:provider/refund` | Refund a payment (*opt-in*: mounted only when `refund` option is set)|
190
190
  | GET / POST | `/:provider/webhook` | Handle provider webhook (POST for Nexi/Satispay, GET for Satispay)|
191
+ | POST | `/:provider/mandate` | Start a mandate setup (*opt-in*: mounted only when `mandates` is set; built by `buildMandateRequest`). See [Recurring payments](#recurring-payments-mandates)|
192
+ | GET | `/:provider/mandate` | `405` with `Allow: POST` (*opt-in*, with `mandates`): never falls through to the details route|
193
+ | GET / POST | `/:provider/mandate/callback` | Confirm a mandate setup (*opt-in*, with `mandates`; always exempt from `middleware`)|
194
+
195
+ There is no route for mandate charges or revocations: call `chargeMandate` / `revokeMandate` from a server job.
191
196
 
192
197
  ---
193
198
 
@@ -199,7 +204,7 @@ Since 2.0.0 every permissive behaviour of `createCheckoutRouter` is an explicit,
199
204
  2. **`GET /:provider/:id` is opt-in.** The details route returns `success`, `status`, `amount`, `currency` and `paymentId` for any id it is given (for Nexi, the id is your order code). It is mounted only when `details` is set: `details: { middleware: [requireAuth] }`. `details: {}` mounts it unprotected and prints one `console.warn`. The old `details: { enabled: false }` is deprecated and behaves as omitted.
200
205
  3. **Generated order ids are unpredictable.** When `orderId` is omitted, Nexi (`codTrans`) and Satispay (`external_code`) use `ORD-` + 24 random hex chars instead of `ORD-<timestamp>`, so ids cannot be enumerated and do not collide.
201
206
  4. **Amounts are exact, in the currency's minor units.** Amounts are checked against the ISO 4217 exponent (0 for JPY, KRW, CLP…; 3 for KWD, BHD, JOD…; 2 otherwise) and never rounded: `19.999 EUR` or `10.5 JPY` fails with `INVALID_AMOUNT` instead of charging a rounded amount. The router answers `400 { success: false, code: 'INVALID_AMOUNT', error }` without calling the provider; a provider called directly returns `{ success: false, status: 'FAILED', error }`. PayPal formats payment and refund amounts with the exponent of the order's currency: it receives `'1000'` for 1000 JPY and `'1.200'` for 1.2 KWD. Nexi and Satispay accept only currencies with 2 decimal places: `createPayment` in JPY, KWD or another 0- or 3-decimal currency (including Nexi numeric codes such as `392`) returns `{ success: false, status: 'FAILED', error }`, because their outcome amounts and refund amounts are in hundredths. A refund `amount` of `0` (or `null`) fails like any invalid amount; only an omitted amount refunds in full.
202
- 5. **500 bodies are generic.** An unexpected error (a throwing `buildPaymentRequest`, `onBeforeExecute`, `redirect.onSuccess`/`onFailure`, event listener, or provider) answers `500 { success: false, error: 'Internal server error' }`. The error goes to `onError(err, req)`, or to `console.error` when `onError` is not set. `exposeErrorMessages: true` restores the message in the body (for development). `CheckoutError` responses (4xx) keep their message and code and are not passed to `onError`.
207
+ 5. **500 bodies are generic.** An unexpected error (a throwing `buildPaymentRequest`, `onBeforeExecute`, `redirect.onSuccess`/`onFailure`, event listener (except the mandate events emitted after a provider call, see [Events](#events)), or provider) answers `500 { success: false, error: 'Internal server error' }`. The error goes to `onError(err, req)`, or to `console.error` when `onError` is not set. `exposeErrorMessages: true` restores the message in the body (for development). `CheckoutError` responses (4xx) keep their message and code and are not passed to `onError`.
203
208
 
204
209
  | Option | Default | Effect |
205
210
  |--------|---------|--------|
@@ -214,6 +219,265 @@ Since 2.0.0 every permissive behaviour of `createCheckoutRouter` is an explicit,
214
219
 
215
220
  ---
216
221
 
222
+ ## Recurring payments (mandates)
223
+
224
+ *Since 2.1.0.* A **mandate** is the customer's saved consent to be charged again without being present: a monthly or yearly subscription that renews until the customer cancels. The core contract is `IMandateProvider` (optional, next to `IPaymentProvider`), the `CheckoutConfigurator` mandate methods and two Express routes.
225
+
226
+ ### The model
227
+
228
+ 1. **Primitives only.** The library creates, confirms, charges, reads and revokes mandates. **Your app owns** scheduling (renewal dates), retries and dunning, subscription state, prices, VAT and invoices. The library never stores a timer or a schedule and knows nothing of plans or tiers.
229
+ 2. **Every later charge is merchant-initiated (MIT)**, for every provider: your app decides when and how much to charge. PayPal uses Vault, not PayPal Subscriptions or billing plans, so a price change needs no PayPal-side plan revision.
230
+ 3. **No in-memory state.** Every method returns plain JSON objects that you persist (`MandatePendingState`, `Mandate`) and pass back later. A restart or a second instance loses nothing.
231
+ 4. **Setup always includes the first charge** (Nexi first payment, PayPal vault-with-purchase, Satispay first `PRE_AUTHORIZED` payment). There are no zero-amount setups and no trials. `MandateSetupOutcome` reports the mandate (`success`, `mandate`) and the first charge (`firstCharge`) separately, because one can succeed without the other.
232
+ 5. **Idempotency.**
233
+ - Every charge carries a `chargeRef` that you derive, unique per intended charge and stable across retries, for example `` `${subscriptionId}-${periodStart}` `` (`/^[A-Za-z0-9-]{2,30}$/`, no underscore).
234
+ - Retry an `unknown` charge **made by `chargeMandate`** with the **same** `chargeRef`. A **dunning retry** after a `failed` charge is a new intended charge with a **new** `chargeRef` (for example `…-r1`).
235
+ - The first charge of a setup uses `chargeRef = orderId`, but it is **not** a `chargeMandate` charge: **never call `chargeMandate` with the setup's `orderId`.** On PayPal that creates a second order (the setup used the request ids `setup-<orderId>` and `capture-<orderId>`), so it either charges the customer twice or stays `unknown` forever with `DUPLICATE_INVOICE_ID`.
236
+ - **An `unknown` first charge is resolved with `confirmMandate`.** A terminal outcome can carry `firstCharge.status === 'unknown'`, even with `success: true` and an active mandate: the PayPal capture is `PENDING`, or the Satispay first payment timed out or got a 5xx. Keep the consumed pending state, and re-run `checkout.confirmMandate(provider, pending, {})` from a server job until a **terminal** outcome reports a `firstCharge.status` other than `unknown`. Do this directly, not through the callback route, which keeps answering 404. See [Resolving an unknown first charge](#resolving-an-unknown-first-charge).
237
+ - `mandateRef` (`/^[A-Za-z0-9_-]{5,30}$/`) and `orderId` are **fresh for every setup attempt**. After a failed or abandoned setup, start a new one with new values: reusing them would replay the provider's idempotency keys and order codes.
238
+ 6. **Unknown outcomes are explicit.** A charge that times out, gets a 5xx or an unparseable body, or is refused for a reason that is not about the customer (HTTP 401, 403, 429, an authentication or MAC error, rate limiting) ends with `status: 'unknown'`, never `failed`: an expired credential must not push every subscriber into dunning. `failed` means an authoritative refusal of this specific charge. The library never retries on its own.
239
+ 7. **Fail closed.** `verified` is `true` only for outcomes authenticated by a MAC or signature checked by the library, or by an authenticated server-to-server response. Identities (contract number, vault id, token id, order id) come from the persisted `MandatePendingState` / `Mandate` or from authenticated fields, never from query strings or unsigned callback fields. On the mandate, unsigned hints (card expiry, masked PAN, payer email) are only under `mandate.unverified`; the provider `raw` of outcomes and events may still contain them.
240
+ 8. **Only creation and callbacks are routes.** `chargeMandate` and `revokeMandate` are never mounted: charges are a server job, and an HTTP route would be a charge-anyone endpoint. Creation requires the server-side `buildMandateRequest`, like `buildPaymentRequest`.
241
+
242
+ ### What the configurator guarantees
243
+
244
+ | Method | Behaviour |
245
+ |--------|-----------|
246
+ | `supportsMandates(provider)` | `true` when the provider implements `IMandateProvider` (`isMandateProvider`). |
247
+ | `createMandate(provider, request)` | Validates first (`INVALID_MANDATE_REQUEST` for `mandateRef`, `orderId`, `description` of 1-127 chars, a `returnUrl`/`cancelUrl` (or a `notifyUrl`, when set) that is not an absolute `http(s)` URL; `INVALID_AMOUNT` from the amount) without calling the provider. A start whose `pending` does not match the request (`provider`, `mandateRef`, `orderId`, and `amount` and `currency`, compared in minor units and case-insensitively; a missing or invalid one never matches) is returned as `success: false`, since `confirmMandate` checks the first charge's amount against `pending`. Emits `mandate.setup_started` (`verified: false`) on success. |
248
+ | `confirmMandate(provider, pending, input)` | Unverified outcome → `success: false`, `terminal: false`, no `mandate`, a `succeeded` first charge becomes `unknown`. A verified outcome whose `firstCharge.chargeRef` or `mandate` does not match `pending` → unverified, non-terminal failure `'Mandate outcome does not match the pending setup'`. `terminal` is the provider's value AND `verified` (always `true` with `success: true`). Events **only for terminal outcomes**: `mandate.created` or `mandate.setup_failed`, then `charge.*` for the first charge. An unverified outcome's `firstCharge` carries no `providerPaymentId`, `amount` or `currency`. Re-callable with `{}` and the consumed `pending` to resolve an `unknown` first charge (see below). |
249
+ | `chargeMandate(provider, mandate, charge)` | Validates `chargeRef` and the amount. A `revoked` mandate returns `failed` with `mandateUnusable: true` without calling the provider. An unverified `succeeded` becomes `unknown` (never `failed`). A verified `succeeded` whose reported amount or currency differs from the request becomes `unknown` with `'Charged amount does not match the request'`. A provider that throws gives `unknown`, whose `raw` (and the `charge.unknown` event's) is only the error's `{ name, message }`, never the SDK request, headers or body. `chargeRef` is always the request's. Emits `charge.succeeded`, `charge.failed`, `charge.unknown` or `charge.requires_action`; a throwing listener never rejects it (see below). |
250
+ | `getMandate(provider, mandate)` | Re-reads usability from the provider: `{ status: 'active' \| 'revoked' \| 'unknown', verified }`. Call it before charging when in doubt. |
251
+ | `revokeMandate(provider, mandate)` | Calls the provider and emits `mandate.revoked` with `data: { mandateRef, revokedAtProvider }`. **Mark your own copy revoked whatever the provider answers.** |
252
+
253
+ Mandate methods throw `CheckoutError('MANDATES_NOT_SUPPORTED')` (HTTP 422 through the router) for a provider without mandate support, and `INVALID_MANDATE_REQUEST` for a `pending` or `mandate` of another provider.
254
+
255
+ **Events.** For charge events `paymentId` is the `providerPaymentId` and `orderId` is the `chargeRef`; every mandate and charge event has `data.mandateRef`. The credential-like `providerMandateId` (PayPal vault id, Satispay token id) is **never** part of an event: it is scrubbed from the event's copy of the whole payload (`raw`, `error`, `data`), and binary values in that copy (`Buffer`, typed arrays, `ArrayBuffer`) become `'[binary]'`, since they cannot be scrubbed. Every setup event (`mandate.setup_started`, `mandate.created`, `mandate.setup_failed`) and the first charge's `charge.*` likewise scrub `pending.providerRef` (the Satispay token id, even when the setup failed and no mandate is returned; the PayPal order id), unless it is the `orderId` or `mandateRef` (Nexi `codTrans`) or the first charge's `providerPaymentId` (PayPal, where the order id refunds the charge) and not the `providerMandateId` itself. Nothing is scrubbed when the id is the app's own `mandateRef` (Nexi), so Nexi events keep binary `raw` as is. The values returned by the methods keep the full provider `raw`.
256
+
257
+ - **Events can repeat.** The redirect and the server notification (Nexi `urlpost`, Satispay callback) can confirm the same setup concurrently, and a replayed callback or a same-`chargeRef` retry runs again: `mandate.created`, `mandate.setup_failed` and `charge.*` can be emitted more than once for the same `orderId` (= `chargeRef`). Listeners that book revenue or extend a subscription must be idempotent on `orderId`, like `onOutcome`. An `unknown` charge that is later resolved emits `charge.unknown`, then `charge.succeeded` or `charge.failed`, for the same `orderId`: dedupe on the event name and `orderId`, not on `orderId` alone.
258
+ - **Listener errors never hide a result that moved money.** `confirmMandate`, `chargeMandate` and `revokeMandate` emit after the provider call; a listener that throws there does **not** reject the method: the normalized result is returned, the remaining events are still emitted, and the error (a `CheckoutListenerError`) goes to `new CheckoutConfigurator({ onListenerError: (err, event) => … })`, or to `console.error` without that hook. Through Express, the callback still runs `onOutcome`. `mandate.setup_started` keeps the general rule (the error propagates): no money has moved yet.
259
+
260
+ ### Express
261
+
262
+ ```typescript
263
+ app.use(express.json());
264
+ app.use(express.urlencoded({ extended: false })); // Nexi urlpost arrives as a form POST
265
+
266
+ app.use('/checkout', createCheckoutRouter(checkout, {
267
+ buildPaymentRequest,
268
+ middleware: [requireAuth], // never applied to /:provider/mandate/callback
269
+ mandates: {
270
+ // Server-side: price from your plan, ids from your DB, fresh for every attempt
271
+ buildMandateRequest: async (req, { provider }) => {
272
+ const sub = await subscriptions.startCheckout(req.user.id, req.body.planId);
273
+ const callback = `https://myapp.com/checkout/${provider}/mandate/callback`;
274
+ return {
275
+ mandateRef: sub.mandateRef, // e.g. 'MNDT' + 10 random digits
276
+ orderId: sub.firstOrderId, // e.g. `${sub.id}-INIT`
277
+ amount: sub.plan.price, // e.g. 19.9
278
+ currency: 'EUR',
279
+ description: `${sub.plan.name} subscription`,
280
+ returnUrl: callback, // MUST be the callback route
281
+ cancelUrl: callback, // MUST be the callback route
282
+ notifyUrl: callback, // Nexi urlpost, Satispay callback_url
283
+ };
284
+ },
285
+ // Persist the pending state BEFORE the response is sent
286
+ onStarted: async (start, req) => {
287
+ if (start.success && start.pending) await pendings.insert({ ...start.pending, state: 'pending' });
288
+ },
289
+ // Single-use: null once consumed (→ 404), so a replay never reaches the provider
290
+ loadPending: (req, { provider, mandateRef }) =>
291
+ pendings.findOne({ provider, mandateRef, state: 'pending' }),
292
+ onOutcome: async (outcome, req, res) => {
293
+ const mandateRef = String(req.query.mandate_ref);
294
+ if (outcome.terminal) {
295
+ // Atomic conditional update: act only if it matched
296
+ const consumed = await pendings.updateOne(
297
+ { mandateRef, state: 'pending' },
298
+ { state: 'done', firstCharge: outcome.firstCharge.status, setupSuccess: outcome.success },
299
+ );
300
+ if (consumed && outcome.mandate) await mandates.save(outcome.mandate);
301
+ if (consumed) await subscriptions.recordFirstCharge(mandateRef, outcome.firstCharge);
302
+ // firstCharge 'unknown': keep the row; a job re-confirms it (never chargeMandate with orderId)
303
+ }
304
+ // A non-terminal outcome leaves the pending state in place for the genuine callback
305
+ // Redirect only the customer's browser (GET). A server-to-server POST (Nexi urlpost) must get
306
+ // HTTP 200: do not respond, and the route answers 200 { success }
307
+ if (req.method === 'GET') {
308
+ res.redirect(302, outcome.success ? '/account/billing?ok=1' : '/account/billing?error=1');
309
+ }
310
+ },
311
+ },
312
+ }));
313
+ ```
314
+
315
+ - `POST /checkout/:provider/mandate` (behind `options.middleware` and `mandates.middleware`) runs `buildMandateRequest`, `checkout.createMandate`, then **awaits `onStarted`**, then answers `201 { success: true, approvalUrl }` (never `pending`, never `raw`). Send the customer to `approvalUrl`. `onStarted` also receives failed starts (`start.success === false`). `GET /checkout/:provider/mandate` answers `405` (`Allow: POST`).
316
+ - `GET|POST /checkout/:provider/mandate/callback` is **exempt from the shared `middleware`, whatever `publicPaths` says**: Nexi `urlpost`, the Satispay callback and the customer's browser reach it without your credentials. **`returnUrl`, `cancelUrl` and `notifyUrl` must point to this route** (`<mount>/<provider>/mandate/callback`), as **absolute** `http(s)` URLs (a relative URL answers `400 INVALID_MANDATE_REQUEST`); the provider appends `mandate_ref`, which the route validates (400), passes to `loadPending` (`null` → 404), then calls `checkout.confirmMandate` and `onOutcome`. If `onOutcome` does not respond, the route answers `200 { success }`.
317
+ - All four hooks are required: without one, `createCheckoutRouter` throws `INSECURE_CONFIGURATION` at mount.
318
+
319
+ **Pending state is single-use.** When `outcome.terminal` is true, `onOutcome` must atomically mark the pending state consumed and persist the mandate (a conditional update such as `pending → done`, acting only if it matched). From then on `loadPending` must return `null`, so a replayed callback answers 404 and never reaches the provider again. This matters most for Satispay, where a replay would re-run the first charge, protected only by an idempotency key of undocumented retention. Non-terminal outcomes (unverified, still pending, network error) must not consume the pending state. Expire stale pending states after a TTL of your choice, for example 24 hours. **Consumed means closed to the callback route, not deleted:** keep the consumed record while its first charge is `unknown`, since that is the input to the re-confirm job below.
320
+
321
+ **Why an unsigned `mandate_ref` is safe.** `mandate_ref` comes from the query string and is used **only** as a lookup key. Every provider verifies the callback against the persisted `pending` (the MAC-covered Nexi `codTrans`, the PayPal order id, a server-to-server re-read of the Satispay token), never against the query. A forged `mandate_ref` therefore leads at most to confirming another customer's genuinely approved setup, idempotently.
322
+
323
+ Without Express, call the same methods from your own routes: `createMandate` then persist `pending`; on the callback, load `pending` by `mandate_ref` and call `confirmMandate(provider, pending, { query, body, headers, context: { method, path, rawBody } })`.
324
+
325
+ ### Resolving an unknown first charge
326
+
327
+ A terminal setup outcome can report `firstCharge.status === 'unknown'`. With `success: true` the mandate is active and must be saved, but nobody knows yet whether the first payment was taken. With `success: false` (for example PayPal `MANDATE_NOT_VAULTED` after a `PENDING` capture) there is no mandate, and the money may still have been taken. Either way the callback route is closed (the pending state is consumed) and the library does not resolve the charge on its own. Your app does, with a server job:
328
+
329
+ ```typescript
330
+ const toManual = async (mandateRef: string) => {
331
+ // Leaves the query for good: the job never calls the provider for it again
332
+ if (await pendings.updateOne({ mandateRef, firstCharge: 'unknown' }, { firstCharge: 'manual' })) {
333
+ await subscriptions.reconcileManually(mandateRef);
334
+ }
335
+ };
336
+
337
+ for (const { state, firstCharge, setupSuccess, ...pending } of await pendings.find({ state: 'done', firstCharge: 'unknown' })) {
338
+ // Check the age BEFORE calling the provider. Per provider: PayPal can wait, Satispay cannot (see below)
339
+ if (tooOld(pending)) { await toManual(pending.mandateRef); continue; }
340
+
341
+ const outcome = await checkout.confirmMandate(pending.provider, pending, {}); // no callback input
342
+ if (!outcome.terminal || outcome.firstCharge.status === 'unknown') continue; // try again on the next run
343
+ // The setup itself changed since (e.g. Satispay authorization canceled): the saved mandate is gone
344
+ if (outcome.success !== setupSuccess) { await toManual(pending.mandateRef); continue; }
345
+
346
+ // Use firstCharge only: the mandate is already saved, the subscription already started
347
+ const resolved = await pendings.updateOne(
348
+ { mandateRef: pending.mandateRef, firstCharge: 'unknown' },
349
+ { firstCharge: outcome.firstCharge.status },
350
+ );
351
+ if (resolved) await subscriptions.recordFirstCharge(pending.mandateRef, outcome.firstCharge);
352
+ }
353
+ ```
354
+
355
+ - **Never** resolve it with `chargeMandate(provider, mandate, { chargeRef: orderId, … })`: `chargeMandate` starts a new merchant-initiated charge, and only some providers would recognise it as a replay of the first one. The rule "retry `unknown` with the same `chargeRef`" covers charges made by `chargeMandate` only.
356
+ - A re-confirm that is terminal emits its events again (`mandate.created` or `mandate.setup_failed`, then the first charge's `charge.*`): the first run emitted `charge.unknown`, the resolving one emits `charge.succeeded` or `charge.failed`, for the same `orderId`. Dedupe on the event name and `orderId`, not on `orderId` alone, or you drop the resolution.
357
+ - Re-confirm with the persisted `pending`, verbatim, as for the callback. `{}` carries no callback fields, so a provider can only answer from a server-to-server re-read or by replaying its own idempotency keys, never from query values.
358
+ - Per provider, as specified in #31 to #33:
359
+ - **Nexi XPay** never reports a terminal outcome with an `unknown` first charge: the MAC-verified callback is either OK (`succeeded`) or KO (`failed`). A re-confirm with `{}` is unverified and non-terminal, and moves no money.
360
+ - **PayPal** re-reads the order (`getOrder`) and maps its existing capture. A `COMPLETED` order is never captured again, so the job can keep re-confirming a `PENDING` capture for as long as PayPal takes to settle it.
361
+ - **Satispay** re-reads the authorization and replays the first payment with the same `Idempotency-Key` (= `orderId`), which returns Satispay's original response. That key's retention is not documented, so re-confirm promptly (minutes to hours, not days), and reconcile manually in the Satispay dashboard (`external_code` = `orderId`) once the setup is older than that. If the customer cancelled the authorization in the meantime, the re-read is `CANCELED` and the first payment is still replayed with the same key: Satispay's original `ACCEPTED` payment gives `success: false` with a `succeeded` first charge ('Authorization canceled after the first charge'), a 4xx (no payment exists for the key) gives a verified `failed` one, and anything else stays `unknown` and non-terminal. Both terminal cases differ from the first run's `success`, so the job above hands them to manual reconciliation, with `firstCharge` saying whether the money was taken (see the Satispay subsection below).
362
+ - When the job gives up, reconcile in the provider's dashboard by the setup's `orderId` (Nexi `codTrans`, PayPal `invoice_id`, Satispay `external_code`), then record the result yourself.
363
+
364
+ ### Renewal job (no HTTP)
365
+
366
+ Charges are a server job. A daily run might look like this:
367
+
368
+ ```typescript
369
+ for (const sub of await subscriptions.due(new Date())) {
370
+ const mandate = await mandates.load(sub.mandateRef); // the persisted Mandate, verbatim
371
+ const chargeRef = `${sub.id}-${sub.periodStart}`; // same value on every retry of this period
372
+ const result = await checkout.chargeMandate(mandate.provider, mandate, {
373
+ chargeRef: sub.retry ? `${chargeRef}-r${sub.retry}` : chargeRef,
374
+ amount: sub.plan.price,
375
+ currency: 'EUR',
376
+ description: `${sub.plan.name} ${sub.periodStart}`,
377
+ });
378
+
379
+ switch (result.status) {
380
+ case 'succeeded': // money taken: extend the period, issue the invoice
381
+ await subscriptions.renew(sub, result.providerPaymentId);
382
+ break;
383
+ case 'unknown': // do NOT count as a failure: retry with the SAME chargeRef next run
384
+ // (first charges of a setup are re-confirmed instead, see above)
385
+ await subscriptions.markUnknown(sub, result.error);
386
+ break;
387
+ case 'failed': // authoritative refusal: dunning, next attempt gets a NEW chargeRef
388
+ if (result.mandateUnusable) await mandates.markRevoked(sub.mandateRef);
389
+ await subscriptions.dunning(sub, result.providerCode);
390
+ break;
391
+ case 'requires_action': // the customer must act: ask them to set up a new mandate
392
+ await subscriptions.askForAction(sub);
393
+ break;
394
+ }
395
+ }
396
+ ```
397
+
398
+ Only `failed` counts towards dunning. Refund a mandate charge with the existing `refundPayment(provider, result.providerPaymentId, amount)`. To change the card, the customer creates a new mandate.
399
+
400
+ ### Provider prerequisites
401
+
402
+ - **Nexi XPay:** MIT/recurring must be enabled on the terminal profile by Nexi support. Scheduled and unscheduled contracts are not interchangeable: state which one you need.
403
+ - **PayPal:** "Save payment methods" (reference transactions) must be approved on the account. Turn on the payment receiving preference "Block accidental payments" (one payment per invoice ID): with it, a replay after PayPal's 6-hour request-id window cannot charge twice.
404
+ - **Satispay:** pre-authorized payments (Automatic Payments) must be enabled for the shop.
405
+
406
+ Use `checkout.supportsMandates(provider)` to check whether a registered provider implements `IMandateProvider`.
407
+
408
+ ### Nexi XPay
409
+
410
+ `NexiProvider` implements `IMandateProvider` with XPay contracts ("Specifiche tecniche di integrazione con il gateway di pagamento XPay" v20.4): the setup is a hosted-page first payment that registers the contract (`num_contratto` = your `mandateRef`, `tipo_servizio=paga_multi`, `tipo_richiesta=PP`, `codTrans` = `orderId`), renewals use `POST /ecomm/api/recurring/pagamentoRicorrente` (`codiceTransazione` = `chargeRef`), `getMandate` reads `dettagliContratto` and `revokeMandate` calls `cancellaContratto`.
411
+
412
+ ```typescript
413
+ const nexi = new NexiProvider({
414
+ merchantId: process.env.NEXI_ALIAS!,
415
+ macKey: process.env.NEXI_MAC_KEY!,
416
+ environment: 'production',
417
+ contractType: 'S', // only if the terminal does both scheduled and unscheduled contracts
418
+ timeoutMs: 30000, // recurring and contract API calls (default 30000)
419
+ });
420
+ ```
421
+
422
+ - **Terminal.** Recurring payments must be enabled on the terminal by XPay support, stating whether the contracts are **scheduled** or **unscheduled** (MIT): the two are not interchangeable. A merchant whose terminal does both sets `contractType: 'S'` or `'U'`, sent as `tipo_contratto` and included in the setup MAC. Two more terminal settings matter; agree them with XPay support too:
423
+ - **One payment attempt per `codTrans`.** XPay can be configured for up to 3 attempts with the same transaction code, and such a terminal sends several notifications for the same order (manual, pp. 32-33). The library treats a MAC-verified `KO` as a terminal refusal, so the first KO consumes the pending setup; an OK from a later attempt with the same `codTrans` then answers 404, and the customer is charged and a contract exists while the app has no mandate. Use a terminal that allows a single attempt per `codTrans`.
424
+ - **What happens when the `urlpost` notification fails.** XPay either keeps the transaction, leaving the merchant to recover the outcome from the back office, e-mail or API, or voids the authorization without charging the customer (manual, p. 17); the merchant tells support which one. The library requires only that the `urlpost` get HTTP 200 (see **Notification** below). On a terminal that voids, any other answer can void a first payment the app has already recorded as an active, paid mandate. On a terminal that keeps it, a missed `urlpost` is recovered only by the customer's redirect: if the customer never comes back, the first payment and the contract exist at Nexi with no mandate in the app, and a re-confirm with `{}` is non-terminal, so reconcile by `codTrans` in the back office.
425
+ - **Currency: EUR only.** `pagamentoRicorrente` accepts only `divisa=978`, so `createMandate` and `chargeMandate` with any other currency return a failure without calling Nexi.
426
+ - **Callback.** Nexi redirects the customer to `url` / `url_back` with the outcome in the query string, and sends the `urlpost` notification as a **form POST**: mount `express.urlencoded()` before the checkout router, or the notification body is empty and the outcome stays unverified. Both arrive at the callback route and are verified with the 7-field outcome MAC; the MAC-covered `codTrans`, `importo` and `divisa` must match the pending setup. The contract number is always the persisted `mandateRef`, never the query's `num_contratto`; an OK outcome must still echo `num_contratto` equal to it (without it, no contract may have been registered), and a `tipo_servizio` / `tipo_richiesta` other than `paga_multi` / `PP` is rejected; such outcomes are unverified and non-terminal. The validated card expiry (`scadenza_pan`) and masked PAN (`pan`) are copied to `mandate.unverified`; the unfiltered callback fields, these two included, remain in the outcome's `raw` and in the `raw` of the `mandate.created` / `mandate.setup_failed` events, so do not log or persist `raw` where card data must not appear. Only `KO`, `ANNULLO` and `ERRORE` are terminal refusals; any other `esito` (such as a pending one) is unverified and non-terminal, so the pending setup survives until the final outcome arrives. A Nexi first payment is never `unknown` on a terminal outcome.
427
+ - **Notification (`urlpost`) must get HTTP 200.** The manual requires the `urlpost` answer to be an "HTTP 200" to confirm receipt (pp. 22-23 and 33). Do not redirect the POST: the README `onOutcome` redirects only `GET` requests (the customer's browser) and lets the route answer `200 { success }` for the POST. On a terminal set to void on notification failure, every other answer must be assumed to count as a failed delivery: the `404` for a late or replayed `urlpost` whose pending setup was already consumed or expired, a `500` from a hook that throws, and a `302` too (whether XPay treats a 3xx as a failure is not documented; this follows the manual's "must be HTTP 200" wording). Whenever the `urlpost` is confirmed first, it consumes the pending setup, and the customer's browser then reaches the callback route with no pending state and gets the route's `404` JSON instead of your UI. That order is guaranteed on a terminal that voids on notification failure, where the redirect to `url` happens only after the `urlpost` answer (p. 33), and possible on any other.
428
+ - **Retries.** Before every charge the provider reads `situazioneOrdine` for the `chargeRef`: one that took the money (`Autorizzato`, `Contabilizzato`, `Contabilizzato Parz.`, and also `Rimborsato` / `Rimborsato Parz.`) is returned as `succeeded` without charging again, with the `stato` in `result.raw.stato`; one that Nexi refused (`Negato`, `Annullato`, `Non Creato`) as `failed`; one still in progress (`In Corso`) or with any other `stato`, and a lookup that fails, as `unknown` (nothing is charged). A refunded `chargeRef` is `succeeded` because the charge did happen: check `result.raw.stato` before extending a subscription on a retried charge. Retry an `unknown` charge with the same `chargeRef`; an `In Corso` one resolves once Nexi completes it. **A dunning retry after `failed` needs a new `chargeRef`**: Nexi would accept up to 2 retries of the same code after a KO, but the library does not use them. If several report entries share the `chargeRef`, any one that took the money makes it `succeeded`, otherwise any one not final makes it `unknown`, and it is `failed` only when every entry is a refusal. Codes 8, 14 and 22 (invalid contract, card expired, card not chargeable) set `mandateUnusable`; credential, MAC and technical errors are `unknown`. An amount over 8 digits of cents (999999.99 EUR) is `failed` without calling Nexi.
429
+ - **12-month expiry (manual, p. 61).** Contracts unused for more than 12 months expire under card-scheme rules, so a **yearly** plan sits right at the limit. Nexi's remedy is a 1-cent recurring charge followed by a refund before the 12 months run out (`chargeMandate` with a 0.01 EUR charge, then `refundPayment('nexi', result.providerPaymentId, 0.01)`): your scheduler must plan for it.
430
+ - **Refunds.** `providerPaymentId` is the `codiceTransazione` (= `chargeRef`), so the existing `refundPayment` refunds a mandate charge.
431
+
432
+ ### PayPal (Vault)
433
+
434
+ `PayPalProvider` implements `IMandateProvider` with **PayPal Vault, saving the PayPal wallet during the first purchase** (Orders v2). It uses no PayPal Subscriptions, billing plans or setup tokens. Requires `@paypal/paypal-server-sdk` `^2.5.0`.
435
+
436
+ **Prerequisites.**
437
+ - The account must be approved for **reference transactions** ("Save PayPal and Venmo payment methods" in the Payment Preferences). In the sandbox, enable *Vault* under the app's Advanced options. Without it, the setup order is refused or the wallet is not vaulted.
438
+ - Turn on the payment receiving preference **"Block accidental payments"** (one payment per invoice ID). The replay guarantee below depends on it.
439
+ - Optional: `new PayPalProvider({ clientId, clientSecret, customerType: 'BUSINESS' })` (default `'CONSUMER'`) sets the vault customer type.
440
+
441
+ **Setup.** `createMandate` creates an order for the first charge with `store_in_vault: ON_SUCCESS` (`usage_type: MERCHANT`, `usage_pattern: SUBSCRIPTION_PREPAID`). The order's `invoice_id` and `reference_id` are your `orderId`, its `custom_id` is the `mandateRef`, and its request id is `setup-<orderId>`. `pending.providerRef` is the PayPal order id and `approvalUrl` is its `payer-action` link. `notifyUrl` is ignored: PayPal webhooks (`VAULT.PAYMENT-TOKEN.*`) are not used.
442
+
443
+ **Confirm.** `confirmMandate` ignores everything in the callback except `token`, which must be `pending.providerRef` (otherwise it is an unverified, non-terminal failure). It re-reads the order with `getOrder` **before** capturing, and checks `custom_id`, `invoice_id`, the amount and the currency against `pending`. An order that does not match is a terminal failure and is **never captured**. Then, by order status:
444
+ - `APPROVED`: captured once, with the request id `capture-<orderId>`. If a concurrent confirm captured it first (`ORDER_ALREADY_CAPTURED`), the order is re-read.
445
+ - `COMPLETED`: already captured. It is used as it is, so a re-confirm with `{}` never captures twice, even after PayPal's 6-hour request-id window.
446
+ - `CREATED`, `SAVED`, `PAYER_ACTION_REQUIRED`: the customer has not approved, or not yet. This is a verified, **non-terminal** failure (`ORDER_NOT_APPROVED`): a cancel link does not end the setup.
447
+ - `VOIDED`: a terminal failure.
448
+ - Any error reading the order (5xx, network, 401, 403, 429), or a capture call that errors with anything other than `ORDER_ALREADY_CAPTURED`: non-terminal, with the first charge `unknown`.
449
+
450
+ `firstCharge.providerPaymentId` is the **PayPal order id**, which `refundPayment` accepts. A capture still `PENDING` is `unknown`: re-confirm it with `{}` from a job (see [Resolving an unknown first charge](#resolving-an-unknown-first-charge)). `providerMandateId` is the vault payment token id and `providerData.customerId` is the PayPal customer id; the payer email is only a hint under `mandate.unverified`.
451
+
452
+ **`MANDATE_NOT_VAULTED`.** If the order was captured but PayPal did not report the vault token as `VAULTED` (status `APPROVED`, `CREATED`, or no vault at all), the outcome is terminal with `success: false` and `error: 'MANDATE_NOT_VAULTED'`, and **`firstCharge` still reports the capture**: the customer may have paid. Check `firstCharge.status`. When it is `succeeded`, either refund it with `checkout.refundPayment('paypal', outcome.firstCharge.providerPaymentId)` or keep it as a one-off payment (for example, one paid period with no renewal), and ask the customer to set up a new mandate with a fresh `mandateRef` and `orderId`. The token id is scrubbed (`'[redacted]'`) from `outcome.raw` and from the `mandate.setup_failed` event, as it is from every outcome without a mandate: a token not yet `VAULTED` may still become usable.
453
+
454
+ **Charges and replays.** `chargeMandate` creates a merchant-initiated order paid with the vault token (`stored_credential`: `MERCHANT`, `SUBSEQUENT`, `SUBSCRIPTION_PREPAID`), whose request id and `invoice_id` are the `chargeRef`. `providerPaymentId` is the order id. An order that needs the customer (`PAYER_ACTION_REQUIRED`) is `requires_action`; a `DECLINED` or `FAILED` capture and any other 4xx are `failed` (`providerCode` is PayPal's issue, e.g. `PAYMENT_DENIED`); 401, 403 and 429 are `unknown` with `providerCode` `'401'`, `'403'` or `'429'`; 408, 409 and the issue `PREVIOUS_REQUEST_IN_PROGRESS` (whatever the status) are `unknown` with PayPal's issue, or the status, as `providerCode`; 5xx and network errors are `unknown`.
455
+ - **Within 6 hours**, a replay with the same `chargeRef` returns PayPal's original response (PayPal keeps request ids for 6 hours) **once the original request has finished**.
456
+ - **While the original is still in progress** (for example, a retry right after a client-side timeout), PayPal answers 409 `RESOURCE_CONFLICT` / `PREVIOUS_REQUEST_IN_PROGRESS`, which says nothing about the money: the original may still complete and charge. The result is `unknown`, never `failed`. Retry it later with the **same** `chargeRef`, never a new one: a new `chargeRef` is a new invoice id that `DUPLICATE_INVOICE_ID` cannot block, so it could charge the customer twice.
457
+ - **After 6 hours**, PayPal refuses the reused invoice id: the result is `unknown` with `providerCode: 'DUPLICATE_INVOICE_ID'` and `'A charge with this chargeRef was already submitted; reconcile it in PayPal'`. It never charges twice, **provided** "Block accidental payments" is on. Reconcile it in the PayPal dashboard by invoice id (= `chargeRef`).
458
+
459
+ **Status and revocation.** `getMandate` reads the vault token: present is `active`, 404 is `revoked` (both verified), anything else is `unknown` (unverified). `revokeMandate` **deletes the vault payment token** (`DELETE /v3/vault/payment-tokens/{id}`); a token already gone (404) also counts as revoked. Mark your own copy `revoked` whatever it returns: the configurator then refuses to charge it without calling PayPal, and PayPal itself refuses a charge on a deleted token.
460
+
461
+ ### Satispay
462
+
463
+ `SatispayProvider` implements `IMandateProvider` with **Automatic Payments (pre-authorized payment tokens), web-redirect flow**: `createMandate` creates an authorization, the customer approves it in Satispay, and every charge (the first one included) is a synchronous `PRE_AUTHORIZED` payment.
464
+
465
+ - **Prerequisite:** pre-authorized payments (Automatic Payments) must be enabled for your shop by Satispay. Without it, `createMandate` fails.
466
+ - **EUR only.** `createMandate` and `chargeMandate` refuse any other currency without calling Satispay.
467
+ - **`redirect_url` must not be opened in a WebView.** `approvalUrl` is the authorization's `redirect_url`: send the customer there in the browser (or let the Satispay app open it), never inside an embedded WebView.
468
+ - **`cancelUrl` is unused:** Satispay has no cancel redirect. The customer always comes back to `returnUrl` (with `mandate_ref`), and a declined authorization is reported by the re-read as `CANCELED` (terminal, `success: false`, no mandate). `confirmMandate` then replays the first payment (same body, `Idempotency-Key` = `orderId`), because a re-confirm after an `unknown` first charge (see "Resolving an unknown first charge" above) can find the authorization canceled after that payment was taken:
469
+ - a 4xx: no payment exists for that key (on a first confirm, the customer declined). The first charge is a verified `failed` ('Authorization canceled').
470
+ - `ACCEPTED`: Satispay's original first payment. The outcome is `error: 'Authorization canceled after the first charge'` with a verified `succeeded` first charge: the customer paid once but there is no mandate. As for PayPal's `MANDATE_NOT_VAULTED`, refund it with `checkout.refundPayment('satispay', outcome.firstCharge.providerPaymentId, amount)` or keep it as a one-off payment, and ask for a new setup with a fresh `mandateRef` and `orderId`. Listeners see `mandate.setup_failed`, then `charge.succeeded`, for the `orderId`.
471
+ - 401, 403, 408, 409, 429, 5xx, a timeout or a network error: unverified, non-terminal, `unknown` first charge, no events. Re-confirm later.
472
+ - The replay is authoritative only while Satispay keeps the key, which is not documented: re-confirm promptly, and reconcile older setups in the Satispay dashboard (`external_code` = `orderId`).
473
+ - **Callback.** The authorization's `callback_url` is `notifyUrl`, or `serverUrl` + `mandateCallbackPath` (default `/checkout/satispay/mandate/callback`) when `notifyUrl` is not set, with `mandate_ref` and `token_id={uuid}` appended. Satispay calls it with an HTTP GET whenever the authorization changes, replacing `{uuid}` with the token id. The callback is unsigned: `confirmMandate` only checks that `token_id`, when present, equals the persisted `pending.providerRef`, then re-reads the token server-to-server. `PENDING` is a verified, **non-terminal** outcome (`error: 'AUTHORIZATION_PENDING'`): keep the pending state and wait for the next callback. `ACCEPTED` activates the mandate and makes the first charge.
474
+ - **Revocation is customer-side only.** Satispay documents no merchant-side revocation, so `revokeMandate` makes no network call and returns `revokedAtProvider: false`: stop charging the mandate and mark your copy revoked. The customer revokes it from the Satispay app; `getMandate` then reads `revoked`, and a later charge fails with `mandateUnusable: true`.
475
+ - **Charges and replay semantics.** Every `PRE_AUTHORIZED` payment carries `Idempotency-Key = chargeRef` (the first charge: `orderId`; the setup itself: `setup-<orderId>`), and a request with the same key gets Satispay's original response, so a retry with the same `chargeRef` never charges twice. Satispay does not document how long it keeps idempotency keys: retry an `unknown` charge **promptly (the same day)**, and resolve older ones manually in the Satispay dashboard (`external_code` = `chargeRef`). A refused charge is a 4xx (`failed`, `providerCode` = Satispay's `code`); 401, 403, 429, 5xx, timeouts and network errors are `unknown`. 408 and 409 are `unknown` too: they may answer a concurrent request with the same key still in flight (the redirect and Satispay's callback can confirm the same setup at once), and the original may succeed. The payment is created as `ACCEPTED` directly, and `providerPaymentId` is the Satispay payment id, so `refundPayment(provider, providerPaymentId, amount)` refunds a mandate charge.
476
+ - **Check on the sandbox before going live** (Satispay does not document them): that a charge on a canceled token answers 4xx and creates no payment (and with which `code`), which the first confirm of every declined authorization relies on; that a replayed `Idempotency-Key` returns the original payment, also once the token is `CANCELED`; which status answers a same-key request still in flight (treated as `unknown` for 408 and 409; any other 4xx would be read as `failed`).
477
+ - **Config.** `timeoutMs` (default `30000`) bounds every mandate API call; `mandateCallbackPath` changes the default callback path.
478
+
479
+ ---
480
+
217
481
  ## Security: A Redirect Is Not Proof of Payment
218
482
 
219
483
  Relying solely on front-end browser redirects (`GET /:provider/redirect`) to fulfill orders introduces significant risk:
@@ -267,9 +531,21 @@ checkout.events
267
531
  .on('payment.failed', ({ provider, error, orderId, verified, raw }) => { /* ... */ })
268
532
  .on('payment.refunded', ({ provider, paymentId, orderId, verified, raw }) => { /* ... */ })
269
533
  .on('webhook.received', ({ provider, paymentId, orderId, status, error, verified, data, raw }) => { /* ... */ });
534
+
535
+ // Recurring payments (mandates): data.mandateRef on every event, never the providerMandateId
536
+ checkout.events
537
+ .on('mandate.setup_started', ({ provider, orderId, data }) => { /* verified: false */ })
538
+ .on('mandate.created', ({ provider, orderId, data }) => { /* terminal, verified */ })
539
+ .on('mandate.setup_failed', ({ provider, orderId, error, data }) => { /* terminal, verified */ })
540
+ .on('mandate.revoked', ({ provider, data }) => { /* data.revokedAtProvider */ })
541
+ .on('charge.succeeded', ({ paymentId, orderId /* = chargeRef */, verified, data }) => { /* ... */ })
542
+ .on('charge.failed', ({ orderId, error, data }) => { /* data.mandateUnusable, data.providerCode */ })
543
+ .on('charge.unknown', ({ orderId, error }) => { /* chargeMandate: retry with the same chargeRef; first charge: re-confirm */ })
544
+ .on('charge.requires_action', ({ orderId }) => { /* ... */ });
545
+ // Mandate and charge events can repeat for the same orderId (= chargeRef): dedupe on event name + orderId
270
546
  ```
271
547
 
272
- An error thrown by a listener propagates to the caller wrapped in a `CheckoutListenerError` (exported): its `message` is the original message (or `String(value)` for non-Error values), `cause` is the original value, untouched (frozen errors are fine), and `event` is the event name. The Express adapter answers `500` with a generic body (the message only with `exposeErrorMessages: true`) and passes the error to `onError`.
548
+ An error thrown by a listener is wrapped in a `CheckoutListenerError` (exported): its `message` is the original message (or `String(value)` for non-Error values), `cause` is the original value, untouched (frozen errors are fine), and `event` is the event name. It propagates to the caller, and the Express adapter answers `500` with a generic body (the message only with `exposeErrorMessages: true`) and passes the error to `onError`. The exception is the mandate events emitted after a provider call (`mandate.created`, `mandate.setup_failed`, `mandate.revoked`, `charge.*`): there the error goes to the `onListenerError` config hook (or `console.error`) and the method still returns its result, see [Recurring payments](#recurring-payments-mandates).
273
549
 
274
550
  Currencies are validated against the ISO 4217 list of active alpha-3 codes (`ISO_4217_ALPHA3_CODES`, plus the mapped Nexi numeric codes such as `978`); shape-only codes like `'XYZ'` or `'ABC'` and non-monetary codes (`XXX`, `XTS`, precious metals) are rejected.
275
551
 
@@ -349,6 +625,8 @@ export class StripeProvider extends BasePaymentProvider {
349
625
  checkout.registerProvider(new StripeProvider({ secretKey: '...' }));
350
626
  ```
351
627
 
628
+ To support [recurring payments](#recurring-payments-mandates), also implement `IMandateProvider` (`createMandate`, `confirmMandate`, `chargeMandate`, `getMandate`, `revokeMandate`): `isMandateProvider` detects the five methods, and the configurator validates inputs and normalizes outcomes as described there. Append `mandate_ref` to the setup URLs with `appendMandateRef(url, mandateRef)`. `confirmMandate(pending, {})`, with no callback input, must be safe to call again at any time, including after a terminal outcome: re-read the setup server-to-server or replay your own idempotency keys, never start a second first charge, and return an unverified, non-terminal outcome when you cannot re-read. Apps rely on it to [resolve an unknown first charge](#resolving-an-unknown-first-charge).
629
+
352
630
  ---
353
631
 
354
632
  ## Built-in Providers
@@ -2,10 +2,69 @@ import { Router, Request, Response, RequestHandler } from 'express';
2
2
  import { CheckoutConfigurator } from '../../checkout-configurator';
3
3
  import { PaymentRequest } from '../../models/payment-request.model';
4
4
  import { PaymentResult } from '../../models/payment-result.model';
5
+ import { MandatePendingState, MandateSetupOutcome, MandateSetupRequest, MandateSetupStart } from '../../models/mandate.model';
5
6
  export interface PaymentRequestContext {
6
7
  provider: string;
7
8
  res: Response;
8
9
  }
10
+ /**
11
+ * Recurring payments (mandates). Mounts `POST /:provider/mandate` (`GET` answers 405) and
12
+ * `GET|POST /:provider/mandate/callback`. Charges and revocations are never routes: call
13
+ * `checkout.chargeMandate` / `checkout.revokeMandate` from a server job.
14
+ *
15
+ * All four hooks are required: `createCheckoutRouter` throws
16
+ * `CheckoutError('INSECURE_CONFIGURATION')` at mount when one is missing.
17
+ *
18
+ * **Pending state is single-use.** When `outcome.terminal` is true, `onOutcome` must atomically
19
+ * mark the pending state consumed and persist the mandate (a conditional update such as
20
+ * `pending → done`, acting only if it matched); from then on `loadPending` must return `null`, so
21
+ * a replayed callback answers 404 and never reaches the provider again. A non-terminal outcome
22
+ * must not consume it. The app may expire stale pending states after a TTL of its choice.
23
+ */
24
+ export interface ExpressMandateOptions {
25
+ /**
26
+ * REQUIRED. Builds the setup request on the server (price from your plan, ids from your DB),
27
+ * ignoring the client body. `mandateRef` and `orderId` must be fresh for every attempt.
28
+ *
29
+ * `returnUrl` and `cancelUrl` (and `notifyUrl`) **must point to the callback route**,
30
+ * `<mount>/<provider>/mandate/callback` (e.g. `https://app.example/checkout/nexi/mandate/callback`),
31
+ * as absolute http(s) URLs (a relative URL answers 400 `INVALID_MANDATE_REQUEST`):
32
+ * the provider appends `mandate_ref`, the route confirms the setup when the customer comes back,
33
+ * and `onOutcome` decides where the browser goes next.
34
+ *
35
+ * Like `buildPaymentRequest`: if the hook sends a response itself, returning `null` or
36
+ * `undefined` stops the adapter; otherwise a missing request answers 400.
37
+ */
38
+ buildMandateRequest: (req: Request, ctx: PaymentRequestContext) => MandateSetupRequest | null | undefined | Promise<MandateSetupRequest | null | undefined>;
39
+ /**
40
+ * REQUIRED. Returns the persisted pending state for this callback, or null (→ 404).
41
+ * Must return `null` once a terminal outcome consumed it (single-use, see above).
42
+ * `ctx.mandateRef` comes from the unsigned query string: use it only as a lookup key.
43
+ */
44
+ loadPending: (req: Request, ctx: {
45
+ provider: string;
46
+ mandateRef: string;
47
+ }) => MandatePendingState | null | Promise<MandatePendingState | null>;
48
+ /**
49
+ * REQUIRED. Persist the outcome (idempotently: it may be called twice). When `outcome.terminal`
50
+ * is true, atomically consume the pending state and save `outcome.mandate`; never consume it on
51
+ * a non-terminal outcome. Redirect the customer's browser yourself (`req.method === 'GET'`,
52
+ * typically `res.redirect(302, …)` to your UI); do not respond to a server-to-server POST (Nexi
53
+ * `urlpost`, which must get HTTP 200), and the route answers `200 { success }`. Consuming closes the route, it does not delete
54
+ * the record: when `outcome.firstCharge.status` is `unknown`, keep the pending state and resolve
55
+ * the charge from a job with `checkout.confirmMandate(provider, pending, {})`, never with
56
+ * `chargeMandate` and the setup's `orderId`.
57
+ */
58
+ onOutcome: (outcome: MandateSetupOutcome, req: Request, res: Response) => void | Promise<void>;
59
+ /**
60
+ * REQUIRED. Called with the start result after createMandate, to persist `start.pending`
61
+ * (when `start.success`). Awaited **before** the response is sent, so the pending state exists
62
+ * before the customer can come back.
63
+ */
64
+ onStarted: (start: MandateSetupStart, req: Request) => void | Promise<void>;
65
+ /** Applied to POST /:provider/mandate only (the callback is always public). */
66
+ middleware?: RequestHandler[];
67
+ }
9
68
  export interface ExpressCheckoutOptions {
10
69
  /**
11
70
  * Middleware applied to ALL checkout routes (e.g. API key auth).
@@ -84,6 +143,11 @@ export interface ExpressCheckoutOptions {
84
143
  * `console.error('[awesome-node-checkout]', err)`.
85
144
  */
86
145
  onError?: (err: unknown, req: Request) => void;
146
+ /**
147
+ * Recurring payments (mandates). If omitted, the mandate routes are NOT mounted (404).
148
+ * See `ExpressMandateOptions`.
149
+ */
150
+ mandates?: ExpressMandateOptions;
87
151
  }
88
152
  /**
89
153
  * Matches a request path against public path patterns by segments.
@@ -102,11 +166,15 @@ export declare function isPathPublic(reqPath: string, publicPaths: (string | str
102
166
  * GET /:provider/webhook → handleWebhook
103
167
  * POST /:provider/webhook → handleWebhook
104
168
  * GET /:provider/redirect → handleRedirect (must come before /:provider/:id)
169
+ * POST /:provider/mandate → createMandate (only if options.mandates is configured)
170
+ * GET /:provider/mandate → 405, Allow: POST (only if options.mandates is configured)
171
+ * GET /:provider/mandate/callback → confirmMandate (only if options.mandates is configured)
172
+ * POST /:provider/mandate/callback → confirmMandate (only if options.mandates is configured)
105
173
  * GET /:provider/:id → getPaymentDetails (only if options.details is configured)
106
174
  * ```
107
175
  *
108
176
  * Throws `CheckoutError('INSECURE_CONFIGURATION')` when neither `buildPaymentRequest` nor
109
- * `allowClientPaymentRequest: true` is set.
177
+ * `allowClientPaymentRequest: true` is set, or when `mandates` lacks one of its four hooks.
110
178
  *
111
179
  * Usage:
112
180
  * ```typescript
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/adapters/express/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AAEnE,OAAO,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AACpE,OAAO,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAIlE,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,QAAQ,CAAC;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;IAE9B;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC,EAAE,CAAC;IAEpC;;;;;;;OAOG;IACH,mBAAmB,CAAC,EAAE,CACpB,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,qBAAqB,KACvB,cAAc,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IAEpF;;;;;OAKG;IACH,yBAAyB,CAAC,EAAE,IAAI,CAAC;IAEjC;;;OAGG;IACH,QAAQ,CAAC,EAAE;QACT,SAAS,EAAE,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,KAAK,MAAM,CAAC;QAC3D,SAAS,EAAE,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,KAAK,MAAM,CAAC;KAC5D,CAAC;IAEF;;;;;OAKG;IACH,OAAO,CAAC,EAAE;QACR;;;WAGG;QACH,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;KAC/B,CAAC;IAEF;;;OAGG;IACH,MAAM,CAAC,EAAE;QACP,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;KAC/B,CAAC;IAEF;;OAEG;IACH,OAAO,CAAC,EAAE;QACR,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;QAC9B,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KAC7E,CAAC;IAEF;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAE9B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;CAChD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC,EAAE,GAAG,OAAO,CAgBzF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,oBAAoB,EAC9B,OAAO,GAAE,sBAA2B,GACnC,MAAM,CAmOR"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/adapters/express/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AAEnE,OAAO,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AACpE,OAAO,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAGlE,OAAO,EAEL,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,iBAAiB,EAClB,MAAM,4BAA4B,CAAC;AAEpC,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,QAAQ,CAAC;CACf;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;;;;;;;;;OAYG;IACH,mBAAmB,EAAE,CACnB,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,qBAAqB,KACvB,mBAAmB,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IAE9F;;;;OAIG;IACH,WAAW,EAAE,CACX,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,KAC1C,mBAAmB,GAAG,IAAI,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAAC;IAEtE;;;;;;;;;OASG;IACH,SAAS,EAAE,CAAC,OAAO,EAAE,mBAAmB,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/F;;;;OAIG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE5E,+EAA+E;IAC/E,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;CAC/B;AAED,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;IAE9B;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC,EAAE,CAAC;IAEpC;;;;;;;OAOG;IACH,mBAAmB,CAAC,EAAE,CACpB,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,qBAAqB,KACvB,cAAc,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IAEpF;;;;;OAKG;IACH,yBAAyB,CAAC,EAAE,IAAI,CAAC;IAEjC;;;OAGG;IACH,QAAQ,CAAC,EAAE;QACT,SAAS,EAAE,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,KAAK,MAAM,CAAC;QAC3D,SAAS,EAAE,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,KAAK,MAAM,CAAC;KAC5D,CAAC;IAEF;;;;;OAKG;IACH,OAAO,CAAC,EAAE;QACR;;;WAGG;QACH,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;KAC/B,CAAC;IAEF;;;OAGG;IACH,MAAM,CAAC,EAAE;QACP,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;KAC/B,CAAC;IAEF;;OAEG;IACH,OAAO,CAAC,EAAE;QACR,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;QAC9B,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KAC7E,CAAC;IAEF;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAE9B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;IAE/C;;;OAGG;IACH,QAAQ,CAAC,EAAE,qBAAqB,CAAC;CAClC;AAKD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC,EAAE,GAAG,OAAO,CAgBzF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,oBAAoB,EAC9B,OAAO,GAAE,sBAA2B,GACnC,MAAM,CA6PR"}