awesome-node-checkout 1.3.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 (59) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +335 -17
  3. package/dist/adapters/express/index.d.ts +103 -2
  4. package/dist/adapters/express/index.d.ts.map +1 -1
  5. package/dist/adapters/express/index.js +213 -30
  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 +677 -13
  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 +529 -2
  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 +440 -3
  42. package/dist/providers/satispay/satispay.provider.js.map +1 -1
  43. package/dist/utils/ids.d.ts +10 -0
  44. package/dist/utils/ids.d.ts.map +1 -0
  45. package/dist/utils/ids.js +16 -0
  46. package/dist/utils/ids.js.map +1 -0
  47. package/dist/utils/money.d.ts +20 -0
  48. package/dist/utils/money.d.ts.map +1 -0
  49. package/dist/utils/money.js +68 -0
  50. package/dist/utils/money.js.map +1 -0
  51. package/dist/utils/redact.d.ts +15 -0
  52. package/dist/utils/redact.d.ts.map +1 -0
  53. package/dist/utils/redact.js +60 -0
  54. package/dist/utils/redact.js.map +1 -0
  55. package/dist/utils/url.d.ts +12 -0
  56. package/dist/utils/url.d.ts.map +1 -0
  57. package/dist/utils/url.js +19 -0
  58. package/dist/utils/url.js.map +1 -0
  59. package/package.json +2 -2
@@ -3,6 +3,7 @@ import { PaymentRequest } from './models/payment-request.model';
3
3
  import { PaymentResult, WebhookResult } from './models/payment-result.model';
4
4
  import { CheckoutConfig } from './models/checkout-config.model';
5
5
  import { CheckoutEventBus } from './events/checkout-event-bus';
6
+ import { ChargeRequest, ChargeResult, Mandate, MandateCallbackInput, MandatePendingState, MandateRevokeResult, MandateSetupOutcome, MandateSetupRequest, MandateSetupStart, MandateStatusResult } from './models/mandate.model';
6
7
  /**
7
8
  * Main entry point for awesome-node-checkout.
8
9
  *
@@ -87,5 +88,110 @@ export declare class CheckoutConfigurator {
87
88
  * @throws CheckoutError if the provider does not support redirect handling.
88
89
  */
89
90
  handleRedirect(providerName: string, query: Record<string, string>): Promise<PaymentResult>;
91
+ /**
92
+ * True when the provider implements `IMandateProvider`.
93
+ * @throws CheckoutError('PROVIDER_NOT_FOUND') if the provider is not registered.
94
+ */
95
+ supportsMandates(providerName: string): boolean;
96
+ /** Resolves a provider that implements mandates, or throws MANDATES_NOT_SUPPORTED. */
97
+ private getMandateProvider;
98
+ private invalidMandate;
99
+ /** A persisted mandate passed back by the app: it must belong to this provider. */
100
+ private assertMandate;
101
+ /**
102
+ * Event payloads never carry the credential-like `providerMandateId`, nor a setup's
103
+ * `pending.providerRef` (the future Satispay token id): every `secrets` entry is scrubbed from
104
+ * the whole payload (`raw`, `data`, `error`). Nexi's contract number is the app's own
105
+ * `mandateRef`, which is not a credential and is part of every payload, so it is never scrubbed.
106
+ *
107
+ * Used only after a mandate provider call has completed (confirm, charge, revoke): an error
108
+ * raised while emitting (a throwing listener) never rejects the caller, because money may
109
+ * already have moved. It goes to `config.onListenerError` instead (see `reportListenerError`).
110
+ */
111
+ private emitMandateEvent;
112
+ /** `config.onListenerError`, or `console.error` without it. Never throws. */
113
+ private reportListenerError;
114
+ private emitChargeEvent;
115
+ /**
116
+ * Charge normalization (fail closed). Returns a new object, never mutates the provider's:
117
+ * - `chargeRef` is always the request's;
118
+ * - an unknown status becomes `unknown`;
119
+ * - `succeeded` without `verified: true` becomes `unknown` (never `failed`: an unauthenticated
120
+ * "success" means we do not know);
121
+ * - a verified `succeeded` whose reported amount or currency differs from the request becomes
122
+ * `unknown` with error 'Charged amount does not match the request'. Absent fields are not
123
+ * compared (Nexi's charge response carries no amount).
124
+ */
125
+ private normalizeCharge;
126
+ /**
127
+ * Starts a mandate setup (customer present). The setup always includes the first charge
128
+ * (`chargeRef = request.orderId`).
129
+ *
130
+ * Validates the request first and throws `CheckoutError('INVALID_MANDATE_REQUEST')` (or
131
+ * `'INVALID_AMOUNT'` from `toMinorUnits`) without calling the provider. `returnUrl` and
132
+ * `cancelUrl` (and `notifyUrl`, when set) must be absolute http(s) URLs. A successful start whose
133
+ * `pending` does not match the request (provider, mandateRef, orderId, amount in minor units,
134
+ * currency) is returned as `success: false`. Persist `pending` before sending the customer to
135
+ * `approvalUrl`. Emits `mandate.setup_started` (`verified: false`) when the start succeeded, with
136
+ * `pending.providerRef` scrubbed from its payload (unless it is the orderId or the mandateRef).
137
+ * A listener error on that event propagates: no money has moved yet.
138
+ *
139
+ * @throws CheckoutError('MANDATES_NOT_SUPPORTED') if the provider does not implement mandates.
140
+ */
141
+ createMandate(providerName: string, request: MandateSetupRequest): Promise<MandateSetupStart>;
142
+ /**
143
+ * Confirms a mandate setup from a provider callback (redirect or server notification), against
144
+ * the persisted `pending` state. Idempotent: it may be called twice for the same setup.
145
+ *
146
+ * Normalization (fail closed):
147
+ * - an unverified outcome is `success: false`, `terminal: false`, has no `mandate`, and a first
148
+ * charge reported `succeeded` becomes `unknown`;
149
+ * - a verified outcome whose `firstCharge.chargeRef` is not `pending.orderId`, or whose `mandate`
150
+ * does not match `pending` (mandateRef, provider), becomes an unverified, non-terminal failure
151
+ * 'Mandate outcome does not match the pending setup';
152
+ * - `terminal` is the provider's value AND `verified`; it is always `true` with `success: true`.
153
+ *
154
+ * Events are emitted only for terminal outcomes: `mandate.created` (success) or
155
+ * `mandate.setup_failed`, then `charge.*` for the first charge. A non-terminal outcome emits
156
+ * nothing, so a forged callback never looks like a failed setup. Events can repeat for the
157
+ * same setup (redirect and notification, replays): listeners must be idempotent on `orderId`.
158
+ * A throwing listener never rejects this method: the error goes to `config.onListenerError`
159
+ * and the outcome is returned, so `onOutcome` can persist it.
160
+ *
161
+ * A terminal outcome can carry a first charge reported `unknown` (even with `success: true`).
162
+ * Resolve it by calling this method again from a job with the persisted (consumed) `pending` and
163
+ * an empty `input` (`{}`), never with `chargeMandate` and `chargeRef = pending.orderId`. The
164
+ * re-run is terminal again, so its events repeat.
165
+ *
166
+ * @throws CheckoutError('INVALID_MANDATE_REQUEST') if `pending.provider` is not `providerName`.
167
+ */
168
+ confirmMandate(providerName: string, pending: MandatePendingState, input?: MandateCallbackInput): Promise<MandateSetupOutcome>;
169
+ private normalizeSetupOutcome;
170
+ /**
171
+ * Merchant-initiated charge on an active mandate (customer absent). Never mounted as a route.
172
+ *
173
+ * Validates `chargeRef` (/^[A-Za-z0-9-]{2,30}$/, `INVALID_MANDATE_REQUEST`) and the amount
174
+ * (`INVALID_AMOUNT`). A `revoked` mandate returns `failed` with `mandateUnusable: true` without
175
+ * calling the provider. A provider that throws gives `unknown`, with `raw` reduced to the error's
176
+ * `{ name, message }`. See `normalizeCharge` for the rest. Emits `charge.succeeded`,
177
+ * `charge.failed`, `charge.unknown` or `charge.requires_action`; a throwing listener never
178
+ * rejects this method (the error goes to `config.onListenerError`), so a charge that moved
179
+ * money always returns its result.
180
+ *
181
+ * Retry an `unknown` charge with the SAME `chargeRef`; a dunning retry after `failed` is a new
182
+ * charge with a NEW `chargeRef`. The library never retries on its own. Never pass a setup's
183
+ * `orderId` as `chargeRef`: an `unknown` first charge is resolved with `confirmMandate(provider,
184
+ * pending, {})`, since this method starts a new charge (on PayPal, a new order).
185
+ */
186
+ chargeMandate(providerName: string, mandate: Mandate, charge: ChargeRequest): Promise<ChargeResult>;
187
+ /** Re-reads the mandate's usability from the provider (`verified: false` when it cannot). */
188
+ getMandate(providerName: string, mandate: Mandate): Promise<MandateStatusResult>;
189
+ /**
190
+ * Revokes the mandate at the provider, when the provider supports it, and emits
191
+ * `mandate.revoked` with `data: { mandateRef, revokedAtProvider }` (a throwing listener goes to
192
+ * `config.onListenerError` and never rejects). Mark your own copy revoked whatever the
193
+ * provider answers.
194
+ */
195
+ revokeMandate(providerName: string, mandate: Mandate): Promise<MandateRevokeResult>;
90
196
  }
91
197
  //# sourceMappingURL=checkout-configurator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"checkout-configurator.d.ts","sourceRoot":"","sources":["../src/checkout-configurator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,yCAAyC,CAAC;AAC3E,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC7E,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAEhE,OAAO,EAAE,gBAAgB,EAA2C,MAAM,6BAA6B,CAAC;AAExG;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,oBAAoB;IAQnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAPnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4C;IACtE,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAkC;IAEhE,wDAAwD;IACxD,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAA0B;gBAE9B,MAAM,GAAE,cAAmB;IAExD,0FAA0F;IAC1F,OAAO,CAAC,eAAe;IAUvB,8EAA8E;YAChE,IAAI;IASlB;;;OAGG;IACH,gBAAgB,CAAC,QAAQ,EAAE,gBAAgB,GAAG,IAAI;IAalD;;;OAGG;IACH,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB;IAY3C,oDAAoD;IACpD,sBAAsB,IAAI,MAAM,EAAE;IAIlC;;;;;;;;;OASG;IACH,OAAO,CAAC,gBAAgB;IAiBxB,sDAAsD;IAChD,aAAa,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;IAoB1F;;;;;;OAMG;IACG,cAAc,CAClB,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,EACjB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC5B,OAAO,CAAC,aAAa,CAAC;IA8BzB,2DAA2D;IACrD,iBAAiB,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAKxF,qCAAqC;IAC/B,aAAa,CACjB,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,aAAa,CAAC;IAmBzB;;;OAGG;IACG,aAAa,CACjB,YAAY,EAAE,MAAM,EACpB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,EACpC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GACtE,OAAO,CAAC,aAAa,CAAC;IAyBzB;;;OAGG;IACG,cAAc,CAClB,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC5B,OAAO,CAAC,aAAa,CAAC;CAuB1B"}
1
+ {"version":3,"file":"checkout-configurator.d.ts","sourceRoot":"","sources":["../src/checkout-configurator.ts"],"names":[],"mappings":"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"}
@@ -3,6 +3,106 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CheckoutConfigurator = void 0;
4
4
  const errors_1 = require("./models/errors");
5
5
  const checkout_event_bus_1 = require("./events/checkout-event-bus");
6
+ const mandate_provider_interface_1 = require("./interfaces/mandate-provider.interface");
7
+ const mandate_model_1 = require("./models/mandate.model");
8
+ const money_1 = require("./utils/money");
9
+ const redact_1 = require("./utils/redact");
10
+ const CHARGE_STATUSES = new Set([
11
+ 'succeeded',
12
+ 'failed',
13
+ 'unknown',
14
+ 'requires_action',
15
+ ]);
16
+ const CHARGE_EVENTS = {
17
+ succeeded: 'charge.succeeded',
18
+ failed: 'charge.failed',
19
+ unknown: 'charge.unknown',
20
+ requires_action: 'charge.requires_action',
21
+ };
22
+ const SETUP_MISMATCH = 'Mandate outcome does not match the pending setup';
23
+ const AMOUNT_MISMATCH = 'Charged amount does not match the request';
24
+ function isObject(value) {
25
+ return value !== null && typeof value === 'object';
26
+ }
27
+ function isNonEmptyString(value) {
28
+ return typeof value === 'string' && value.trim() !== '';
29
+ }
30
+ /** An absolute `http:` or `https:` URL (what `appendMandateRef` and the providers need). */
31
+ function isAbsoluteHttpUrl(value) {
32
+ if (typeof value !== 'string')
33
+ return false;
34
+ try {
35
+ const { protocol } = new URL(value);
36
+ return protocol === 'https:' || protocol === 'http:';
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /** Same amount in minor units (and same currency, when reported). Only present fields are compared. */
43
+ function chargedAmountMatches(result, amount, currency) {
44
+ if (result.currency != null) {
45
+ const expected = typeof currency === 'string' ? currency.trim().toUpperCase() : '';
46
+ if (typeof result.currency !== 'string' || expected === '')
47
+ return false;
48
+ if (result.currency.trim().toUpperCase() !== expected)
49
+ return false;
50
+ }
51
+ if (result.amount != null) {
52
+ try {
53
+ return (0, money_1.toMinorUnits)(result.amount, currency) === (0, money_1.toMinorUnits)(amount, currency);
54
+ }
55
+ catch {
56
+ // An invalid reported amount never matches; never throw after money may have moved
57
+ return false;
58
+ }
59
+ }
60
+ return true;
61
+ }
62
+ /**
63
+ * `pending.amount` / `pending.currency` (returned by the provider) equal the setup request's.
64
+ * Unlike `chargedAmountMatches`, both fields are required: a missing or invalid one is a mismatch.
65
+ */
66
+ function pendingAmountMatches(pending, request) {
67
+ if (typeof pending.currency !== 'string')
68
+ return false;
69
+ const currency = pending.currency.trim().toUpperCase();
70
+ if (currency === '' || currency !== request.currency.trim().toUpperCase())
71
+ return false;
72
+ try {
73
+ return (0, money_1.toMinorUnits)(pending.amount, currency) === (0, money_1.toMinorUnits)(request.amount, currency);
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /**
80
+ * The ids scrubbed from every event of a terminal setup outcome (`mandate.created` or
81
+ * `mandate.setup_failed`, and the first charge's `charge.*`):
82
+ * - the mandate's `providerMandateId`, taken from the provider's outcome (a dropped mandate's id
83
+ * may still be inside `raw`);
84
+ * - `pending.providerRef`, which is the providerMandateId itself for Satispay (token id) and can
85
+ * reach `raw` or `error` even when the setup failed and no mandate is returned. It is kept when
86
+ * it is the app's own `orderId` or `mandateRef` (Nexi `codTrans`), or the verified first
87
+ * charge's `providerPaymentId` (the PayPal order id, which refunds the charge), unless it is
88
+ * also the providerMandateId.
89
+ */
90
+ function setupEventSecrets(pending, providerOutcome, outcome) {
91
+ const secrets = [];
92
+ const providerMandateId = isObject(providerOutcome) && isObject(providerOutcome.mandate)
93
+ ? providerOutcome.mandate.providerMandateId
94
+ : undefined;
95
+ if (isNonEmptyString(providerMandateId))
96
+ secrets.push(providerMandateId);
97
+ const providerRef = pending.providerRef;
98
+ if (isNonEmptyString(providerRef) &&
99
+ providerRef !== pending.orderId &&
100
+ providerRef !== pending.mandateRef &&
101
+ providerRef !== outcome.firstCharge.providerPaymentId) {
102
+ secrets.push(providerRef);
103
+ }
104
+ return secrets;
105
+ }
6
106
  /**
7
107
  * Main entry point for awesome-node-checkout.
8
108
  *
@@ -226,6 +326,421 @@ class CheckoutConfigurator {
226
326
  });
227
327
  return result;
228
328
  }
329
+ // ---------------------------------------------------------------------------
330
+ // Recurring payments (mandates)
331
+ // ---------------------------------------------------------------------------
332
+ /**
333
+ * True when the provider implements `IMandateProvider`.
334
+ * @throws CheckoutError('PROVIDER_NOT_FOUND') if the provider is not registered.
335
+ */
336
+ supportsMandates(providerName) {
337
+ return (0, mandate_provider_interface_1.isMandateProvider)(this.getProvider(providerName));
338
+ }
339
+ /** Resolves a provider that implements mandates, or throws MANDATES_NOT_SUPPORTED. */
340
+ getMandateProvider(providerName) {
341
+ const provider = this.getProvider(providerName);
342
+ if (!(0, mandate_provider_interface_1.isMandateProvider)(provider)) {
343
+ throw new errors_1.CheckoutError(`Provider '${providerName}' does not support recurring payments (mandates)`, 'MANDATES_NOT_SUPPORTED', providerName);
344
+ }
345
+ return provider;
346
+ }
347
+ invalidMandate(message, providerName) {
348
+ return new errors_1.CheckoutError(message, 'INVALID_MANDATE_REQUEST', providerName);
349
+ }
350
+ /** A persisted mandate passed back by the app: it must belong to this provider. */
351
+ assertMandate(providerName, mandate) {
352
+ if (!isObject(mandate) || mandate.provider !== providerName) {
353
+ throw this.invalidMandate(`Mandate does not belong to provider '${providerName}'`, providerName);
354
+ }
355
+ if (!isNonEmptyString(mandate.mandateRef)) {
356
+ throw this.invalidMandate('Mandate has no mandateRef', providerName);
357
+ }
358
+ if (!isNonEmptyString(mandate.providerMandateId)) {
359
+ throw this.invalidMandate('Mandate has no providerMandateId', providerName);
360
+ }
361
+ }
362
+ /**
363
+ * Event payloads never carry the credential-like `providerMandateId`, nor a setup's
364
+ * `pending.providerRef` (the future Satispay token id): every `secrets` entry is scrubbed from
365
+ * the whole payload (`raw`, `data`, `error`). Nexi's contract number is the app's own
366
+ * `mandateRef`, which is not a credential and is part of every payload, so it is never scrubbed.
367
+ *
368
+ * Used only after a mandate provider call has completed (confirm, charge, revoke): an error
369
+ * raised while emitting (a throwing listener) never rejects the caller, because money may
370
+ * already have moved. It goes to `config.onListenerError` instead (see `reportListenerError`).
371
+ */
372
+ async emitMandateEvent(event, payload, secrets, mandateRef) {
373
+ try {
374
+ await this.emit(event, (0, redact_1.redactSecret)(payload, secrets.filter((s) => s !== mandateRef)));
375
+ }
376
+ catch (err) {
377
+ await this.reportListenerError(err, event);
378
+ }
379
+ }
380
+ /** `config.onListenerError`, or `console.error` without it. Never throws. */
381
+ async reportListenerError(err, event) {
382
+ const hook = this.config.onListenerError;
383
+ if (typeof hook === 'function') {
384
+ try {
385
+ await hook(err, event);
386
+ return;
387
+ }
388
+ catch (hookErr) {
389
+ console.error('[awesome-node-checkout] onListenerError threw', hookErr);
390
+ }
391
+ }
392
+ console.error('[awesome-node-checkout]', err);
393
+ }
394
+ async emitChargeEvent(providerName, mandateRef, result, secrets) {
395
+ await this.emitMandateEvent(CHARGE_EVENTS[result.status], {
396
+ provider: providerName,
397
+ paymentId: result.providerPaymentId,
398
+ orderId: result.chargeRef,
399
+ status: result.status,
400
+ error: result.error,
401
+ verified: result.verified,
402
+ data: {
403
+ mandateRef,
404
+ amount: result.amount,
405
+ currency: result.currency,
406
+ providerCode: result.providerCode,
407
+ mandateUnusable: result.mandateUnusable,
408
+ },
409
+ raw: result.raw,
410
+ }, secrets, mandateRef);
411
+ }
412
+ /**
413
+ * Charge normalization (fail closed). Returns a new object, never mutates the provider's:
414
+ * - `chargeRef` is always the request's;
415
+ * - an unknown status becomes `unknown`;
416
+ * - `succeeded` without `verified: true` becomes `unknown` (never `failed`: an unauthenticated
417
+ * "success" means we do not know);
418
+ * - a verified `succeeded` whose reported amount or currency differs from the request becomes
419
+ * `unknown` with error 'Charged amount does not match the request'. Absent fields are not
420
+ * compared (Nexi's charge response carries no amount).
421
+ */
422
+ normalizeCharge(result, chargeRef, amount, currency) {
423
+ const normalized = isObject(result)
424
+ ? { ...result }
425
+ : { status: 'unknown', verified: false, chargeRef, error: 'Provider returned no charge result' };
426
+ normalized.chargeRef = chargeRef;
427
+ normalized.verified = normalized.verified === true;
428
+ if (!CHARGE_STATUSES.has(normalized.status)) {
429
+ normalized.status = 'unknown';
430
+ }
431
+ if (normalized.status === 'succeeded') {
432
+ if (!normalized.verified) {
433
+ normalized.status = 'unknown';
434
+ normalized.error = normalized.error ?? 'Charge outcome could not be verified';
435
+ }
436
+ else if (!chargedAmountMatches(normalized, amount, currency)) {
437
+ normalized.status = 'unknown';
438
+ normalized.error = AMOUNT_MISMATCH;
439
+ }
440
+ }
441
+ return normalized;
442
+ }
443
+ /**
444
+ * Starts a mandate setup (customer present). The setup always includes the first charge
445
+ * (`chargeRef = request.orderId`).
446
+ *
447
+ * Validates the request first and throws `CheckoutError('INVALID_MANDATE_REQUEST')` (or
448
+ * `'INVALID_AMOUNT'` from `toMinorUnits`) without calling the provider. `returnUrl` and
449
+ * `cancelUrl` (and `notifyUrl`, when set) must be absolute http(s) URLs. A successful start whose
450
+ * `pending` does not match the request (provider, mandateRef, orderId, amount in minor units,
451
+ * currency) is returned as `success: false`. Persist `pending` before sending the customer to
452
+ * `approvalUrl`. Emits `mandate.setup_started` (`verified: false`) when the start succeeded, with
453
+ * `pending.providerRef` scrubbed from its payload (unless it is the orderId or the mandateRef).
454
+ * A listener error on that event propagates: no money has moved yet.
455
+ *
456
+ * @throws CheckoutError('MANDATES_NOT_SUPPORTED') if the provider does not implement mandates.
457
+ */
458
+ async createMandate(providerName, request) {
459
+ const provider = this.getMandateProvider(providerName);
460
+ if (!isObject(request)) {
461
+ throw this.invalidMandate('Invalid mandate setup request', providerName);
462
+ }
463
+ if (typeof request.mandateRef !== 'string' || !mandate_model_1.MANDATE_REF_PATTERN.test(request.mandateRef)) {
464
+ throw this.invalidMandate('Invalid mandateRef: expected /^[A-Za-z0-9_-]{5,30}$/', providerName);
465
+ }
466
+ if (typeof request.orderId !== 'string' || !mandate_model_1.CHARGE_REF_PATTERN.test(request.orderId)) {
467
+ throw this.invalidMandate('Invalid orderId: expected /^[A-Za-z0-9-]{2,30}$/', providerName);
468
+ }
469
+ if (!isNonEmptyString(request.currency)) {
470
+ throw this.invalidMandate('currency is required', providerName);
471
+ }
472
+ (0, money_1.toMinorUnits)(request.amount, request.currency);
473
+ if (typeof request.description !== 'string' ||
474
+ request.description.length < 1 ||
475
+ request.description.length > 127) {
476
+ throw this.invalidMandate('Invalid description: 1-127 characters are required', providerName);
477
+ }
478
+ if (!isNonEmptyString(request.returnUrl) || !isNonEmptyString(request.cancelUrl)) {
479
+ throw this.invalidMandate('returnUrl and cancelUrl are required', providerName);
480
+ }
481
+ if (!isAbsoluteHttpUrl(request.returnUrl) || !isAbsoluteHttpUrl(request.cancelUrl)) {
482
+ throw this.invalidMandate('returnUrl and cancelUrl must be absolute http(s) URLs', providerName);
483
+ }
484
+ if (request.notifyUrl != null && !isAbsoluteHttpUrl(request.notifyUrl)) {
485
+ throw this.invalidMandate('notifyUrl must be an absolute http(s) URL', providerName);
486
+ }
487
+ const start = await provider.createMandate(request);
488
+ let result;
489
+ if (!isObject(start) || start.success !== true) {
490
+ result = {
491
+ ...(isObject(start) ? start : {}),
492
+ success: false,
493
+ approvalUrl: undefined,
494
+ pending: undefined,
495
+ error: (isObject(start) ? start.error : undefined) ?? 'Mandate setup could not be started',
496
+ };
497
+ }
498
+ else {
499
+ const pending = start.pending;
500
+ // The first charge's amount check in confirmMandate trusts `pending`: bind it to the request
501
+ const matches = isObject(pending) &&
502
+ pending.provider === providerName &&
503
+ pending.mandateRef === request.mandateRef &&
504
+ pending.orderId === request.orderId &&
505
+ pendingAmountMatches(pending, request);
506
+ result = matches
507
+ ? { ...start, pending: { ...pending } }
508
+ : {
509
+ ...start,
510
+ success: false,
511
+ approvalUrl: undefined,
512
+ pending: undefined,
513
+ error: 'Mandate setup does not match the request',
514
+ };
515
+ }
516
+ if (result.success) {
517
+ // `pending.providerRef` may become the credential-like providerMandateId (Satispay token
518
+ // id): scrub it from the event's payload. Not when it is the app's own orderId or
519
+ // mandateRef (Nexi), which are part of every payload anyway.
520
+ const providerRef = result.pending?.providerRef;
521
+ const secret = isNonEmptyString(providerRef) && providerRef !== request.orderId && providerRef !== request.mandateRef
522
+ ? providerRef
523
+ : undefined;
524
+ // No money has moved yet: a throwing listener propagates, like every non-mandate event
525
+ await this.emit('mandate.setup_started', (0, redact_1.redactSecret)({
526
+ provider: providerName,
527
+ orderId: request.orderId,
528
+ status: 'started',
529
+ // A setup start reports no outcome: nothing has been authenticated yet
530
+ verified: false,
531
+ data: { mandateRef: request.mandateRef },
532
+ raw: result.raw,
533
+ }, secret));
534
+ }
535
+ return result;
536
+ }
537
+ /**
538
+ * Confirms a mandate setup from a provider callback (redirect or server notification), against
539
+ * the persisted `pending` state. Idempotent: it may be called twice for the same setup.
540
+ *
541
+ * Normalization (fail closed):
542
+ * - an unverified outcome is `success: false`, `terminal: false`, has no `mandate`, and a first
543
+ * charge reported `succeeded` becomes `unknown`;
544
+ * - a verified outcome whose `firstCharge.chargeRef` is not `pending.orderId`, or whose `mandate`
545
+ * does not match `pending` (mandateRef, provider), becomes an unverified, non-terminal failure
546
+ * 'Mandate outcome does not match the pending setup';
547
+ * - `terminal` is the provider's value AND `verified`; it is always `true` with `success: true`.
548
+ *
549
+ * Events are emitted only for terminal outcomes: `mandate.created` (success) or
550
+ * `mandate.setup_failed`, then `charge.*` for the first charge. A non-terminal outcome emits
551
+ * nothing, so a forged callback never looks like a failed setup. Events can repeat for the
552
+ * same setup (redirect and notification, replays): listeners must be idempotent on `orderId`.
553
+ * A throwing listener never rejects this method: the error goes to `config.onListenerError`
554
+ * and the outcome is returned, so `onOutcome` can persist it.
555
+ *
556
+ * A terminal outcome can carry a first charge reported `unknown` (even with `success: true`).
557
+ * Resolve it by calling this method again from a job with the persisted (consumed) `pending` and
558
+ * an empty `input` (`{}`), never with `chargeMandate` and `chargeRef = pending.orderId`. The
559
+ * re-run is terminal again, so its events repeat.
560
+ *
561
+ * @throws CheckoutError('INVALID_MANDATE_REQUEST') if `pending.provider` is not `providerName`.
562
+ */
563
+ async confirmMandate(providerName, pending, input = {}) {
564
+ const provider = this.getMandateProvider(providerName);
565
+ if (!isObject(pending) || pending.provider !== providerName) {
566
+ throw this.invalidMandate(`Pending mandate setup does not belong to provider '${providerName}'`, providerName);
567
+ }
568
+ if (typeof pending.mandateRef !== 'string' || !mandate_model_1.MANDATE_REF_PATTERN.test(pending.mandateRef)) {
569
+ throw this.invalidMandate('Invalid pending mandateRef', providerName);
570
+ }
571
+ if (typeof pending.orderId !== 'string' || !mandate_model_1.CHARGE_REF_PATTERN.test(pending.orderId)) {
572
+ throw this.invalidMandate('Invalid pending orderId', providerName);
573
+ }
574
+ const providerOutcome = await provider.confirmMandate(pending, input ?? {});
575
+ const outcome = this.normalizeSetupOutcome(providerName, pending, providerOutcome);
576
+ if (outcome.terminal) {
577
+ const secrets = setupEventSecrets(pending, providerOutcome, outcome);
578
+ await this.emitMandateEvent(outcome.success ? 'mandate.created' : 'mandate.setup_failed', {
579
+ provider: providerName,
580
+ orderId: pending.orderId,
581
+ status: outcome.success ? 'active' : 'failed',
582
+ error: outcome.error,
583
+ verified: outcome.verified,
584
+ data: { mandateRef: pending.mandateRef },
585
+ raw: outcome.raw,
586
+ }, secrets, pending.mandateRef);
587
+ await this.emitChargeEvent(providerName, pending.mandateRef, outcome.firstCharge, secrets);
588
+ }
589
+ return outcome;
590
+ }
591
+ normalizeSetupOutcome(providerName, pending, out) {
592
+ const source = isObject(out) ? out : {};
593
+ const providerCharge = isObject(source.firstCharge) ? source.firstCharge : undefined;
594
+ if (source.verified !== true) {
595
+ // Like normalizeOutcome: an unauthenticated callback provides no identities and no amounts
596
+ const firstCharge = providerCharge
597
+ ? {
598
+ ...providerCharge,
599
+ chargeRef: pending.orderId,
600
+ verified: false,
601
+ providerPaymentId: undefined,
602
+ amount: undefined,
603
+ currency: undefined,
604
+ }
605
+ : { status: 'unknown', verified: false, chargeRef: pending.orderId };
606
+ if (!CHARGE_STATUSES.has(firstCharge.status))
607
+ firstCharge.status = 'unknown';
608
+ if (firstCharge.status === 'succeeded') {
609
+ firstCharge.status = 'unknown';
610
+ firstCharge.error = firstCharge.error ?? 'Charge outcome could not be verified';
611
+ }
612
+ return {
613
+ ...source,
614
+ success: false,
615
+ verified: false,
616
+ terminal: false,
617
+ mandate: undefined,
618
+ firstCharge,
619
+ error: source.error ?? 'Mandate outcome could not be verified',
620
+ };
621
+ }
622
+ const mismatch = () => ({
623
+ success: false,
624
+ verified: false,
625
+ terminal: false,
626
+ firstCharge: { status: 'unknown', verified: false, chargeRef: pending.orderId, error: SETUP_MISMATCH },
627
+ error: SETUP_MISMATCH,
628
+ raw: source.raw,
629
+ });
630
+ if (!providerCharge || providerCharge.chargeRef !== pending.orderId)
631
+ return mismatch();
632
+ const mandate = source.mandate;
633
+ if (mandate !== undefined && mandate !== null) {
634
+ if (!isObject(mandate) || mandate.mandateRef !== pending.mandateRef || mandate.provider !== providerName) {
635
+ return mismatch();
636
+ }
637
+ }
638
+ const success = source.success === true;
639
+ if (success &&
640
+ (!isObject(mandate) || mandate.status !== 'active' || !isNonEmptyString(mandate.providerMandateId))) {
641
+ // success: true means an active mandate: anything else is a provider bug
642
+ return mismatch();
643
+ }
644
+ const firstCharge = this.normalizeCharge(providerCharge, pending.orderId, pending.amount, pending.currency);
645
+ return {
646
+ ...source,
647
+ success,
648
+ verified: true,
649
+ terminal: success || source.terminal === true,
650
+ mandate: success && isObject(mandate) ? { ...mandate } : undefined,
651
+ firstCharge,
652
+ };
653
+ }
654
+ /**
655
+ * Merchant-initiated charge on an active mandate (customer absent). Never mounted as a route.
656
+ *
657
+ * Validates `chargeRef` (/^[A-Za-z0-9-]{2,30}$/, `INVALID_MANDATE_REQUEST`) and the amount
658
+ * (`INVALID_AMOUNT`). A `revoked` mandate returns `failed` with `mandateUnusable: true` without
659
+ * calling the provider. A provider that throws gives `unknown`, with `raw` reduced to the error's
660
+ * `{ name, message }`. See `normalizeCharge` for the rest. Emits `charge.succeeded`,
661
+ * `charge.failed`, `charge.unknown` or `charge.requires_action`; a throwing listener never
662
+ * rejects this method (the error goes to `config.onListenerError`), so a charge that moved
663
+ * money always returns its result.
664
+ *
665
+ * Retry an `unknown` charge with the SAME `chargeRef`; a dunning retry after `failed` is a new
666
+ * charge with a NEW `chargeRef`. The library never retries on its own. Never pass a setup's
667
+ * `orderId` as `chargeRef`: an `unknown` first charge is resolved with `confirmMandate(provider,
668
+ * pending, {})`, since this method starts a new charge (on PayPal, a new order).
669
+ */
670
+ async chargeMandate(providerName, mandate, charge) {
671
+ const provider = this.getMandateProvider(providerName);
672
+ this.assertMandate(providerName, mandate);
673
+ if (!isObject(charge) || typeof charge.chargeRef !== 'string' || !mandate_model_1.CHARGE_REF_PATTERN.test(charge.chargeRef)) {
674
+ throw this.invalidMandate('Invalid chargeRef: expected /^[A-Za-z0-9-]{2,30}$/', providerName);
675
+ }
676
+ if (!isNonEmptyString(charge.currency)) {
677
+ throw this.invalidMandate('currency is required', providerName);
678
+ }
679
+ (0, money_1.toMinorUnits)(charge.amount, charge.currency);
680
+ let result;
681
+ if (mandate.status === 'revoked') {
682
+ result = {
683
+ status: 'failed',
684
+ verified: false,
685
+ chargeRef: charge.chargeRef,
686
+ error: 'Mandate is revoked',
687
+ mandateUnusable: true,
688
+ };
689
+ }
690
+ else {
691
+ let providerResult;
692
+ try {
693
+ providerResult = await provider.chargeMandate(mandate, charge);
694
+ }
695
+ catch (err) {
696
+ // The request may or may not have reached the provider: we do not know.
697
+ // Only name and message of the thrown value reach `raw` (and the charge.unknown event):
698
+ // SDK errors carry the request, its headers (bearer token) and its body as own properties
699
+ providerResult = {
700
+ status: 'unknown',
701
+ verified: false,
702
+ chargeRef: charge.chargeRef,
703
+ error: 'Charge outcome unknown: the provider call failed',
704
+ raw: err instanceof Error ? { name: err.name, message: err.message } : undefined,
705
+ };
706
+ }
707
+ result = this.normalizeCharge(providerResult, charge.chargeRef, charge.amount, charge.currency);
708
+ }
709
+ await this.emitChargeEvent(providerName, mandate.mandateRef, result, [mandate.providerMandateId]);
710
+ return result;
711
+ }
712
+ /** Re-reads the mandate's usability from the provider (`verified: false` when it cannot). */
713
+ async getMandate(providerName, mandate) {
714
+ const provider = this.getMandateProvider(providerName);
715
+ this.assertMandate(providerName, mandate);
716
+ const result = await provider.getMandate(mandate);
717
+ return { ...result, verified: result?.verified === true };
718
+ }
719
+ /**
720
+ * Revokes the mandate at the provider, when the provider supports it, and emits
721
+ * `mandate.revoked` with `data: { mandateRef, revokedAtProvider }` (a throwing listener goes to
722
+ * `config.onListenerError` and never rejects). Mark your own copy revoked whatever the
723
+ * provider answers.
724
+ */
725
+ async revokeMandate(providerName, mandate) {
726
+ const provider = this.getMandateProvider(providerName);
727
+ this.assertMandate(providerName, mandate);
728
+ const providerResult = await provider.revokeMandate(mandate);
729
+ const result = {
730
+ ...(isObject(providerResult) ? providerResult : {}),
731
+ revokedAtProvider: isObject(providerResult) && providerResult.revokedAtProvider === true,
732
+ };
733
+ await this.emitMandateEvent('mandate.revoked', {
734
+ provider: providerName,
735
+ status: 'revoked',
736
+ error: result.error,
737
+ // The revoke result carries no authentication flag
738
+ verified: false,
739
+ data: { mandateRef: mandate.mandateRef, revokedAtProvider: result.revokedAtProvider },
740
+ raw: result.raw,
741
+ }, [mandate.providerMandateId], mandate.mandateRef);
742
+ return result;
743
+ }
229
744
  }
230
745
  exports.CheckoutConfigurator = CheckoutConfigurator;
231
746
  //# sourceMappingURL=checkout-configurator.js.map