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
@@ -36,12 +36,47 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.NexiProvider = void 0;
37
37
  const crypto = __importStar(require("crypto"));
38
38
  const base_payment_provider_abstract_1 = require("../../abstract/base-payment-provider.abstract");
39
+ const ids_1 = require("../../utils/ids");
40
+ const money_1 = require("../../utils/money");
39
41
  const parsing_util_1 = require("../../utils/parsing.util");
42
+ const url_1 = require("../../utils/url");
43
+ const mandate_model_1 = require("../../models/mandate.model");
44
+ /** Nexi XPay paid statuses (`situazioneOrdine` report `stato`, lower-case). */
45
+ const NEXI_PAID_STATUSES = new Set(['autorizzato', 'contabilizzato', 'catturato']);
46
+ /**
47
+ * Replay guard (`situazioneOrdine` report `stato`, trimmed and lower-case; manual v20.4 p. 622).
48
+ * Money was taken for the code: paid, partly captured, or charged and then (partly) refunded.
49
+ */
50
+ const NEXI_CHARGED_STATUSES = new Set([
51
+ ...NEXI_PAID_STATUSES,
52
+ 'contabilizzato parz.',
53
+ 'rimborsato',
54
+ 'rimborsato parz.',
55
+ ]);
56
+ /** Replay guard: the clear refusals, the only states that make an existing code `failed`. */
57
+ const NEXI_REFUSED_STATUSES = new Set(['negato', 'annullato', 'non creato']);
58
+ /** Nexi XPay numeric code of EUR, the only currency `pagamentoRicorrente` accepts. */
59
+ const NEXI_EUR_NUMERIC = '978';
60
+ const EUR_ONLY = 'Nexi mandates support EUR only';
61
+ /** `pagamentoRicorrente` `importo`: cents, at most 8 digits (pp. 188-193). */
62
+ const NEXI_RECURRING_MAX_IMPORTO_DIGITS = 8;
63
+ /** Hosted-page `esito` values that are a final refusal of the setup. */
64
+ const NEXI_SETUP_REFUSALS = new Set(['KO', 'ANNULLO', 'ERRORE']);
65
+ /** Configuration, credentials or technical errors: never the customer's fault. */
66
+ const NEXI_MERCHANT_ERRORS = new Set([1, 3, 4, 5, 7, 12, 50, 97, 98, 99, 100]);
67
+ const NEXI_NOT_FOUND_ERRORS = new Set([2, 13]);
68
+ const NEXI_MANDATE_UNUSABLE_ERRORS = new Set([8, 14, 22]);
69
+ const NEXI_DUPLICATE_ERROR = 9;
40
70
  /**
41
71
  * Nexi eCommerce provider.
42
72
  * Flow: `redirect` — builds a signed checkout URL, redirects the user to Nexi,
43
73
  * then handles the POST-back via `handleRedirect`.
44
74
  *
75
+ * Recurring payments (`IMandateProvider`): the setup is a hosted-page first payment that
76
+ * registers a contract (`num_contratto` = `mandateRef`, `tipo_servizio=paga_multi`,
77
+ * `tipo_richiesta=PP`); later charges use `pagamentoRicorrente`; `cancellaContratto` revokes.
78
+ * EUR only. See "Specifiche tecniche di integrazione con il gateway di pagamento XPay" v20.4.
79
+ *
45
80
  * Note: Nexi XPay mandates SHA-1 for MAC computation. This is a requirement of
46
81
  * the Nexi API and cannot be changed on the client side.
47
82
  */
@@ -55,6 +90,10 @@ class NexiProvider extends base_payment_provider_abstract_1.BasePaymentProvider
55
90
  config.environment === 'production'
56
91
  ? 'https://ecommerce.nexi.it'
57
92
  : 'https://int-ecommerce.nexi.it';
93
+ this.timeoutMs =
94
+ typeof config.timeoutMs === 'number' && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0
95
+ ? config.timeoutMs
96
+ : 30000;
58
97
  }
59
98
  /**
60
99
  * Computes the SHA-1 MAC required by Nexi to authenticate the payment request.
@@ -121,10 +160,223 @@ class NexiProvider extends base_payment_provider_abstract_1.BasePaymentProvider
121
160
  return false;
122
161
  }
123
162
  }
163
+ /**
164
+ * Extracts the fields of a hosted-page outcome (redirect query or `urlpost` form). Missing
165
+ * fields are `''`; `codTrans` falls back to `fallbackCodTrans` when given. `mac` is returned as
166
+ * is, to be checked with `isValidHexMac`.
167
+ */
168
+ extractOutcomeFields(data, fallbackCodTrans) {
169
+ const f = (k) => {
170
+ const v = data?.[k];
171
+ return v === undefined || v === null ? '' : String(v);
172
+ };
173
+ const rawCodTrans = data?.['codTrans'];
174
+ return {
175
+ codTrans: rawCodTrans === undefined || rawCodTrans === null ? (fallbackCodTrans ?? '') : String(rawCodTrans),
176
+ esito: f('esito'),
177
+ importo: f('importo'),
178
+ divisa: f('divisa'),
179
+ dataStr: f('data'),
180
+ orario: f('orario'),
181
+ codAut: f('codAut'),
182
+ mac: data?.['mac'],
183
+ };
184
+ }
185
+ /** True when the 7-field outcome MAC of `fields` is present and valid (constant time). */
186
+ outcomeMacValid(fields) {
187
+ const { codTrans, esito, importo, divisa, dataStr, orario, codAut, mac } = fields;
188
+ return (this.isValidHexMac(mac) &&
189
+ this.safeVerifyResponseMac(codTrans, esito, importo, divisa, dataStr, orario, codAut, mac));
190
+ }
191
+ // ---------------------------------------------------------------------------
192
+ // Recurring payments (mandates): MACs and API helpers
193
+ // ---------------------------------------------------------------------------
194
+ /** Constant-time comparison of a hex MAC against the expected SHA-1 hex digest. */
195
+ hexMacEquals(expected, mac) {
196
+ if (!this.isValidHexMac(mac))
197
+ return false;
198
+ try {
199
+ const expBuf = Buffer.from(expected, 'hex');
200
+ const macBuf = Buffer.from(mac, 'hex');
201
+ return expBuf.length === macBuf.length && expBuf.length > 0 && crypto.timingSafeEqual(expBuf, macBuf);
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ }
207
+ /**
208
+ * Verifies the standard XPay REST response MAC:
209
+ * `esito={esito}idOperazione={idOperazione}timeStamp={timeStamp}{macKey}`.
210
+ */
211
+ responseMacValid(json) {
212
+ const str = (k) => {
213
+ const v = json[k];
214
+ return v === undefined || v === null ? '' : String(v);
215
+ };
216
+ const expected = this.sha1(`esito=${str('esito')}idOperazione=${str('idOperazione')}timeStamp=${str('timeStamp')}${this.config.macKey}`);
217
+ return this.hexMacEquals(expected, json['mac']);
218
+ }
219
+ /**
220
+ * MAC policy of the recurring and contract APIs: a present `mac` must be valid whatever the
221
+ * `esito`; a missing one is tolerated only on `KO` (an error answer may carry none).
222
+ */
223
+ responseAuthentic(json) {
224
+ const mac = json['mac'];
225
+ if (mac === undefined || mac === null || mac === '')
226
+ return json['esito'] === 'KO';
227
+ return this.responseMacValid(json);
228
+ }
229
+ /**
230
+ * Classifies a "Tabella Codici errore API Restful" code (pp. 883-885). A missing or
231
+ * non-numeric code is `merchant`: without a code nothing says the customer is at fault.
232
+ */
233
+ classifyApiError(codice) {
234
+ const code = typeof codice === 'number' || (typeof codice === 'string' && /^\d+$/.test(codice.trim()))
235
+ ? Number(codice)
236
+ : NaN;
237
+ if (!Number.isInteger(code))
238
+ return 'merchant';
239
+ if (NEXI_MERCHANT_ERRORS.has(code))
240
+ return 'merchant';
241
+ if (code === NEXI_DUPLICATE_ERROR)
242
+ return 'duplicate';
243
+ if (NEXI_NOT_FOUND_ERRORS.has(code))
244
+ return 'notFound';
245
+ if (NEXI_MANDATE_UNUSABLE_ERRORS.has(code))
246
+ return 'mandateUnusable';
247
+ return 'declined';
248
+ }
249
+ /** `errore.codice` / `errore.messaggio` of a response, when present. */
250
+ apiError(json) {
251
+ const errore = json['errore'];
252
+ if (errore === null || typeof errore !== 'object')
253
+ return { codice: undefined };
254
+ const e = errore;
255
+ return {
256
+ codice: e['codice'],
257
+ messaggio: typeof e['messaggio'] === 'string' ? e['messaggio'] : undefined,
258
+ };
259
+ }
260
+ /**
261
+ * Computes the MAC of `pagamentoRicorrente` (p. 191). `scadenza` is not sent, so it is in the
262
+ * string with an empty value, as the manual requires:
263
+ * `apiKey=…numeroContratto=…codiceTransazione=…importo=…divisa=978scadenza=timeStamp=…{macKey}`.
264
+ */
265
+ generateRecurringMac(numeroContratto, codiceTransazione, importo, timeStamp) {
266
+ return this.sha1(`apiKey=${this.config.merchantId}numeroContratto=${numeroContratto}` +
267
+ `codiceTransazione=${codiceTransazione}importo=${importo}divisa=${NEXI_EUR_NUMERIC}` +
268
+ `scadenza=timeStamp=${timeStamp}${this.config.macKey}`);
269
+ }
270
+ /**
271
+ * POSTs a JSON body to an XPay REST endpoint with the configured timeout. Never throws; the
272
+ * error never contains the response body.
273
+ */
274
+ async postApi(path, body) {
275
+ let response;
276
+ try {
277
+ response = await fetch(`${this.apiUrl}${path}`, {
278
+ method: 'POST',
279
+ headers: { 'Content-Type': 'application/json' },
280
+ body: JSON.stringify(body),
281
+ signal: AbortSignal.timeout(this.timeoutMs),
282
+ });
283
+ }
284
+ catch (err) {
285
+ const name = err instanceof Error ? err.name : '';
286
+ return {
287
+ ok: false,
288
+ error: name === 'TimeoutError' || name === 'AbortError' ? 'Nexi API timeout' : 'Nexi API network error',
289
+ };
290
+ }
291
+ if (!response || !response.ok) {
292
+ return { ok: false, error: `Nexi API error: HTTP ${response?.status}` };
293
+ }
294
+ try {
295
+ const json = (await response.json());
296
+ if (json === null || typeof json !== 'object' || Array.isArray(json)) {
297
+ return { ok: false, error: 'Invalid provider response' };
298
+ }
299
+ return { ok: true, json: json };
300
+ }
301
+ catch {
302
+ return { ok: false, error: 'Invalid provider response' };
303
+ }
304
+ }
305
+ /**
306
+ * Reads `situazioneOrdine` for `codTrans` (same request as `getPaymentDetails`) and classifies
307
+ * the raw response, over every report entry for `codTrans`:
308
+ * - `paid`: some entry shows the money was taken (`NEXI_CHARGED_STATUSES`: paid, partly captured,
309
+ * refunded or partly refunded);
310
+ * - `pending`: otherwise, some entry is in progress (`In Corso`), has an unrecognised `stato`, or
311
+ * has none. Fail safe: such a code may still be charged, so it is never reported as refused;
312
+ * - `refused`: every entry is a clear refusal (`Negato`, `Annullato`, `Non Creato`);
313
+ * - `absent`: OK with no matching report entry, or KO "not found" 2/13;
314
+ * - `error`: anything else (HTTP, network, timeout, non-JSON, bad MAC, a missing MAC on OK, no
315
+ * `report` array, any other KO).
316
+ */
317
+ async lookupTransaction(codTrans) {
318
+ const timeStamp = String(Date.now());
319
+ const mac = this.generateStatusMac(this.config.merchantId, codTrans, timeStamp);
320
+ const res = await this.postApi('/ecomm/api/bo/situazioneOrdine', {
321
+ apiKey: this.config.merchantId,
322
+ codiceTransazione: codTrans,
323
+ timeStamp,
324
+ mac,
325
+ });
326
+ if (!res.ok)
327
+ return { kind: 'error' };
328
+ const json = res.json;
329
+ if (!this.responseAuthentic(json))
330
+ return { kind: 'error' };
331
+ if (json['esito'] === 'KO') {
332
+ return this.classifyApiError(this.apiError(json).codice) === 'notFound'
333
+ ? { kind: 'absent' }
334
+ : { kind: 'error' };
335
+ }
336
+ if (json['esito'] !== 'OK' || !Array.isArray(json['report']))
337
+ return { kind: 'error' };
338
+ const entries = json['report'].filter((r) => r !== null && typeof r === 'object' && r['codiceTransazione'] === codTrans);
339
+ if (entries.length === 0)
340
+ return { kind: 'absent' };
341
+ // Nexi may list a code more than once (it accepts up to 2 retries of a code after a KO): one
342
+ // charged entry means the customer was charged, whatever the order of the entries; one entry
343
+ // that is not final (or not understood) means the code may still be charged
344
+ const statoOf = (e) => typeof e['stato'] === 'string' ? e['stato'] : undefined;
345
+ const classOf = (e) => {
346
+ const stato = statoOf(e)?.trim().toLowerCase();
347
+ if (stato === undefined)
348
+ return 'pending';
349
+ if (NEXI_CHARGED_STATUSES.has(stato))
350
+ return 'paid';
351
+ return NEXI_REFUSED_STATUSES.has(stato) ? 'refused' : 'pending';
352
+ };
353
+ const kind = ['paid', 'pending'].find((k) => entries.some((e) => classOf(e) === k)) ?? 'refused';
354
+ const entry = entries.find((e) => classOf(e) === kind) ?? entries[0];
355
+ const stato = statoOf(entry);
356
+ const amount = (0, parsing_util_1.parseAmountFromCents)(entry['importo']);
357
+ const currency = (0, parsing_util_1.parseNexiCurrency)(entry['divisa']);
358
+ return {
359
+ kind,
360
+ ...(stato !== undefined ? { stato } : {}),
361
+ ...(amount !== undefined ? { amount } : {}),
362
+ ...(currency !== undefined ? { currency } : {}),
363
+ };
364
+ }
124
365
  async createPayment(request) {
125
366
  try {
126
- const amountCents = Math.round(request.amount * 100);
127
- const orderId = request.orderId ?? `ORD-${Date.now()}`;
367
+ // Outcome amounts (redirect, webhook, details) and refunds are read and sent in hundredths:
368
+ // only 2-decimal currencies keep create, read-back and refund on the same scale.
369
+ // Numeric codes ('392') are normalised so they are checked like their alpha-3 code.
370
+ const currencyCode = (0, parsing_util_1.parseNexiCurrency)(request.currency) ?? request.currency;
371
+ if ((0, money_1.currencyExponent)(currencyCode) !== 2) {
372
+ return {
373
+ success: false,
374
+ error: 'Payment currency is not supported by Nexi: only currencies with 2 decimal places are accepted',
375
+ status: 'FAILED',
376
+ };
377
+ }
378
+ const amountCents = (0, money_1.toMinorUnits)(request.amount, currencyCode);
379
+ const orderId = request.orderId ?? (0, ids_1.generateOrderId)();
128
380
  const mac = this.generateMac(orderId, request.currency, amountCents);
129
381
  const params = {
130
382
  alias: this.config.merchantId,
@@ -150,6 +402,9 @@ class NexiProvider extends base_payment_provider_abstract_1.BasePaymentProvider
150
402
  };
151
403
  }
152
404
  catch (error) {
405
+ if ((0, money_1.isInvalidAmountError)(error)) {
406
+ return { success: false, error: error.message, status: 'FAILED' };
407
+ }
153
408
  return this.errorResult(error, 'Failed to create Nexi payment');
154
409
  }
155
410
  }
@@ -163,15 +418,9 @@ class NexiProvider extends base_payment_provider_abstract_1.BasePaymentProvider
163
418
  */
164
419
  async executePayment(paymentId, data) {
165
420
  try {
166
- const esito = data?.esito ?? '';
167
421
  const requestedId = typeof paymentId === 'string' && paymentId !== '' ? paymentId : undefined;
168
- const codTrans = data?.codTrans ?? requestedId ?? '';
169
- const importo = data?.importo ?? '';
170
- const divisa = data?.divisa ?? '';
171
- const dataStr = data?.data ?? '';
172
- const orario = data?.orario ?? '';
173
- const codAut = data?.codAut ?? '';
174
- const mac = data?.mac;
422
+ const fields = this.extractOutcomeFields(data, requestedId);
423
+ const { codTrans, esito, importo, divisa, mac } = fields;
175
424
  if (!mac) {
176
425
  return {
177
426
  success: false,
@@ -181,7 +430,7 @@ class NexiProvider extends base_payment_provider_abstract_1.BasePaymentProvider
181
430
  error: 'MAC missing',
182
431
  };
183
432
  }
184
- if (!this.isValidHexMac(mac) || !this.safeVerifyResponseMac(codTrans, esito, importo, divisa, dataStr, orario, codAut, mac)) {
433
+ if (!this.outcomeMacValid(fields)) {
185
434
  return {
186
435
  success: false,
187
436
  verified: false,
@@ -351,7 +600,6 @@ class NexiProvider extends base_payment_provider_abstract_1.BasePaymentProvider
351
600
  const stato = item.stato;
352
601
  const amount = (0, parsing_util_1.parseAmountFromCents)(item.importo);
353
602
  const currency = (0, parsing_util_1.parseNexiCurrency)(item.divisa);
354
- const NEXI_PAID_STATUSES = new Set(['autorizzato', 'contabilizzato', 'catturato']);
355
603
  const statoStr = typeof stato === 'string' ? stato.toLowerCase() : '';
356
604
  const isPaidStatus = NEXI_PAID_STATUSES.has(statoStr);
357
605
  const isSuccess = isPaidStatus && amount !== undefined && amount > 0 && !!currency && currency.trim() !== '';
@@ -381,7 +629,10 @@ class NexiProvider extends base_payment_provider_abstract_1.BasePaymentProvider
381
629
  async refundPayment(paymentId, amount) {
382
630
  try {
383
631
  const timestamp = new Date().toISOString();
384
- const importo = amount ? Math.round(amount * 100).toString() : '';
632
+ // The refund API carries no currency. createPayment accepts only 2-decimal currencies,
633
+ // so a partial amount is always in hundredths. Only an omitted (undefined) amount means a
634
+ // full refund, as in PayPal: 0 and null are rejected by toMinorUnits like any invalid amount.
635
+ const importo = amount !== undefined ? (0, money_1.toMinorUnits)(amount, 'EUR').toString() : '';
385
636
  const mac = this.generateRefundMac(this.config.merchantId, paymentId, importo, timestamp);
386
637
  const params = {
387
638
  alias: this.config.merchantId,
@@ -412,6 +663,9 @@ class NexiProvider extends base_payment_provider_abstract_1.BasePaymentProvider
412
663
  };
413
664
  }
414
665
  catch (error) {
666
+ if ((0, money_1.isInvalidAmountError)(error)) {
667
+ return { success: false, error: error.message, status: 'FAILED' };
668
+ }
415
669
  return this.errorResult(error, 'Failed to refund Nexi payment');
416
670
  }
417
671
  }
@@ -423,6 +677,416 @@ class NexiProvider extends base_payment_provider_abstract_1.BasePaymentProvider
423
677
  async handleRedirect(query) {
424
678
  return this.executePayment(query['codTrans'] ?? '', query);
425
679
  }
680
+ // ---------------------------------------------------------------------------
681
+ // Recurring payments (mandates) — IMandateProvider
682
+ // ---------------------------------------------------------------------------
683
+ /**
684
+ * Starts a contract registration: a hosted-page first payment with `num_contratto` =
685
+ * `mandateRef`, `tipo_servizio=paga_multi`, `tipo_richiesta=PP` (pp. 59-65), charging the first
686
+ * period. `codTrans` is `orderId`. EUR only. `mandate_ref` is appended to `url`, `url_back` and
687
+ * `urlpost`.
688
+ */
689
+ async createMandate(request) {
690
+ try {
691
+ if (request.currency !== 'EUR') {
692
+ return { success: false, error: EUR_ONLY };
693
+ }
694
+ if (!mandate_model_1.MANDATE_REF_PATTERN.test(request.mandateRef) || !mandate_model_1.CHARGE_REF_PATTERN.test(request.orderId)) {
695
+ return { success: false, error: 'Invalid mandateRef or orderId for Nexi' };
696
+ }
697
+ const importo = (0, money_1.toMinorUnits)(request.amount, 'EUR');
698
+ const codTrans = request.orderId;
699
+ const contractType = this.config.contractType;
700
+ const mac = contractType === undefined
701
+ ? this.generateMac(codTrans, 'EUR', importo)
702
+ : this.sha1(`codTrans=${codTrans}divisa=EURimporto=${importo}tipo_contratto=${contractType}${this.config.macKey}`);
703
+ const params = {
704
+ alias: this.config.merchantId,
705
+ importo: String(importo),
706
+ divisa: 'EUR',
707
+ codTrans,
708
+ url: (0, url_1.appendMandateRef)(request.returnUrl, request.mandateRef),
709
+ url_back: (0, url_1.appendMandateRef)(request.cancelUrl, request.mandateRef),
710
+ mac,
711
+ num_contratto: request.mandateRef,
712
+ tipo_servizio: 'paga_multi',
713
+ tipo_richiesta: 'PP',
714
+ };
715
+ if (contractType !== undefined)
716
+ params.tipo_contratto = contractType;
717
+ if (request.notifyUrl)
718
+ params.urlpost = (0, url_1.appendMandateRef)(request.notifyUrl, request.mandateRef);
719
+ if (this.config.group)
720
+ params.gruppo = this.config.group;
721
+ return {
722
+ success: true,
723
+ approvalUrl: `${this.apiUrl}/ecomm/ecomm/DispatcherServlet?${new URLSearchParams(params)}`,
724
+ pending: {
725
+ provider: this.name,
726
+ mandateRef: request.mandateRef,
727
+ orderId: codTrans,
728
+ amount: request.amount,
729
+ currency: 'EUR',
730
+ providerRef: codTrans,
731
+ createdAt: new Date().toISOString(),
732
+ },
733
+ };
734
+ }
735
+ catch (error) {
736
+ if ((0, money_1.isInvalidAmountError)(error))
737
+ return { success: false, error: error.message };
738
+ if (error instanceof TypeError)
739
+ return { success: false, error: 'Invalid returnUrl, cancelUrl or notifyUrl' };
740
+ return { success: false, error: 'Failed to create Nexi mandate' };
741
+ }
742
+ }
743
+ /**
744
+ * Confirms a setup from the hosted-page outcome: the redirect on `url` (GET query) or the
745
+ * `urlpost` notification (form POST). Pure function of its inputs, hence idempotent.
746
+ *
747
+ * - The 7-field outcome MAC must be valid, and the MAC-covered `codTrans`, `importo` and
748
+ * `divisa` must match `pending`; otherwise the outcome is unverified and non-terminal (a forged
749
+ * callback never consumes the pending state). The unsigned `num_contratto`, when present, must
750
+ * be `pending.mandateRef` (and `tipo_servizio` / `tipo_richiesta`, when present, `paga_multi` /
751
+ * `PP`); the contract number used is always `pending.mandateRef`.
752
+ * - `esito=OK` with `num_contratto`: active mandate, first charge `succeeded` (terminal). An OK
753
+ * without `num_contratto` is unverified and non-terminal (no contract may exist).
754
+ * - `esito` `KO`, `ANNULLO` or `ERRORE`: no mandate, first charge `failed` (terminal). Start a
755
+ * new setup with a fresh `mandateRef` and `orderId`: a `codTrans` is never reused. This
756
+ * requires a terminal that allows a single payment attempt per `codTrans`: on a terminal
757
+ * configured for up to 3 attempts (manual, pp. 32-33), a KO then an OK arrive for the same
758
+ * `codTrans`, and the OK finds the pending state already consumed.
759
+ * - Any other `esito` (e.g. a pending one) is unverified and non-terminal: it may still turn OK.
760
+ *
761
+ * An empty input (`{}`) has no MAC, so a re-confirm is unverified and non-terminal: Nexi never
762
+ * reports a terminal outcome with an `unknown` first charge.
763
+ */
764
+ async confirmMandate(pending, input) {
765
+ const unverified = (error) => ({
766
+ success: false,
767
+ verified: false,
768
+ terminal: false,
769
+ firstCharge: { status: 'unknown', verified: false, chargeRef: pending.orderId, error },
770
+ error,
771
+ });
772
+ try {
773
+ const params = {};
774
+ for (const source of [input?.query, input?.body]) {
775
+ if (source === null || typeof source !== 'object')
776
+ continue;
777
+ for (const [k, v] of Object.entries(source)) {
778
+ if (typeof v === 'string')
779
+ params[k] = v;
780
+ }
781
+ }
782
+ const fields = this.extractOutcomeFields(params);
783
+ if (!this.outcomeMacValid(fields))
784
+ return unverified('Invalid outcome MAC');
785
+ if (fields.codTrans !== pending.orderId) {
786
+ return unverified('Outcome does not match the pending setup');
787
+ }
788
+ if (fields.importo !== String((0, money_1.toMinorUnits)(pending.amount, 'EUR')) || fields.divisa !== 'EUR') {
789
+ return unverified('Outcome amount does not match');
790
+ }
791
+ // Not MAC-covered: only tampering indicators, never the source of the contract number
792
+ if (params['num_contratto'] !== undefined && params['num_contratto'] !== pending.mandateRef) {
793
+ return unverified('Outcome does not match the pending setup');
794
+ }
795
+ if ((params['tipo_servizio'] !== undefined && params['tipo_servizio'] !== 'paga_multi') ||
796
+ (params['tipo_richiesta'] !== undefined && params['tipo_richiesta'] !== 'PP')) {
797
+ return unverified('Outcome does not match the pending setup');
798
+ }
799
+ if (fields.esito === 'OK') {
800
+ // Nexi echoes num_contratto on a contract-registering outcome. Without it, the customer may
801
+ // have stripped the contract parameters from the (unsigned) approvalUrl, so a plain payment
802
+ // was made and no contract may exist: never report that as an active mandate.
803
+ if (params['num_contratto'] === undefined) {
804
+ return unverified('Outcome does not match the pending setup');
805
+ }
806
+ const unverifiedHints = {};
807
+ const scadenzaPan = params['scadenza_pan'];
808
+ if (scadenzaPan !== undefined && /^\d{6}$/.test(scadenzaPan))
809
+ unverifiedHints.cardExpiry = scadenzaPan;
810
+ const pan = params['pan'];
811
+ if (pan !== undefined && /^[0-9*]{8,19}$/.test(pan))
812
+ unverifiedHints.maskedPan = pan;
813
+ const mandate = {
814
+ provider: this.name,
815
+ mandateRef: pending.mandateRef,
816
+ providerMandateId: pending.mandateRef,
817
+ status: 'active',
818
+ createdAt: new Date().toISOString(),
819
+ ...(Object.keys(unverifiedHints).length > 0 ? { unverified: unverifiedHints } : {}),
820
+ };
821
+ return {
822
+ success: true,
823
+ verified: true,
824
+ terminal: true,
825
+ mandate,
826
+ firstCharge: {
827
+ status: 'succeeded',
828
+ verified: true,
829
+ chargeRef: pending.orderId,
830
+ providerPaymentId: pending.orderId,
831
+ amount: pending.amount,
832
+ currency: 'EUR',
833
+ },
834
+ raw: params,
835
+ };
836
+ }
837
+ if (!NEXI_SETUP_REFUSALS.has(fields.esito)) {
838
+ // e.g. a pending outcome: the setup may still turn OK, so the pending state must survive
839
+ return unverified(`Mandate setup outcome ${fields.esito} is not final`);
840
+ }
841
+ const messaggio = params['messaggio'];
842
+ const codiceEsito = params['codiceEsito'];
843
+ return {
844
+ success: false,
845
+ verified: true,
846
+ terminal: true,
847
+ firstCharge: {
848
+ status: 'failed',
849
+ verified: true,
850
+ chargeRef: pending.orderId,
851
+ error: messaggio !== undefined && messaggio !== '' ? messaggio : fields.esito,
852
+ ...(codiceEsito !== undefined ? { providerCode: codiceEsito } : {}),
853
+ },
854
+ error: `Mandate setup failed with outcome ${fields.esito}`,
855
+ raw: params,
856
+ };
857
+ }
858
+ catch {
859
+ return unverified('Mandate outcome could not be verified');
860
+ }
861
+ }
862
+ /**
863
+ * Merchant-initiated charge with `pagamentoRicorrente` (pp. 188-193), EUR only.
864
+ * `codiceTransazione` is `charge.chargeRef`, so `refundPayment(chargeRef, amount)` refunds it.
865
+ *
866
+ * Replay guard: `situazioneOrdine` is read first. A transaction that took the money (paid,
867
+ * partly captured, refunded or partly refunded) is returned as `succeeded` without charging
868
+ * again, with its `stato` in `raw`; one that only Nexi refused (`Negato`, `Annullato`,
869
+ * `Non Creato`) is `failed` (a dunning retry needs a new `chargeRef`: Nexi's own retries of a
870
+ * code after a KO are not used); one still in progress (`In Corso`) or with an unrecognised
871
+ * `stato` is `unknown`, and so is a lookup error: nothing is charged. A Nexi "transaction
872
+ * already present" (9) is re-read.
873
+ * Merchant-side errors (credentials, MAC, technical) are `unknown`, never `failed`.
874
+ */
875
+ async chargeMandate(mandate, charge) {
876
+ const chargeRef = charge.chargeRef;
877
+ const unknown = (error, providerCode) => ({
878
+ status: 'unknown',
879
+ verified: false,
880
+ chargeRef,
881
+ error,
882
+ ...(providerCode !== undefined ? { providerCode } : {}),
883
+ });
884
+ const fromLookup = (lookup) => ({
885
+ status: 'succeeded',
886
+ verified: true,
887
+ chargeRef,
888
+ providerPaymentId: chargeRef,
889
+ ...(lookup.amount !== undefined ? { amount: lookup.amount } : {}),
890
+ ...(lookup.currency !== undefined ? { currency: lookup.currency } : {}),
891
+ ...(lookup.stato !== undefined ? { raw: { stato: lookup.stato } } : {}),
892
+ });
893
+ try {
894
+ if (charge.currency !== 'EUR') {
895
+ return { status: 'failed', verified: false, chargeRef, error: EUR_ONLY };
896
+ }
897
+ if (typeof chargeRef !== 'string' || chargeRef.includes('_')) {
898
+ return { status: 'failed', verified: false, chargeRef, error: 'Nexi codiceTransazione cannot contain "_"' };
899
+ }
900
+ const importo = String((0, money_1.toMinorUnits)(charge.amount, 'EUR'));
901
+ if (importo.length > NEXI_RECURRING_MAX_IMPORTO_DIGITS) {
902
+ // pagamentoRicorrente takes at most 8 digits of cents (pp. 188-193): Nexi would answer KO 1
903
+ // (a merchant-side `unknown`), and a retry with the same chargeRef could never succeed
904
+ return {
905
+ status: 'failed',
906
+ verified: false,
907
+ chargeRef,
908
+ error: 'Amount exceeds the Nexi recurring limit (8 digits of cents)',
909
+ };
910
+ }
911
+ // Replay guard: never charge twice for the same chargeRef
912
+ const lookup = await this.lookupTransaction(chargeRef);
913
+ if (lookup.kind === 'paid')
914
+ return fromLookup(lookup);
915
+ if (lookup.kind === 'refused') {
916
+ return {
917
+ status: 'failed',
918
+ verified: true,
919
+ chargeRef,
920
+ error: `A transaction with this chargeRef already exists: ${lookup.stato ?? 'unknown'}`,
921
+ };
922
+ }
923
+ if (lookup.kind === 'pending') {
924
+ // `In Corso` may still end Autorizzato: a `failed` here would send the app to dunning with a
925
+ // new chargeRef, charging the customer twice
926
+ return unknown(`A transaction with this chargeRef is not final at Nexi: ${lookup.stato ?? 'unknown'}`);
927
+ }
928
+ if (lookup.kind !== 'absent') {
929
+ return unknown('Could not check whether this chargeRef was already charged');
930
+ }
931
+ const numeroContratto = mandate.providerMandateId;
932
+ const timeStamp = String(Date.now());
933
+ const body = {
934
+ apiKey: this.config.merchantId,
935
+ numeroContratto,
936
+ codiceTransazione: chargeRef,
937
+ importo,
938
+ divisa: NEXI_EUR_NUMERIC,
939
+ timeStamp,
940
+ mac: this.generateRecurringMac(numeroContratto, chargeRef, importo, timeStamp),
941
+ };
942
+ if (this.config.group)
943
+ body.codiceGruppo = this.config.group;
944
+ const res = await this.postApi('/ecomm/api/recurring/pagamentoRicorrente', body);
945
+ if (!res.ok)
946
+ return unknown(res.error);
947
+ const json = res.json;
948
+ if (!this.responseAuthentic(json))
949
+ return unknown('Response MAC mismatch');
950
+ if (json['esito'] === 'OK') {
951
+ const raw = {};
952
+ if (json['codiceAutorizzazione'] !== undefined)
953
+ raw.codiceAutorizzazione = json['codiceAutorizzazione'];
954
+ const dettagli = json['dettagliCarta'];
955
+ const expiry = dettagli !== null && typeof dettagli === 'object'
956
+ ? dettagli['expiry']
957
+ : undefined;
958
+ if (typeof expiry === 'string' && /^\d{6}$/.test(expiry))
959
+ raw.cardExpiry = expiry;
960
+ // The response carries no amount: amount and currency are deliberately not set
961
+ return { status: 'succeeded', verified: true, chargeRef, providerPaymentId: chargeRef, raw };
962
+ }
963
+ if (json['esito'] !== 'KO')
964
+ return unknown('Unexpected Nexi outcome');
965
+ const { codice, messaggio } = this.apiError(json);
966
+ const providerCode = codice === undefined || codice === null ? undefined : String(codice);
967
+ const raw = { esito: 'KO', ...(providerCode !== undefined ? { codice: providerCode } : {}) };
968
+ switch (this.classifyApiError(codice)) {
969
+ case 'duplicate': {
970
+ const again = await this.lookupTransaction(chargeRef);
971
+ if (again.kind === 'paid')
972
+ return fromLookup(again);
973
+ return unknown('Transaction already present', providerCode);
974
+ }
975
+ case 'merchant':
976
+ return { ...unknown('Nexi refused the request (merchant-side error)', providerCode), raw };
977
+ case 'mandateUnusable':
978
+ return {
979
+ status: 'failed',
980
+ verified: true,
981
+ chargeRef,
982
+ mandateUnusable: true,
983
+ error: messaggio ?? 'Mandate is no longer usable',
984
+ ...(providerCode !== undefined ? { providerCode } : {}),
985
+ raw,
986
+ };
987
+ default:
988
+ return {
989
+ status: 'failed',
990
+ verified: true,
991
+ chargeRef,
992
+ error: messaggio ?? 'Payment refused',
993
+ ...(providerCode !== undefined ? { providerCode } : {}),
994
+ raw,
995
+ };
996
+ }
997
+ }
998
+ catch (error) {
999
+ if ((0, money_1.isInvalidAmountError)(error)) {
1000
+ return { status: 'failed', verified: false, chargeRef, error: error.message };
1001
+ }
1002
+ return unknown('Charge outcome unknown');
1003
+ }
1004
+ }
1005
+ /**
1006
+ * Re-reads the contract with `dettagliContratto` (pp. 851-853). `active` when it is listed and
1007
+ * its first payment is not KO; `revoked` when its first payment is KO or Nexi answers "not
1008
+ * found" or "invalid contract"; otherwise `unknown` (unverified).
1009
+ */
1010
+ async getMandate(mandate) {
1011
+ try {
1012
+ const numeroContratto = mandate.providerMandateId;
1013
+ const timeStamp = String(Date.now());
1014
+ const mac = this.sha1(`apiKey=${this.config.merchantId}numeroContratto=${numeroContratto}codiceFiscale=` +
1015
+ `dataRegistrazioneDa=dataRegistrazioneA=timeStamp=${timeStamp}${this.config.macKey}`);
1016
+ const res = await this.postApi('/ecomm/api/contratti/dettagliContratto', {
1017
+ apiKey: this.config.merchantId,
1018
+ numeroContratto,
1019
+ codiceFiscale: '',
1020
+ dataRegistrazioneDa: '',
1021
+ dataRegistrazioneA: '',
1022
+ timeStamp,
1023
+ mac,
1024
+ });
1025
+ if (!res.ok)
1026
+ return { status: 'unknown', verified: false };
1027
+ const json = res.json;
1028
+ if (!this.responseAuthentic(json))
1029
+ return { status: 'unknown', verified: false, raw: json };
1030
+ if (json['esito'] === 'OK' && Array.isArray(json['contratti'])) {
1031
+ const entry = json['contratti'].find((c) => c !== null && typeof c === 'object' && c['numeroContratto'] === numeroContratto);
1032
+ if (entry) {
1033
+ const stato = entry['statoPrimoPag'];
1034
+ const ko = typeof stato === 'string' && stato.toUpperCase() === 'KO';
1035
+ return { status: ko ? 'revoked' : 'active', verified: true, raw: json };
1036
+ }
1037
+ }
1038
+ if (json['esito'] === 'KO') {
1039
+ const kind = this.classifyApiError(this.apiError(json).codice);
1040
+ if (kind === 'notFound' || kind === 'mandateUnusable') {
1041
+ return { status: 'revoked', verified: true, raw: json };
1042
+ }
1043
+ }
1044
+ return { status: 'unknown', verified: false, raw: json };
1045
+ }
1046
+ catch {
1047
+ return { status: 'unknown', verified: false };
1048
+ }
1049
+ }
1050
+ /**
1051
+ * Cancels the contract with `cancellaContratto` (pp. 838-840). "Not found" and "invalid
1052
+ * contract" count as revoked: the contract is already gone.
1053
+ */
1054
+ async revokeMandate(mandate) {
1055
+ try {
1056
+ const numeroContratto = mandate.providerMandateId;
1057
+ const timeStamp = String(Date.now());
1058
+ const mac = this.sha1(`apiKey=${this.config.merchantId}numeroContratto=${numeroContratto}timeStamp=${timeStamp}${this.config.macKey}`);
1059
+ const res = await this.postApi('/ecomm/api/contratti/cancellaContratto', {
1060
+ apiKey: this.config.merchantId,
1061
+ numeroContratto,
1062
+ timeStamp,
1063
+ mac,
1064
+ });
1065
+ if (!res.ok)
1066
+ return { revokedAtProvider: false, error: res.error };
1067
+ const json = res.json;
1068
+ if (!this.responseAuthentic(json)) {
1069
+ return { revokedAtProvider: false, error: 'Response MAC mismatch', raw: json };
1070
+ }
1071
+ if (json['esito'] === 'OK')
1072
+ return { revokedAtProvider: true, raw: json };
1073
+ if (json['esito'] === 'KO') {
1074
+ const { codice } = this.apiError(json);
1075
+ const kind = this.classifyApiError(codice);
1076
+ if (kind === 'notFound' || kind === 'mandateUnusable')
1077
+ return { revokedAtProvider: true, raw: json };
1078
+ return {
1079
+ revokedAtProvider: false,
1080
+ error: `Nexi refused the cancellation (code ${codice === undefined ? 'missing' : String(codice)})`,
1081
+ raw: json,
1082
+ };
1083
+ }
1084
+ return { revokedAtProvider: false, error: 'Unexpected Nexi outcome', raw: json };
1085
+ }
1086
+ catch {
1087
+ return { revokedAtProvider: false, error: 'Failed to revoke Nexi mandate' };
1088
+ }
1089
+ }
426
1090
  }
427
1091
  exports.NexiProvider = NexiProvider;
428
1092
  //# sourceMappingURL=nexi.provider.js.map