@agent-cards/sdk 0.21.0 → 0.22.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 (103) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/PREFLIGHT.md +30 -0
  3. package/README.md +1070 -21
  4. package/dist/aci.d.ts +173 -0
  5. package/dist/aci.generated.d.ts +87 -0
  6. package/dist/aci.generated.js +306 -0
  7. package/dist/aci.js +371 -0
  8. package/dist/adyen-merchant-hosted.generated.d.ts +3 -3
  9. package/dist/adyen-merchant-hosted.generated.js +283 -49
  10. package/dist/agentcard.d.ts +9 -2
  11. package/dist/agentcard.js +4 -2
  12. package/dist/amadeus-checkout.generated.d.ts +57 -0
  13. package/dist/amadeus-checkout.generated.js +549 -0
  14. package/dist/amadeus.d.ts +41 -0
  15. package/dist/amadeus.js +81 -0
  16. package/dist/builtin-registry.generated.js +1 -1
  17. package/dist/card-endpoint.d.ts +177 -0
  18. package/dist/card-endpoint.js +286 -0
  19. package/dist/card-fields.generated.d.ts +5 -3
  20. package/dist/card-fields.generated.js +284 -7
  21. package/dist/cdp.d.ts +5 -1
  22. package/dist/cdp.js +841 -135
  23. package/dist/client.d.ts +403 -19
  24. package/dist/client.js +949 -98
  25. package/dist/creditguard.generated.d.ts +79 -0
  26. package/dist/creditguard.generated.js +662 -0
  27. package/dist/cse-body.js +11 -1
  28. package/dist/datatrans.d.ts +96 -0
  29. package/dist/datatrans.generated.d.ts +64 -0
  30. package/dist/datatrans.generated.js +426 -0
  31. package/dist/datatrans.js +178 -0
  32. package/dist/elavon-converge.d.ts +37 -0
  33. package/dist/elavon-converge.generated.d.ts +49 -0
  34. package/dist/elavon-converge.generated.js +439 -0
  35. package/dist/elavon-converge.js +84 -0
  36. package/dist/eprotect.d.ts +152 -0
  37. package/dist/eprotect.generated.d.ts +77 -0
  38. package/dist/eprotect.generated.js +498 -0
  39. package/dist/eprotect.js +222 -0
  40. package/dist/hosted-form.d.ts +3 -2
  41. package/dist/hosted-form.js +3 -2
  42. package/dist/index.d.ts +17 -3
  43. package/dist/index.js +6 -1
  44. package/dist/juspay-gate.d.ts +82 -0
  45. package/dist/juspay-gate.js +126 -0
  46. package/dist/juspay.generated.d.ts +89 -0
  47. package/dist/juspay.generated.js +705 -0
  48. package/dist/lifecycle.d.ts +57 -6
  49. package/dist/lifecycle.js +119 -8
  50. package/dist/merchant-card-endpoints.generated.d.ts +63 -0
  51. package/dist/merchant-card-endpoints.generated.js +1103 -0
  52. package/dist/merchant-hosted.d.ts +4 -1
  53. package/dist/merchant-hosted.js +5 -2
  54. package/dist/merchant-session-probe.d.ts +48 -0
  55. package/dist/merchant-session-probe.js +53 -0
  56. package/dist/merchant-session.generated.d.ts +69 -0
  57. package/dist/merchant-session.generated.js +919 -0
  58. package/dist/payrails.d.ts +133 -0
  59. package/dist/payrails.generated.d.ts +73 -0
  60. package/dist/payrails.generated.js +596 -0
  61. package/dist/payrails.js +305 -0
  62. package/dist/pelecard.d.ts +32 -0
  63. package/dist/pelecard.generated.d.ts +32 -0
  64. package/dist/pelecard.generated.js +283 -0
  65. package/dist/pelecard.js +42 -0
  66. package/dist/pre-claim.d.ts +72 -2
  67. package/dist/pre-claim.js +857 -5
  68. package/dist/preflight-catalog.json +2218 -287
  69. package/dist/preflight-schemas.json +179 -6
  70. package/dist/preflight.generated.d.ts +1 -1
  71. package/dist/preflight.generated.js +233 -4
  72. package/dist/preparation.d.ts +5 -2
  73. package/dist/preparation.js +36 -10
  74. package/dist/prepared-processor.d.ts +84 -5
  75. package/dist/prepared-processor.js +147 -6
  76. package/dist/primer.d.ts +53 -0
  77. package/dist/primer.generated.d.ts +38 -0
  78. package/dist/primer.generated.js +624 -0
  79. package/dist/primer.js +82 -0
  80. package/dist/registry.d.ts +100 -8
  81. package/dist/registry.js +85 -11
  82. package/dist/shiji-sts.d.ts +144 -0
  83. package/dist/shiji-sts.generated.d.ts +72 -0
  84. package/dist/shiji-sts.generated.js +913 -0
  85. package/dist/shiji-sts.js +222 -0
  86. package/dist/stripe-checkout.generated.d.ts +1 -1
  87. package/dist/stripe-checkout.generated.js +699 -9
  88. package/dist/stripe-setup.d.ts +16 -0
  89. package/dist/stripe-setup.js +61 -0
  90. package/dist/substitutions.generated.js +187 -29
  91. package/dist/verifone.d.ts +68 -0
  92. package/dist/verifone.generated.d.ts +51 -0
  93. package/dist/verifone.generated.js +1401 -0
  94. package/dist/verifone.js +119 -0
  95. package/dist/voice-types.d.ts +27 -0
  96. package/dist/voice.d.ts +6 -1
  97. package/dist/voice.js +6 -1
  98. package/dist/wspay.d.ts +41 -0
  99. package/dist/wspay.generated.d.ts +41 -0
  100. package/dist/wspay.generated.js +370 -0
  101. package/dist/wspay.js +65 -0
  102. package/examples/preflight/kernel-native/inventory.json +1 -1
  103. package/package.json +3 -3
package/dist/client.js CHANGED
@@ -1,11 +1,19 @@
1
- import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, LOOPBACK_URL_PATTERNS, findRecognizer, recognizerRequiresPreparation, } from './registry.js';
1
+ import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, LOOPBACK_URL_PATTERNS, findRecognizer, isRecognizerRequest, recognizerRequestMethod, recognizerRequiresPreparation, urlsOnProcessorHosts, } from './registry.js';
2
2
  import { armServedProfiles, classifyProfileRequest, declareSandboxMerchant, merchantProfileEnvironment, merchantProfileFor, merchantProfileUrlPatterns, } from './merchant-hosted.js';
3
3
  import { merchantAmountExchanges, merchantAmountFromResponses, merchantProfilePricedBySource, merchantTotalWire, MerchantTotalWait, } from './merchant-total.js';
4
+ import { ACI_CAPABILITY, ACI_PSP, aciCardLegAnswer, redactAciCheckoutId } from './aci.js';
5
+ import { EPROTECT_CAPABILITY, EPROTECT_PSP, eprotectCheckoutRefusal, eprotectScriptAnswer, isEprotectRegistration } from './eprotect.js';
4
6
  import { isMercadoTokenRequest, parseMercadoCheckoutContext, validateMercadoProcessorContext } from './mercado-checkout.generated.js';
5
7
  import { matchesPreparation, preparationMode, validPreparationEnvironment } from './prepared-processor.js';
6
8
  import { fiservKeyPinById, isFiservSubstitution, requiresFiservPreparation } from './fiserv.js';
9
+ import { armServedVerifoneProfiles, classifyVerifoneProfileRequest, isVerifoneProfileEntry, isVerifoneSubstitution, verifoneProfileFor, verifoneProfileUrlPatterns, } from './verifone.js';
7
10
  import { hasOwnedShopMarker, parseOwnedShopOrder, parseOwnedShopReceipt } from './owned-shop.generated.js';
8
11
  import { requestWithoutCard } from './card-fields.generated.js';
12
+ import { merchantSessionCapture } from './merchant-session-probe.js';
13
+ import { creditguardPageProcessUrl, creditguardPpDomainHost, creditguardPreparationEnvironment, creditguardProcessTxId, sanitizeCreditguardResponse, } from './creditguard.generated.js';
14
+ import { CONVERGE_CAPABILITY } from './elavon-converge.js';
15
+ import { SHIJI_STS_CAPABILITY, SHIJI_STS_PSP, isShijiStsServedCompanion, isShijiStsServedSubstitution } from './shiji-sts.js';
16
+ import { AMADEUS_CHECKOUT_CAPABILITY } from './amadeus-checkout.generated.js';
9
17
  import { classifyStripeCheckoutRequest, encodeStripeCheckoutContext, hasStripeCheckoutMarker, parseStripeCheckoutContext, parseStripeCheckoutResponse, STRIPE_CHECKOUT_CONTEXT_HEADER, } from './stripe-checkout.generated.js';
10
18
  import { VoiceModule } from './voice.js';
11
19
  import { BuyModule } from './buy.js';
@@ -15,12 +23,21 @@ import { AgentcardError } from './voice-types.js';
15
23
  import { GMO_CAPABILITY, GMO_PSP, gmoTokenAnswer } from './gmo.js';
16
24
  import { isCybersourceFlexTokenShape } from './cybersource-flex.generated.js';
17
25
  import { CYBERSOURCE_FLEX_CAPABILITY, CYBERSOURCE_FLEX_PSP, validCybersourceFlexSidecar } from './cybersource-flex.js';
26
+ import { JUSPAY_CAPABILITY, JUSPAY_PSP, juspayPageAnswer } from './juspay-gate.js';
27
+ import { CARD_ENDPOINT_MODE, CARD_ENDPOINT_PSP, CardEndpointRefusedError, cardEndpointById, cardEndpointLive, cardEndpointSessionRefusal, } from './card-endpoint.js';
28
+ import { DATATRANS_CAPABILITY } from './datatrans.js';
29
+ import { PELECARD_CAPABILITY, PELECARD_PSP } from './pelecard.js';
30
+ import { PRIMER_CAPABILITY, PRIMER_PSP, primerTokenAnswer } from './primer.js';
31
+ import { PAYRAILS_CAPABILITY, payrailsSaveAnswer } from './payrails.js';
32
+ import { WSPAY_CAPABILITY, WSPAY_PSP } from './wspay.js';
18
33
  /**
19
34
  * The modes this SDK can finish. Asked for on syncRegistry (the API serves
20
35
  * only recognizers in these modes, so a request this build cannot complete
21
- * is never paused) and sent on every create.
36
+ * is never paused) and sent on every create. `device_encrypt`: the adapters
37
+ * capture the Payrails session key the page's init payload names (payrails.ts),
38
+ * and the cardholder's device encrypts and sends the save.
22
39
  */
23
- export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
40
+ export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form', 'device_encrypt'];
24
41
  /**
25
42
  * Registry features this SDK honours, asked for on syncRegistry next to the
26
43
  * modes. `card_fields`: it reads a recognizer's cardFields and claims only a
@@ -50,35 +67,240 @@ export const SUPPORTED_REGISTRY_FEATURES = ['card_fields', 'checkout_sessions'];
50
67
  * serves Flex's recognizer the same way, only for a company Agentcard turned it on for.
51
68
  * A gated recognizer is never in BUILTIN_REGISTRY, so a build reaches it only through a
52
69
  * sync that declares its capability.
70
+ * `juspay_txns.v1`: Juspay's card flow (juspay-gate.ts), whose page tokenize
71
+ * this build answers locally and whose /txns it pauses, served and refused by
72
+ * the same rule. A gated recognizer is never in BUILTIN_REGISTRY, so a build
73
+ * reaches it only through a sync that declares its capability.
74
+ * `datatrans_secure_fields_tokenize`: Datatrans Secure Fields' TOKENIZE card
75
+ * post, which this build judges before it pauses, under the card-on-file
76
+ * policy served with the entry, and answers again for a repeat of the same
77
+ * form (datatrans.ts). A gated recognizer never sits in BUILTIN_REGISTRY, so
78
+ * it is paused only after a sync.
79
+ * `authorize_net_dpm_form`: the Authorize.Net Direct Post Method and SIM card
80
+ * form, one form navigation to transact.dll that the cardholder's device submits
81
+ * itself (hosted_form). This build finishes it with the hosted_form flow it
82
+ * already speaks, holds the bare endpoint the entry names (bareEndpoint in
83
+ * findRecognizer), and reads the form's keys without regard to case
84
+ * (card-fields.js). The API serves it, like Fiserv's, only to an org Agentcard
85
+ * enabled for that processor; every other org is served the ungated catalog.
86
+ * `pelecard_transaction`: Pelecard's card submission, which this build judges
87
+ * before it pauses (pelecard.ts) and pays only for the amount the agent passes.
88
+ * Declaring it is not enough: the API serves a gated recognizer only to an org
89
+ * Agentcard has enabled for that processor, so for every other org this build
90
+ * syncs the same catalog as one that declares nothing and leaves the processor's
91
+ * requests alone. A gated recognizer never sits in BUILTIN_REGISTRY, so it is
92
+ * paused only after a sync.
93
+ * `aci_split_card.v1`: ACI's COPYandPAY widget, whose card reaches ACI as
94
+ * several requests to one checkout. This build pauses the card-number request
95
+ * for a prepared checkout, holds the page's own CVC and expiry requests and
96
+ * answers them with that request's answer, and continues the page's /payment
97
+ * submission once, with the placeholder expiry removed (aci.ts). A gated
98
+ * recognizer is never in BUILTIN_REGISTRY, so a build reaches it only through a
99
+ * sync that declares its capability, and whether an org may pay through it
100
+ * stays the API's decision.
101
+ * `creditguard_pps_process`: CreditGuard's hosted payment page (Hyp PPS), whose
102
+ * card submit this build pays only under a prepared checkout that names the
103
+ * framed page (prepare({ psp: 'creditguard', environment, paymentPageUrl })). The
104
+ * API serves CreditGuard's recognizer on the same terms as Fiserv's.
105
+ * `primer_payment_instruments`: Primer's card tokenization, which this build
106
+ * judges before it pauses (primer.ts) and hands back to the page only as a
107
+ * single-use token. A merchant's client session can save the card after the
108
+ * payment with no sign in that request, so the API creates a Primer
109
+ * authorization only for a test-mode client and refuses a live one (400
110
+ * invalid_primer_request, live_keep_on_file) until the cardholder can consent
111
+ * to that save; this build then aborts the paused request with nothing sent. A
112
+ * gated recognizer never sits in BUILTIN_REGISTRY, so it is paused only after a
113
+ * sync.
114
+ * `payrails_device_encrypt`: Payrails' card-form save, which this build pauses
115
+ * only once it has bound the save to a captured session (payrails.ts). A gated
116
+ * recognizer never sits in BUILTIN_REGISTRY, so it is paused only after a sync.
117
+ * `converge_hpp_process`: Elavon Converge's Hosted Payment Page card request
118
+ * (elavon-converge.ts), paused in token mode and judged by the pre-claim gate.
119
+ * A gated recognizer never sits in BUILTIN_REGISTRY, so it is paused only after a
120
+ * sync, and the API also refuses a Converge payment for every company the
121
+ * processor is off for.
122
+ * `worldpay_eprotect_jsonp`: Worldpay eProtect, whose card rides in the query of
123
+ * a JSONP <script> GET that this build pauses, binds to a prepared checkout and
124
+ * answers with the registration in the script's own callback (eprotect.ts). A
125
+ * gated recognizer is never in BUILTIN_REGISTRY, so a build reaches either only
126
+ * through a sync that declares its capability, and the API serves it on that
127
+ * sync only for a company it has turned the processor on for.
128
+ * `wspay_confirm`: WSPay Components' card confirm, which this build judges
129
+ * before it pauses (wspay.ts) and pays only for the amount the agent passes.
130
+ * The API serves a gated recognizer only to a build that lists its capability
131
+ * and only for a company Agentcard turned that processor on for, so an older
132
+ * SDK never pauses a request it cannot finish and no SDK pauses one for a
133
+ * company the processor is off for; the API also refuses that processor's
134
+ * payment for every such company. A gated recognizer never sits in
135
+ * BUILTIN_REGISTRY, so it is paused only after a sync.
136
+ * `shiji_sts_pinned_key`: this build swaps Shiji STS's ciphertexts at the body
137
+ * root under the pinned key, binds the getCvvId companion to the getTokenId it
138
+ * continued, and refuses every other Shiji request in the browser (shiji-sts.ts).
139
+ * The API serves it on the same terms, only for a company Agentcard turned Shiji
140
+ * STS on for.
141
+ * `amadeus_checkout_actions.v1`: Amadeus Checkout (the Checkout Web SDK add and
142
+ * the Data Collection Page store), served on the same terms. This build lets every
143
+ * other action on those URLs through without claiming a preparation, so the page's
144
+ * own `load` never spends or blocks one, takes the card request only through a
145
+ * prepared checkout bound to the approved page's host, and fails a paused SDK add
146
+ * the device has not started 20 seconds after the pause (AMADEUS_SEND_WINDOW_MS).
147
+ * `worldline_mycheckout_form`: Worldline MyCheckout's card form (DHL Express),
148
+ * a top-level form POST this build holds like Tranzila's (mode hosted_form) and
149
+ * answers with the submission notice once the cardholder's device submitted the
150
+ * form itself. The page's cancel and its other payment methods on the same URL
151
+ * carry no card and continue untouched. The API serves the recognizer only to a
152
+ * build that lists it and only for a company Agentcard turned it on for.
153
+ * `verifone_card_capture`: a reviewed Verifone merchant's own card request (a
154
+ * verifone.js blob sent to the merchant's server), which this build pauses and pays
155
+ * only through a prepared checkout (verifone.ts); the API serves the Verifone
156
+ * profiles only to a build that lists it, for a company Agentcard turned Verifone on for.
53
157
  */
54
- export const SUPPORTED_CAPABILITIES = ['fiserv_card_capture', GMO_CAPABILITY, CYBERSOURCE_FLEX_CAPABILITY];
158
+ export const SUPPORTED_CAPABILITIES = ['fiserv_card_capture', GMO_CAPABILITY, CYBERSOURCE_FLEX_CAPABILITY, JUSPAY_CAPABILITY, DATATRANS_CAPABILITY, 'authorize_net_dpm_form', PELECARD_CAPABILITY, ACI_CAPABILITY, 'creditguard_pps_process', PRIMER_CAPABILITY, PAYRAILS_CAPABILITY, CONVERGE_CAPABILITY, EPROTECT_CAPABILITY, WSPAY_CAPABILITY, SHIJI_STS_CAPABILITY, AMADEUS_CHECKOUT_CAPABILITY, 'worldline_mycheckout_form', 'verifone_card_capture'];
55
159
  /**
56
160
  * Processors whose paused request names no amount at all, so the pair the
57
161
  * agent passes is the amount the cardholder approves: authorize() refuses one
58
162
  * without it (AmountRequiredError), and the API answers 400 amount_required
59
163
  * for the same case. GMO's card token request carries none: the merchant's
60
- * server charges the token later, for the amount it decides.
164
+ * server charges the token later, for the amount it decides. Pelecard's card
165
+ * submission carries none either: the merchant's server bound the total, the
166
+ * currency and the action type to the payment page when it created it, and no
167
+ * Pelecard answer states them. WSPay's confirm carries none either: WSPay fixed
168
+ * the amount when the merchant's server created the payment, so the agent's
169
+ * pair is also the figure the API compares with WSPay's answer when that
170
+ * answer names one. A WSPay 3-D Secure step answers with no amount, so on that
171
+ * path nothing Agentcard sees confirms what WSPay charges once the challenge
172
+ * completes.
61
173
  */
62
- const AGENT_AMOUNT_PROCESSORS = [GMO_PSP];
174
+ const AGENT_AMOUNT_PROCESSORS = [GMO_PSP, PELECARD_PSP, WSPAY_PSP];
63
175
  /** How AmountRequiredError names a processor. */
64
- const PROCESSOR_NAMES = { [GMO_PSP]: 'GMO' };
176
+ const PROCESSOR_NAMES = { [GMO_PSP]: 'GMO', [PELECARD_PSP]: 'Pelecard', [WSPAY_PSP]: 'WSPay' };
177
+ /**
178
+ * Processors whose request and answer name no amount at all, so the total the
179
+ * payment page shows (`pageAmount`) is the one figure that ties the agent's pair
180
+ * to what the page charges: authorize() refuses one without it
181
+ * (PageAmountRequiredError), and the API answers 400 page_amount_required for the
182
+ * same case. Pelecard only.
183
+ */
184
+ const PAGE_AMOUNT_PROCESSORS = [PELECARD_PSP];
65
185
  /** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
66
186
  export function validAmountInput(amount) {
67
187
  if (typeof amount === 'number')
68
188
  return Number.isSafeInteger(amount) && amount >= 0;
69
189
  return typeof amount === 'string' && /^\d{1,15}\.\d{1,3}$/.test(amount.trim());
70
190
  }
191
+ // The API reads an amount with Stripe's decimal places, which are not always ISO 4217's: MGA has
192
+ // none, IQD and LYD have two. Kept equal to payment-core's stripeMinorUnitDigits by a test.
193
+ const ZERO_DECIMAL_CURRENCIES = ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'];
194
+ const THREE_DECIMAL_CURRENCIES = ['BHD', 'JOD', 'KWD', 'OMR', 'TND'];
195
+ /** Digits after the point when the API reads an amount in `currency`. */
196
+ export function amountDigits(currency) {
197
+ const code = currency.trim().toUpperCase();
198
+ return ZERO_DECIMAL_CURRENCIES.includes(code) ? 0 : THREE_DECIMAL_CURRENCIES.includes(code) ? 3 : 2;
199
+ }
200
+ /** `minor` in the forms the API accepts, labelled with the amount it means; null when the API refuses it. */
201
+ function amountOption(minor, currency) {
202
+ const digits = amountDigits(currency);
203
+ // A three-decimal amount ends in 0 in the smallest unit (5120 for 5.120 KWD), as the API requires.
204
+ if (!Number.isSafeInteger(minor) || minor < 0 || (digits === 3 && minor % 10 !== 0))
205
+ return null;
206
+ const code = currency.trim().toUpperCase();
207
+ if (digits === 0)
208
+ return `For ${minor} ${code}, send the number ${minor}.`;
209
+ const padded = String(minor).padStart(digits + 1, '0');
210
+ const decimal = `${padded.slice(0, -digits)}.${padded.slice(-digits)}`;
211
+ return `For ${decimal} ${code}, send the number ${minor} or the string "${decimal}".`;
212
+ }
213
+ /** Every amount `figure` can mean in `currency`, each in the forms the API accepts. */
214
+ function amountOptions(figure, currency) {
215
+ const digits = amountDigits(currency);
216
+ const whole = /^\d{1,15}$/.test(figure);
217
+ const decimal = /^(\d{1,15})\.(\d{1,3})$/.exec(figure);
218
+ // Digits alone can mean the smallest unit or normal units, so both readings are offered, each
219
+ // labelled with the amount it means, and the agent picks the one it intended.
220
+ const readings = whole ? (digits === 0 ? [Number(figure)] : [Number(figure), Number(figure) * 10 ** digits])
221
+ // More digits after the point than the currency has (23.065 dollars) is no amount at all.
222
+ : decimal && (decimal[2].length <= digits || Number(decimal[2].slice(digits)) === 0)
223
+ ? [Number(decimal[1] + decimal[2].padEnd(digits, '0').slice(0, digits))] : [];
224
+ return readings.map(minor => amountOption(minor, currency)).filter((option) => option !== null);
225
+ }
226
+ /**
227
+ * Why an amount is refused, worded for the agent that sent it: the rule, the value it passed, and
228
+ * each amount that value can mean, in the forms the API accepts. An agent told only the rule sends
229
+ * the same value again, and a tool harness that serializes every argument as text turns 2667 into
230
+ * "2667" on each try. A value that does not read as a price (long, or more digits than any amount)
231
+ * is described and never echoed, so a card number typed into the wrong field cannot reach a log
232
+ * line. A value below zero is named as such and never offered back as a positive amount.
233
+ */
234
+ export function amountRefusal(amount, currency) {
235
+ const rule = 'amount is an integer in the currency\'s smallest unit (2306 for $23.06), or a decimal string in normal units ("23.06").';
236
+ // Ten digits is $99,999,999.99 in cents, and no more of a card number than its first six and last four.
237
+ const readable = typeof amount === 'string' ? amount.length <= 24 && (amount.match(/\d/g)?.length ?? 0) <= 10
238
+ : typeof amount === 'number' && Number.isFinite(amount) && Math.abs(amount) < 1e10;
239
+ if (!readable) {
240
+ const kind = typeof amount === 'string' ? 'a string' : typeof amount === 'number' ? 'a number' : amount === null ? 'null' : `a value of type ${typeof amount}`;
241
+ return `${rule} You passed ${kind}${typeof amount === 'string' || typeof amount === 'number' ? ' that does not read as an amount' : ''}.`;
242
+ }
243
+ const got = typeof amount === 'string' ? `the string ${JSON.stringify(amount)}` : `the number ${amount}`;
244
+ const code = typeof currency === 'string' && /^[a-z]{3}$/i.test(currency.trim()) ? currency.trim() : null;
245
+ // The figure inside a display string: "$23.06", "USD 23.06" and "23.06 usd" read as 23.06.
246
+ let figure = String(amount).trim().replace(/^[$€£¥₹]\s*/, '');
247
+ if (code)
248
+ figure = figure.replace(new RegExp(`^${code}\\s*|\\s*${code}$`, 'gi'), '');
249
+ if (figure.startsWith('-'))
250
+ return `${rule} You passed ${got}, which is below zero.`;
251
+ const options = code ? amountOptions(figure, code) : [];
252
+ return `${rule} You passed ${got}.${options.length ? ` ${options.join(' ')}` : ''}`;
253
+ }
71
254
  const AMOUNT_AUTHORITIES = ['processor', 'agent', 'page', 'none'];
72
255
  export class CheckoutPreparationError extends Error {
73
256
  preparationId;
74
257
  reason;
75
- constructor(preparationId, reason) {
76
- super(`Checkout preparation unavailable: ${reason}`);
258
+ /**
259
+ * The API's own reason code under `reason`, when it gave one: a CreditGuard
260
+ * transaction refused as `creditguard_transaction_refused` names what the
261
+ * Vault cannot finish here (`three_d_secure`, `otp`, `installments`, ...).
262
+ */
263
+ detail;
264
+ /** `explanation` follows the reason in the message: what the caller passed and what to send instead. */
265
+ constructor(preparationId, reason, detail, explanation) {
266
+ super(`Checkout preparation unavailable: ${reason}${detail ? ` (${detail})` : ''}${explanation ? `. ${explanation}` : ''}`);
77
267
  this.preparationId = preparationId;
78
268
  this.reason = reason;
79
269
  this.name = 'CheckoutPreparationError';
270
+ if (detail)
271
+ this.detail = detail;
80
272
  }
81
273
  }
274
+ /**
275
+ * Why a preparation's payment page is refused before any request, or null. CreditGuard needs the page
276
+ * the checkout framed, on a host of the preparation's environment (payment-core's own reading of it,
277
+ * the one the API applies); no other processor takes one.
278
+ */
279
+ function paymentPageRefusal(input) {
280
+ if (input.psp !== 'creditguard')
281
+ return input.paymentPageUrl === undefined ? null : 'payment_page_unsupported';
282
+ if (input.paymentPageUrl === undefined)
283
+ return 'payment_page_required';
284
+ const processUrl = creditguardPageProcessUrl(input.paymentPageUrl);
285
+ if (processUrl === null)
286
+ return 'unsupported_payment_page';
287
+ return creditguardPreparationEnvironment(new URL(processUrl).hostname) === input.environment ? null : 'payment_page_environment_mismatch';
288
+ }
289
+ /**
290
+ * The API's refusals of a CreditGuard preparation, answered before any row or prompt: each names
291
+ * something the caller can act on (a page the Vault cannot finish, an amount CreditGuard does not
292
+ * hold, a company not enabled), so it is reported as its own reason rather than as an unconfirmed
293
+ * preparation. A transaction refusal carries CreditGuard's reason as `detail`.
294
+ */
295
+ const CREDITGUARD_PREPARATION_REFUSALS = ['payment_page_required', 'unsupported_payment_page',
296
+ 'sandbox_host_on_live_row', 'live_host_on_sandbox_row', 'creditguard_transaction_refused', 'creditguard_transaction_unavailable',
297
+ 'amount_mismatch', 'processor_unavailable'];
298
+ function creditguardPreparationRefusal(error) {
299
+ if (!(error instanceof CheckoutApiError) || error.code === null || !CREDITGUARD_PREPARATION_REFUSALS.includes(error.code))
300
+ return null;
301
+ const reason = error.details.reason;
302
+ return new CheckoutPreparationError(null, error.code, typeof reason === 'string' && /^[a-z0-9_]{1,64}$/.test(reason) ? reason : undefined);
303
+ }
82
304
  export class CardEncryptedError extends Error {
83
305
  psp;
84
306
  constructor(psp) {
@@ -164,10 +386,15 @@ export class PreparationRequiredError extends Error {
164
386
  /**
165
387
  * The paused request names no amount and none was passed. A GMO card token
166
388
  * request carries no amount (the merchant's server charges the token later),
167
- * so the `amount` and `currency` you pass are what the cardholder approves.
168
- * Thrown locally before any create or prompt, and terminal: the same request
169
- * without an amount fails the same way. The API answers 400 amount_required
170
- * for the same case. A page total (`pageAmount`) does not count.
389
+ * and neither does a Pelecard card submission (the merchant's server bound it
390
+ * to the payment page when it created it) or a WSPay confirm (WSPay fixed it
391
+ * when the merchant's server created the payment), so the `amount` and
392
+ * `currency` you pass are what the cardholder approves, and for WSPay the
393
+ * figure the API compares with WSPay's answer when that answer names one (a
394
+ * 3-D Secure step names none). Thrown locally before any create or
395
+ * prompt, and terminal: the same request without an amount fails the same
396
+ * way. The API answers 400 amount_required for the same case. A page total
397
+ * (`pageAmount`) does not count.
171
398
  */
172
399
  export class AmountRequiredError extends Error {
173
400
  psp;
@@ -179,6 +406,25 @@ export class AmountRequiredError extends Error {
179
406
  this.name = 'AmountRequiredError';
180
407
  }
181
408
  }
409
+ /**
410
+ * The paused request names no amount, and the total its payment page shows
411
+ * (`pageAmount`) was not passed. Pelecard's merchant bound the total to the
412
+ * payment page when it created it, and no Pelecard request or answer states
413
+ * it, so the page's total is the one figure that ties the `amount` the
414
+ * cardholder approves to what the page charges. Thrown locally before any
415
+ * create or prompt; the API answers 400 page_amount_required for the same
416
+ * case. Not terminal: the page's next submission reads the total again.
417
+ */
418
+ export class PageAmountRequiredError extends Error {
419
+ psp;
420
+ code = 'page_amount_required';
421
+ constructor(psp) {
422
+ const processor = psp === PELECARD_PSP ? 'Pelecard' : psp;
423
+ super(`A ${processor} payment request names no amount, so pass the total its payment page shows as pageAmount beside amount and currency. Nothing was created and nothing was charged.`);
424
+ this.psp = psp;
425
+ this.name = 'PageAmountRequiredError';
426
+ }
427
+ }
182
428
  export class ApprovalTimeoutError extends Error {
183
429
  constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
184
430
  }
@@ -522,10 +768,13 @@ export class CheckoutApiError extends Error {
522
768
  * already has, or already answered, the prompt for this submission, and
523
769
  * once that prior authorization is declined or expired the same form is a
524
770
  * new question. The adapters quiet the page's re-post the way they quiet a
525
- * decline instead of latching the attachment.
771
+ * decline instead of latching the attachment. NOT a stale form fingerprint
772
+ * either (isStaleFingerprint): the bytes of that form are refused for good,
773
+ * but the merchant's page renders a fresh fingerprint on reload, so the
774
+ * page's next form is a new question and is quieted the same way.
526
775
  */
527
776
  get permanent() {
528
- if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission')
777
+ if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission' || this.isStaleFingerprint)
529
778
  return false;
530
779
  // Nor a refusal by the company preset (a 403 carrying `preset` in the
531
780
  // envelope): the company's rules refused THIS purchase, at this merchant,
@@ -535,6 +784,16 @@ export class CheckoutApiError extends Error {
535
784
  return false;
536
785
  return this.status >= 400 && this.status < 500 && this.status !== 429;
537
786
  }
787
+ /**
788
+ * True for a 400 `hosted_form_field_refused` whose reason is
789
+ * `stale_fingerprint`: an Authorize.Net DPM or SIM form signed more than 40
790
+ * minutes before the create, or ahead of the API's clock. Authorize.Net
791
+ * would refuse that fingerprint itself, and the page renders a fresh one
792
+ * when it reloads, so the refusal holds for this form only.
793
+ */
794
+ get isStaleFingerprint() {
795
+ return this.status === 400 && this.code === 'hosted_form_field_refused' && this.details.reason === 'stale_fingerprint';
796
+ }
538
797
  }
539
798
  /** The client-credentials exchange was refused: the RFC 6749 error, with the status the token endpoint answered. */
540
799
  export class CheckoutAuthError extends Error {
@@ -582,18 +841,46 @@ function parseErrorEnvelope(bodyText) {
582
841
  }
583
842
  return { code: null, details: {} };
584
843
  }
844
+ const CREDITGUARD_HOST = /(?:^|\.)creditguard\.co\.il\.?$/i;
845
+ function maskCreditguardTxId(path) {
846
+ return path.replace(/(\/txn\/)[^/]+/gi, '$1{txId}')
847
+ .replace(/[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi, '{txId}');
848
+ }
849
+ /**
850
+ * Hosts that put a per-payment secret in the PATH: Monri Components and
851
+ * WSPay's build of it name the payment's client_secret in
852
+ * /v2/payment/{client_secret}/confirm (and /show, /status), and with the page's
853
+ * public token that secret confirms the payment and reads its status.
854
+ */
855
+ const PATH_SECRET_HOSTS = ['proxy.wspay.biz', 'proxy-test.wspay.biz', 'ipg.monri.com', 'ipgtest.monri.com'];
856
+ /** The segment after a `payment` segment, in any case (the WSPay proxy is IIS) or after an escaped slash. */
857
+ const PATH_SECRET = /(\/payment(?:\/|%2f))[^/]*/gi;
585
858
  /**
586
859
  * A URL as it may appear in an error message: origin + path only. A paused
587
860
  * request's URL can carry a client secret in its query string, and error
588
861
  * messages travel further than anyone intends (onEvent, logs, crash reports).
862
+ * CreditGuard puts one in the path: on its hosts the transaction id after
863
+ * /txn/ is that transaction's only credential (payment-core's creditguard.js),
864
+ * so it reads {txId}, as does any other id-shaped segment there.
865
+ * On the Monri and WSPay hosts the secret is a path segment, so it is replaced
866
+ * with `{client_secret}`: https://proxy.wspay.biz/v2/payment/{client_secret}/confirm.
867
+ * A string that is not a URL keeps the part before any `?` or `#`, with the
868
+ * same segment masked.
589
869
  */
590
870
  export function redactUrl(raw) {
871
+ // An ACI checkout id is that checkout's only capability; it never rides on an event.
872
+ const aci = redactAciCheckoutId(raw);
873
+ if (aci !== null)
874
+ return aci;
591
875
  try {
592
876
  const u = new URL(raw);
593
- return `${u.origin}${u.pathname}`;
877
+ // A trailing dot names the same host, so it cannot let the secret through.
878
+ const path = PATH_SECRET_HOSTS.includes(u.hostname.replace(/\.$/, '')) ? u.pathname.replace(PATH_SECRET, '$1{client_secret}') : u.pathname;
879
+ return `${u.origin}${CREDITGUARD_HOST.test(u.hostname) ? maskCreditguardTxId(path) : path}`;
594
880
  }
595
881
  catch {
596
- return raw.split(/[?#]/)[0];
882
+ const bare = raw.split(/[?#]/)[0].replace(PATH_SECRET, '$1{client_secret}');
883
+ return /creditguard\.co\.il/i.test(bare) ? maskCreditguardTxId(bare) : bare;
597
884
  }
598
885
  }
599
886
  /**
@@ -625,6 +912,13 @@ const FISERV_PREPARATION_REFUSALS = [
625
912
  'processor_unavailable', 'unsupported_checkout', 'merchant_profile_required', 'unknown_merchant_profile',
626
913
  'merchant_profile_key_changed', 'fiserv_key_refused', 'sandbox_host_on_live_row', 'live_host_on_sandbox_row',
627
914
  ];
915
+ /**
916
+ * The API's refusals of a Worldpay eProtect preparation, before any preparation exists, that
917
+ * prepareCheckout surfaces by name: a live checkout's merchant is not the name the reviewed pin for
918
+ * its paypageId carries (`merchant_profile_mismatch`, which this build also refuses itself when it
919
+ * holds that pin). Only for an eProtect checkout.
920
+ */
921
+ const EPROTECT_PREPARATION_REFUSALS = ['merchant_profile_mismatch'];
628
922
  /** Default backoff for a 502 amount_unverifiable at create: two retries, then give up. */
629
923
  const UNVERIFIABLE_RETRY_DELAYS_MS = [500, 1500];
630
924
  const PREPARATION_READ_RETRY_DELAYS_MS = [250, 500];
@@ -638,6 +932,20 @@ function transientReadNetworkError(error) {
638
932
  const code = error.cause?.code ?? error.code;
639
933
  return typeof code === 'string' && TRANSIENT_READ_NETWORK_CODES.has(code);
640
934
  }
935
+ /**
936
+ * Processors whose payment without a card can spend a token the processor
937
+ * minted from the approved card. Nuvei answers a card it is handed (Fields
938
+ * getToken, or either leg of createPayment) with a ccTempToken, and can save
939
+ * it for a userPaymentOptionId; a clientPayment.do carrying only one of those
940
+ * pays with the approved card, and nothing binds it to the approved amount.
941
+ * So once the attachment has handed Nuvei the card, such a payment is refused
942
+ * (after the holds) instead of continuing. Before that, the token was issued
943
+ * elsewhere (Air France's server issues it, behind Amadeus) and it continues.
944
+ * Stripe is not listed: a card-free PaymentIntent confirm is bound by the API
945
+ * (checkStripeContinuation), and the confirmations its passWithoutCard names
946
+ * continue by design (payment-core definitions.js).
947
+ */
948
+ const CARD_FREE_AFTER_HANDOFF_REFUSED = new Set(['nuvei']);
641
949
  export class VaultClient {
642
950
  opts;
643
951
  baseUrl;
@@ -653,6 +961,12 @@ export class VaultClient {
653
961
  * in 'observe' until a sync reads that the API enabled it (armServedProfiles).
654
962
  */
655
963
  merchantProfiles = armServedProfiles();
964
+ /**
965
+ * The reviewed Verifone profiles this client arms: only the ones the API served on the
966
+ * last sync, which it serves only to a company Agentcard turned Verifone on for
967
+ * (armServedVerifoneProfiles). None before a sync.
968
+ */
969
+ verifoneProfiles = [];
656
970
  /** This client's own sandbox declarations (sandboxMerchants), armed at construction, one per profile. */
657
971
  declaredProfiles;
658
972
  preparations = new WeakSet();
@@ -756,9 +1070,14 @@ export class VaultClient {
756
1070
  mergedRegistry() {
757
1071
  return this.extraRecognizers.length ? [...this.extraRecognizers, ...this.registry] : this.registry;
758
1072
  }
759
- /** The recogniser that claims a card request (`POST` to a recognised endpoint), or null; what a paused request names as `recognizer`. */
1073
+ /**
1074
+ * The recogniser that claims a card request, or null; what a paused request names as
1075
+ * `recognizer`. A POST to a recognised endpoint, or for a recognizer whose card request is a
1076
+ * GET (Worldpay eProtect) that exact GET, query included.
1077
+ */
760
1078
  recognizerOf(url, method = 'POST') {
761
- return method.toUpperCase() === 'POST' ? findRecognizer(url, this.mergedRegistry(), { sandbox: this.sandbox }) : null;
1079
+ const rec = findRecognizer(url, this.mergedRegistry(), { sandbox: this.sandbox });
1080
+ return rec !== null && isRecognizerRequest(rec, url, method) ? rec : null;
762
1081
  }
763
1082
  /** Refresh recognizers from the API so new PSPs work without a redeploy. */
764
1083
  async syncRegistry() {
@@ -774,13 +1093,30 @@ export class VaultClient {
774
1093
  // ?capabilities= is the dark-launch half of the same negotiation: a
775
1094
  // processor gated behind a capability is served only when this build lists
776
1095
  // it (SUPPORTED_CAPABILITIES: fiserv_card_capture, gmo_shared_pk,
777
- // capture_context) and the caller's company is one Agentcard turned that
778
- // processor on for, so this build arms Fiserv's card capture, GMO's getToken
779
- // and CyberSource Flex's card fields only for such a company, and an older
780
- // build never receives a recognizer it cannot judge. Every other org's
781
- // registry never recognizes a Flex host, so its Microform requests go
782
- // through untouched; the create checks the org again (403
783
- // processor_unavailable).
1096
+ // capture_context, juspay_txns.v1, datatrans_secure_fields_tokenize,
1097
+ // authorize_net_dpm_form, pelecard_transaction, aci_split_card.v1,
1098
+ // creditguard_pps_process, primer_payment_instruments,
1099
+ // payrails_device_encrypt, converge_hpp_process, worldpay_eprotect_jsonp,
1100
+ // wspay_confirm, shiji_sts_pinned_key, amadeus_checkout_actions.v1,
1101
+ // worldline_mycheckout_form, verifone_card_capture) and
1102
+ // the caller's company is one Agentcard turned that processor on for, so this
1103
+ // build arms Fiserv's card capture, GMO's getToken, CyberSource Flex's card
1104
+ // fields, Juspay's card flow, Datatrans's Secure Fields card post, the
1105
+ // Authorize.Net DPM or SIM form, Pelecard's card submission, ACI's split card,
1106
+ // CreditGuard's hosted page submit, Primer's card tokenization, Payrails' card
1107
+ // save, Converge's payment page, Worldpay eProtect's registration, WSPay's
1108
+ // card confirm, Shiji STS's card leg, Amadeus Checkout's card add and
1109
+ // Worldline MyCheckout's card form only for
1110
+ // such a company, and an older build never receives a recognizer it cannot
1111
+ // judge. Every other org's registry never recognizes a Flex, Juspay, Datatrans,
1112
+ // Pelecard, ACI, CreditGuard, Primer, Payrails, Converge, eProtect, WSPay, Shiji STS, Amadeus or
1113
+ // MyCheckout host or the
1114
+ // transact.dll form, so its Microform, Juspay, Datatrans, Authorize.Net DPM or
1115
+ // SIM, Pelecard, ACI, CreditGuard, Primer, Payrails, Converge, eProtect, WSPay,
1116
+ // Shiji STS, Amadeus and MyCheckout requests go through untouched; the create checks the org again (403
1117
+ // processor_unavailable), and the SDK refuses ACI's card-number request,
1118
+ // CreditGuard's submit, an eProtect registration and an Amadeus card add locally
1119
+ // without a prepared checkout.
784
1120
  const capabilities = SUPPORTED_CAPABILITIES.length ? `&capabilities=${SUPPORTED_CAPABILITIES.join(',')}` : '';
785
1121
  // merchant_profiles=1 asks for the reviewed Adyen merchant profiles too, each
786
1122
  // as a `{ merchant_profile }` entry after the recognizers; an API that
@@ -797,12 +1133,31 @@ export class VaultClient {
797
1133
  if (!Array.isArray(raw))
798
1134
  return;
799
1135
  const isProfile = (e) => !!e && typeof e === 'object' && 'merchant_profile' in e;
800
- this.registry = raw.filter((e) => !isProfile(e)).map((e) => ({
1136
+ this.registry = raw.filter((e) => !isProfile(e) && !isVerifoneProfileEntry(e)).map((e) => ({
801
1137
  ...e,
802
1138
  match: new RegExp(e.match, 'i'),
803
1139
  passthroughHeaders: e.passthroughHeaders.map((h) => new RegExp(h, 'i')),
804
1140
  }));
805
1141
  this.merchantProfiles = armServedProfiles(raw.filter(isProfile));
1142
+ this.verifoneProfiles = armServedVerifoneProfiles(raw.filter(isVerifoneProfileEntry));
1143
+ }
1144
+ /**
1145
+ * The armed Verifone profile whose endpoint origin this URL is on, with its status on
1146
+ * this client; null for any other URL, and for every URL before a sync served one. The
1147
+ * adapters pause every request to that origin and judge it with
1148
+ * classifyVerifoneProfileRequest. A raw runtime that pauses one must never continue a
1149
+ * body carrying a verifone.js blob there unless authorize() paid it.
1150
+ */
1151
+ verifoneProfileOf(url) {
1152
+ return verifoneProfileFor(this.verifoneProfiles, url);
1153
+ }
1154
+ /** The armed Verifone profile with this id, or null. */
1155
+ verifoneProfile(id) {
1156
+ return this.verifoneProfiles.find((profile) => profile.id === id) ?? null;
1157
+ }
1158
+ /** Fetch.enable globs for every armed Verifone profile's endpoint origins; none before a sync served one. */
1159
+ verifoneProfileUrlPatterns() {
1160
+ return verifoneProfileUrlPatterns(this.verifoneProfiles);
806
1161
  }
807
1162
  /**
808
1163
  * The reviewed Adyen merchant profile whose card endpoint (or a sibling of it,
@@ -830,28 +1185,73 @@ export class VaultClient {
830
1185
  merchantProfileUrlPatterns() {
831
1186
  return merchantProfileUrlPatterns([...this.declaredProfiles, ...this.merchantProfiles]);
832
1187
  }
833
- /** True when this request is a card tokenization we can take over. */
1188
+ /**
1189
+ * The card-on-file policy the API served with the recognizer for this
1190
+ * request (Recognizer.cardOnFile), or null when no recognizer matches or
1191
+ * its entry names none. The Datatrans gate refuses a post that asks to keep
1192
+ * the card on file only while this is `refuse` or a value it does not know.
1193
+ */
1194
+ cardOnFilePolicy(url, method = 'POST') {
1195
+ if (method.toUpperCase() !== 'POST')
1196
+ return null;
1197
+ const rec = findRecognizer(url, this.registry);
1198
+ return rec && typeof rec.cardOnFile === 'string' ? rec.cardOnFile : null;
1199
+ }
1200
+ /**
1201
+ * True when this request is a card tokenization we can take over: a POST to
1202
+ * a recognized endpoint, or for a recognizer whose card request is a GET
1203
+ * (Worldpay eProtect) that exact GET, query included.
1204
+ */
834
1205
  isCardRequest(url, method = 'POST') {
835
1206
  return this.recognizerOf(url, method) !== null;
836
1207
  }
1208
+ /**
1209
+ * Whether the current registry holds this processor's recognizer. A
1210
+ * processor that ships dark is served only to a build that declares its
1211
+ * capability (syncRegistry does), so before a sync, or against an API that
1212
+ * withheld it, the adapters leave that processor's traffic exactly as they
1213
+ * found it. prepare() asks this for ACI, whose card-number request names a
1214
+ * checkout id the merchant mints only at Pay time, and for a processor whose
1215
+ * card request is a GET (Worldpay eProtect), since that request is recognized
1216
+ * by a query the page has not written yet.
1217
+ */
1218
+ recognizesProcessor(psp) {
1219
+ return this.registry.some((rec) => rec.psp === psp);
1220
+ }
837
1221
  /**
838
1222
  * What happens to a card request (by URL) whose body carries no card: null
839
1223
  * when it carries one, so it is a card request as usual; 'continue' when the
840
1224
  * recognizer marks the endpoint as one where such requests are routine and
841
1225
  * never ours; 'refuse' otherwise, such as a Stripe confirmation paying with
842
- * a method this checkout never approved. The adapters ask only after their
843
- * holds, so a request that would reuse an approved token is refused there
844
- * first. A body that could not be read is not judged here.
1226
+ * a method this checkout never approved. The adapters continue a 'continue'
1227
+ * in every state and judge a 'refuse' after their holds. A body that could
1228
+ * not be read is not judged here.
1229
+ *
1230
+ * `handedTo` lists the requests the attachment has already handed the card
1231
+ * to (CheckoutLifecycle.cardHandoffs). Once one of them went to the same
1232
+ * processor, a processor in CARD_FREE_AFTER_HANDOFF_REFUSED answers 'refuse'
1233
+ * where it would have answered 'continue'.
1234
+ *
1235
+ * A sandbox client's loopback fixture is judged on the processor's own
1236
+ * hosts, as it is recognised (urlsOnProcessorHosts), so a card-free request
1237
+ * the catalog lets through there continues on the fixture too.
845
1238
  */
846
- withoutCard(url, body) {
1239
+ withoutCard(url, body, handedTo = []) {
847
1240
  if (body == null)
848
1241
  return null;
849
- const rec = findRecognizer(url, this.mergedRegistry(), { sandbox: this.sandbox });
850
- return rec ? requestWithoutCard(rec, url, body) : null;
1242
+ const registry = this.mergedRegistry();
1243
+ const rec = findRecognizer(url, registry, { sandbox: this.sandbox });
1244
+ if (!rec)
1245
+ return null;
1246
+ const answers = urlsOnProcessorHosts(url, rec, { sandbox: this.sandbox }).map((judged) => requestWithoutCard(rec, judged, body));
1247
+ const answer = answers.includes(null) ? null : answers.includes('continue') ? 'continue' : 'refuse';
1248
+ if (answer !== 'continue' || !CARD_FREE_AFTER_HANDOFF_REFUSED.has(rec.psp))
1249
+ return answer;
1250
+ return handedTo.some((handed) => findRecognizer(handed, registry, { sandbox: this.sandbox })?.psp === rec.psp) ? 'refuse' : 'continue';
851
1251
  }
852
1252
  /**
853
- * How the card would reach the processor on this request (`token`, `cse`
854
- * or `hosted_form`; absent on the entry means `token`), or null when the
1253
+ * How the card would reach the processor on this request (`token`, `cse`,
1254
+ * `hosted_form` or `device_encrypt`; absent on the entry means `token`), or null when the
855
1255
  * registry does not recognize it. The adapters read it to decide whether an
856
1256
  * approval outlives the page's own request: a hosted form is a navigation
857
1257
  * and cannot.
@@ -915,6 +1315,9 @@ export class VaultClient {
915
1315
  const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
916
1316
  if (!validPreparationEnvironment(input.psp, input.environment))
917
1317
  throw fail('unsupported_processor');
1318
+ const pageRefusal = paymentPageRefusal(input);
1319
+ if (pageRefusal)
1320
+ throw fail(pageRefusal);
918
1321
  // A merchant-hosted preparation pays one reviewed profile's card request: the
919
1322
  // profile must be armed here, enabled, and paid in its own environment.
920
1323
  // A Fiserv checkout names the key Agentcard pinned for its merchant, in that key's own
@@ -922,7 +1325,21 @@ export class VaultClient {
922
1325
  // this preparation pays must carry an envelope under that key (matchesPreparation).
923
1326
  let profile = null;
924
1327
  let fiservPin = null;
925
- if (input.psp === 'fiserv') {
1328
+ // A Verifone checkout names the reviewed profile of the merchant, armed and enabled here, in
1329
+ // that profile's own environment; the API then names that profile's merchant on the approval.
1330
+ let verifone = null;
1331
+ if (input.psp === 'verifone') {
1332
+ if (input.merchantProfile === undefined)
1333
+ throw fail('merchant_profile_required');
1334
+ verifone = typeof input.merchantProfile === 'string' ? this.verifoneProfile(input.merchantProfile) : null;
1335
+ if (!verifone)
1336
+ throw fail('processor_interception_unavailable');
1337
+ if (verifone.status !== 'enabled')
1338
+ throw fail('unsupported_checkout');
1339
+ if (verifone.environment !== input.environment)
1340
+ throw fail('unsupported_processor');
1341
+ }
1342
+ else if (input.psp === 'fiserv') {
926
1343
  if (input.merchantProfile === undefined)
927
1344
  throw fail('merchant_profile_required');
928
1345
  fiservPin = fiservKeyPinById(input.merchantProfile);
@@ -944,11 +1361,25 @@ export class VaultClient {
944
1361
  throw fail('unsupported_processor');
945
1362
  }
946
1363
  const declaration = profile?.sandboxDeclaration;
947
- if (!validAmountInput(input.amount) || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
948
- throw fail('amount_required');
1364
+ // Worldpay eProtect: the preparation names the Worldpay merchant its registration may name
1365
+ // (paypageId) and the paying card, whose expiry the agent types at the merchant because
1366
+ // Worldpay never receives one. The API refuses either missing, and a paypageId on any other
1367
+ // processor; refusing here asks nobody to approve a preparation that could never bind.
1368
+ const eprotect = input.psp === EPROTECT_PSP;
1369
+ if (eprotect ? typeof input.paypageId !== 'string' || !/^[A-Za-z0-9]{1,64}$/.test(input.paypageId) : input.paypageId !== undefined)
1370
+ throw fail('paypage_id_invalid');
1371
+ if (eprotect && !input.cardId)
1372
+ throw fail('card_required');
1373
+ if (!validAmountInput(input.amount) || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency)) {
1374
+ throw new CheckoutPreparationError(null, 'amount_required', undefined, input.amount != null && !validAmountInput(input.amount) ? amountRefusal(input.amount, input.currency) : undefined);
1375
+ }
949
1376
  const origin = new URL(input.merchantOrigin);
950
1377
  if (!(origin.protocol === 'https:' || (origin.protocol === 'http:' && origin.hostname === 'localhost')) || origin.origin !== input.merchantOrigin)
951
1378
  throw fail('merchant_origin_invalid');
1379
+ // CreditGuard's submit names the page that frames it (ppDomain), and only an HTTPS page under a
1380
+ // dotted hostname can be bound to it, so the API would refuse any other merchant page.
1381
+ if (input.psp === 'creditguard' && (origin.protocol !== 'https:' || creditguardPpDomainHost(btoa(origin.hostname)) !== origin.hostname))
1382
+ throw fail('merchant_origin_invalid');
952
1383
  if (!input.checkoutKey || !input.user || !input.merchant)
953
1384
  throw fail('checkout_context_required');
954
1385
  // The cardholder approves a payment to the merchant the key pin belongs to, so a Fiserv
@@ -956,6 +1387,15 @@ export class VaultClient {
956
1387
  // authorization it pays then name one merchant, never a label the caller chose.
957
1388
  if (fiservPin && input.merchant !== fiservPin.merchant)
958
1389
  throw fail('merchant_profile_mismatch');
1390
+ // A live Worldpay eProtect checkout whose paypageId this build pins for the merchant origin names
1391
+ // that pin's merchant, the one the API shows on the approval (eprotectCheckoutRefusal): the
1392
+ // approval, the handle this returns and every authorization it pays then name one merchant, and no
1393
+ // approval is retired over a label. The API refuses a paypageId with no pin (unsupported_checkout);
1394
+ // test mode needs no pin.
1395
+ const eprotectRefusal = eprotect ? eprotectCheckoutRefusal({ environment: input.environment, paypageId: input.paypageId,
1396
+ merchantOrigin: input.merchantOrigin, merchant: input.merchant }) : null;
1397
+ if (eprotectRefusal)
1398
+ throw fail(eprotectRefusal);
959
1399
  const timeoutMs = input.timeoutMs ?? 15 * 60_000;
960
1400
  if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
961
1401
  throw fail('timeout_invalid');
@@ -973,7 +1413,10 @@ export class VaultClient {
973
1413
  environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
974
1414
  ...(profile ? { merchant_profile: profile.id } : {}),
975
1415
  ...(fiservPin ? { merchant_profile: fiservPin.id } : {}),
1416
+ ...(verifone ? { merchant_profile: verifone.id } : {}),
976
1417
  ...(declaration ? { sandbox_declaration: { endpoint: declaration.endpoint, client_key: declaration.clientKey, environment: 'test' } } : {}),
1418
+ ...(input.psp === 'creditguard' ? { payment_page_url: input.paymentPageUrl } : {}),
1419
+ ...(eprotect ? { paypage_id: input.paypageId } : {}),
977
1420
  }, AbortSignal.timeout(30_000), signal);
978
1421
  if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
979
1422
  throw fail('create_unconfirmed');
@@ -1004,12 +1447,16 @@ export class VaultClient {
1004
1447
  // A merchant-hosted preparation names the merchant its reviewed profile names, and
1005
1448
  // a declared test endpoint names its host (declareSandboxMerchant), never the caller's label.
1006
1449
  // A Fiserv preparation names the merchant its key pin belongs to (input.merchant, checked above), and that pin.
1007
- || state.user !== input.user || state.merchant !== (profile ? profile.merchant : input.merchant) || state.merchant_origin !== input.merchantOrigin
1008
- || state.merchant_profile !== (profile ? profile.id : fiservPin ? fiservPin.id : undefined)
1450
+ // A Verifone preparation names the merchant its reviewed profile names, as a merchant-hosted one.
1451
+ || state.user !== input.user || state.merchant !== (profile ? profile.merchant : verifone ? verifone.merchant : input.merchant)
1452
+ || state.merchant_origin !== input.merchantOrigin
1453
+ || state.merchant_profile !== (profile ? profile.id : fiservPin ? fiservPin.id : verifone ? verifone.id : undefined)
1009
1454
  || !servesDeclaration(state.sandbox_declaration, declaration)
1010
1455
  || !Number.isSafeInteger(state.amount) || (typeof input.amount === 'number' && state.amount !== input.amount) || state.currency !== input.currency.toLowerCase()
1011
1456
  || state.psp !== input.psp || state.mode !== preparationMode(input.psp) || state.environment !== input.environment
1012
- || state.checkout_key !== input.checkoutKey)
1457
+ || state.checkout_key !== input.checkoutKey
1458
+ // eProtect: the merchant and the card the cardholder approved are the ones asked for.
1459
+ || (eprotect && (state.paypage_id !== input.paypageId || state.card_id !== input.cardId)))
1013
1460
  throw fail('ready_unconfirmed', id);
1014
1461
  const prepared = Object.freeze({
1015
1462
  id: preparationId, status: 'ready', psp: input.psp, environment: input.environment, mode: preparationMode(input.psp), expiresAt: state.ready_expires_at,
@@ -1019,7 +1466,10 @@ export class VaultClient {
1019
1466
  paymentStatus: 'not_started', amountAuthority: 'agent',
1020
1467
  ...(profile ? { merchantProfile: profile.id } : {}),
1021
1468
  ...(fiservPin ? { merchantProfile: fiservPin.id } : {}),
1469
+ ...(verifone ? { merchantProfile: verifone.id } : {}),
1022
1470
  ...(declaration ? { sandboxDeclaration: declaration } : {}),
1471
+ ...(input.psp === 'creditguard' ? { paymentPageUrl: input.paymentPageUrl } : {}),
1472
+ ...(eprotect ? { paypageId: input.paypageId } : {}),
1023
1473
  });
1024
1474
  this.preparations.add(prepared);
1025
1475
  ready = true;
@@ -1034,13 +1484,17 @@ export class VaultClient {
1034
1484
  catch (error) {
1035
1485
  if (error instanceof CheckoutPreparationError)
1036
1486
  throw error;
1487
+ const refusal = input.psp === 'creditguard' && id === null ? creditguardPreparationRefusal(error) : null;
1488
+ if (refusal)
1489
+ throw refusal;
1037
1490
  // A refusal the API answered before any preparation existed names its rule: one
1038
1491
  // PREPARATION_REFUSALS names for any checkout, and, for a Fiserv checkout only, one
1039
1492
  // FISERV_PREPARATION_REFUSALS names or a 502 cse_key_unavailable. Every other
1040
1493
  // processor's other refusals stay preparation_unconfirmed, as before.
1041
1494
  if (!id && !signal.aborted && error instanceof CheckoutApiError && error.code
1042
1495
  && ((error.status >= 400 && error.status < 500 && (PREPARATION_REFUSALS.includes(error.code)
1043
- || (input.psp === 'fiserv' && FISERV_PREPARATION_REFUSALS.includes(error.code))))
1496
+ || (input.psp === 'fiserv' && FISERV_PREPARATION_REFUSALS.includes(error.code))
1497
+ || (input.psp === EPROTECT_PSP && EPROTECT_PREPARATION_REFUSALS.includes(error.code))))
1044
1498
  || (input.psp === 'fiserv' && error.status === 502 && error.code === 'cse_key_unavailable')))
1045
1499
  throw fail(error.code);
1046
1500
  throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
@@ -1058,6 +1512,30 @@ export class VaultClient {
1058
1512
  if (state?.id !== id || !['cancelled', 'expired'].includes(state.status))
1059
1513
  throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
1060
1514
  }
1515
+ /**
1516
+ * Retire a ready preparation whose authorization create has not answered, and name the request
1517
+ * that already holds it. Resolves null once the API confirms no merchant request can bind the
1518
+ * preparation any more (cancelled or expired: the API takes a cancel and a create's bind under one
1519
+ * lock), or the ID of the authorization it is already bound to, which the caller retires like any
1520
+ * other (cancelAuthorization). Rejects when the API confirms neither. The Amadeus send window uses
1521
+ * it (amadeus.ts).
1522
+ */
1523
+ async retirePreparation(id) {
1524
+ if (!/^cprep_[A-Za-z0-9_-]{1,128}$/.test(id))
1525
+ throw new CheckoutPreparationError(null, 'id_invalid');
1526
+ try {
1527
+ await this.cancelPreparation(id);
1528
+ return null;
1529
+ }
1530
+ catch { /* bound, or unanswered: read it */ }
1531
+ const state = await this.get(`/v2/checkout/preparations/${id}`, AbortSignal.timeout(3_000));
1532
+ if (state?.id === id && (state.status === 'cancelled' || state.status === 'expired'))
1533
+ return null;
1534
+ if (state?.id === id && state.status === 'bound' && typeof state.authorization_id === 'string'
1535
+ && /^cauth_[A-Za-z0-9_-]{1,128}$/.test(state.authorization_id))
1536
+ return state.authorization_id;
1537
+ throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
1538
+ }
1061
1539
  async observePreparation(id, guidance, reason) {
1062
1540
  try {
1063
1541
  await this.post(`/v2/checkout/preparations/${id}/observe`, {
@@ -1084,6 +1562,9 @@ export class VaultClient {
1084
1562
  * response to replay into the browser. Your process never sees a card.
1085
1563
  */
1086
1564
  async authorize(input) {
1565
+ // A reviewed merchant's own card endpoint is checked whole before anything else
1566
+ // (admitCardEndpoint): the profile, the approval, the amount and the session.
1567
+ const cardEndpoint = input.request.merchant_card_endpoint === undefined ? null : admitCardEndpoint(input);
1087
1568
  const mercadoContext = input.request.mercado_checkout === undefined ? undefined : parseMercadoCheckoutContext(input.request.mercado_checkout);
1088
1569
  if (mercadoContext && (!isMercadoTokenRequest(input.request.url, input.request.method)
1089
1570
  || input.executionMode === 'autopilot' || input.grantId))
@@ -1144,14 +1625,16 @@ export class VaultClient {
1144
1625
  throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
1145
1626
  // An armed merchant profile's endpoint (merchant-hosted.ts): the reviewed
1146
1627
  // merchant's own server, never a processor host, so no recognizer matches it.
1147
- const merchantProfile = nativeCheckout || ownedShop || mercadoContext ? null : this.merchantProfileOf(input.request.url);
1628
+ const merchantProfile = cardEndpoint || nativeCheckout || ownedShop || mercadoContext ? null : this.merchantProfileOf(input.request.url);
1629
+ // An armed Verifone profile's origin (verifone.ts): the reviewed merchant's own server.
1630
+ const verifoneProfile = cardEndpoint || nativeCheckout || ownedShop || mercadoContext || merchantProfile ? null : this.verifoneProfileOf(input.request.url);
1148
1631
  // A preparation pays the profile it was made for and nothing else: a
1149
1632
  // merchant-hosted one never a processor's request or another profile's (two
1150
1633
  // profiles could share an endpoint), and a processor-hosted one never a
1151
1634
  // merchant's own endpoint. Checked here, not left to the matcher above, so the
1152
1635
  // create below can only ever name the profile the cardholder prepared. A Fiserv
1153
1636
  // preparation names a key pin, not a reviewed profile (matchesPreparation binds it).
1154
- if (preparation && (((preparation.psp === 'fiserv' ? null : preparation.merchantProfile) ?? null) !== (merchantProfile?.id ?? null)
1637
+ if (preparation && (((preparation.psp === 'fiserv' || preparation.psp === 'verifone' ? null : preparation.merchantProfile) ?? null) !== (merchantProfile?.id ?? null)
1155
1638
  || (preparation.sandboxDeclaration?.endpoint ?? null) !== (merchantProfile?.sandboxDeclaration?.endpoint ?? null))) {
1156
1639
  throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
1157
1640
  }
@@ -1161,46 +1644,64 @@ export class VaultClient {
1161
1644
  }
1162
1645
  if (merchantProfile)
1163
1646
  assertMerchantHostedRequest(input, merchantProfile);
1164
- const rec = merchantProfile ? MERCHANT_HOSTED_RECOGNIZER : nativeCheckout ? this.registry.find(entry => entry.psp === 'stripe' && (entry.mode ?? 'token') === 'token')
1647
+ // A Verifone preparation pays only its profile's reviewed leg (matchesPreparation, above);
1648
+ // here a request at an armed Verifone merchant is held to that profile's own rules.
1649
+ if (verifoneProfile)
1650
+ assertVerifoneRequest(input, verifoneProfile);
1651
+ // A card endpoint is no processor: its profile, never the registry, says where it goes.
1652
+ const rec = cardEndpoint ? null : merchantProfile ? MERCHANT_HOSTED_RECOGNIZER : verifoneProfile ? VERIFONE_RECOGNIZER : nativeCheckout ? this.registry.find(entry => entry.psp === 'stripe' && (entry.mode ?? 'token') === 'token')
1165
1653
  : findRecognizer(input.request.url, this.registry, { sandbox: this.sandbox });
1166
- if (!rec)
1654
+ if (!cardEndpoint && !rec)
1167
1655
  throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
1168
1656
  // A client-side-encrypted processor is only takeable when its entry says
1169
1657
  // the vault produces that ciphertext itself (mode cse); a bare
1170
1658
  // clientSideEncrypted entry (an older registry, a hand-built one) still
1171
1659
  // refuses here, exactly as before.
1172
- const mode = rec.mode ?? 'token';
1173
- if (preparation && (rec.psp !== preparation.psp || mode !== preparationMode(preparation.psp)))
1174
- throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
1175
- // A preparation-required processor may not be taken over on an ordinary
1176
- // authorization: the cardholder must have approved a prepare() first. Refuse
1177
- // locally, before any create or prompt, when none is active, through the
1178
- // same strict rule the API applies (recognizerRequiresPreparation). Fiserv
1179
- // sets it; the API serves Fiserv only on a sync that declares its capability,
1180
- // and only to a company Agentcard turned Fiserv on for. This build carries
1181
- // Fiserv's own rules, so it also requires the preparation for a Fiserv
1182
- // request whatever the served entry says (requiresFiservPreparation): a
1183
- // registry that dropped the flag never opens an ordinary Fiserv authorization.
1184
- if ((recognizerRequiresPreparation(rec) || requiresFiservPreparation(rec.psp, input.request.url)) && !preparation)
1185
- throw new PreparationRequiredError(rec.psp);
1186
- // The merchant's server picks what it charges Adyen, so the agent's amount is
1187
- // what the API holds the body's own amount to: a merchant-hosted payment names one.
1188
- if (merchantProfile && input.amount == null) {
1189
- throw new Error(`A payment to ${merchantProfile.merchant} names its amount: pass amount and currency, the amount the cardholder approves.`);
1660
+ const mode = rec ? rec.mode ?? 'token' : CARD_ENDPOINT_MODE;
1661
+ // The transaction a CreditGuard submit pays; its approved answer is rebuilt for it alone.
1662
+ const creditguardTxId = rec?.psp === 'creditguard' ? creditguardProcessTxId(input.request.url) : null;
1663
+ if (rec) {
1664
+ if (preparation && (rec.psp !== preparation.psp || mode !== preparationMode(preparation.psp)))
1665
+ throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
1666
+ // A preparation-required processor may not be taken over on an ordinary
1667
+ // authorization: the cardholder must have approved a prepare() first. Refuse
1668
+ // locally, before any create or prompt, when none is active, through the
1669
+ // same strict rule the API applies (recognizerRequiresPreparation). Fiserv,
1670
+ // aci, CreditGuard and amadeus_checkout set it (a CreditGuard submit is the
1671
+ // sale and names no amount); the API serves each only on a sync that declares
1672
+ // its capability,
1673
+ // and only to a company Agentcard turned it on for. This build carries
1674
+ // Fiserv's own rules, so it also requires the preparation for a Fiserv
1675
+ // request whatever the served entry says (requiresFiservPreparation): a
1676
+ // registry that dropped the flag never opens an ordinary Fiserv authorization.
1677
+ if ((recognizerRequiresPreparation(rec) || requiresFiservPreparation(rec.psp, input.request.url)) && !preparation)
1678
+ throw new PreparationRequiredError(rec.psp);
1679
+ // The merchant's server picks what it charges Adyen, so the agent's amount is
1680
+ // what the API holds the body's own amount to: a merchant-hosted payment names one.
1681
+ if (merchantProfile && input.amount == null) {
1682
+ throw new Error(`A payment to ${merchantProfile.merchant} names its amount: pass amount and currency, the amount the cardholder approves.`);
1683
+ }
1684
+ if (verifoneProfile && input.amount == null) {
1685
+ throw new Error(`A payment to ${verifoneProfile.merchant} names its amount: pass amount and currency, the amount the cardholder approves.`);
1686
+ }
1687
+ // device_encrypt (Payrails) also encrypts in-page; the device rebuilds that
1688
+ // ciphertext under the session key the request carries.
1689
+ if (rec.clientSideEncrypted && mode !== 'cse' && mode !== 'device_encrypt')
1690
+ throw new CardEncryptedError(rec.psp);
1691
+ if (rec.psp === 'creditguard' && creditguardTxId === null)
1692
+ throw new Error(`not a CreditGuard card submit: ${redactUrl(input.request.url)}`);
1693
+ if (mode === CARD_ENDPOINT_MODE || !SUPPORTED_MODES.includes(mode))
1694
+ throw new UnsupportedModeError(mode);
1190
1695
  }
1191
- if (rec.clientSideEncrypted && mode !== 'cse')
1192
- throw new CardEncryptedError(rec.psp);
1193
- if (!SUPPORTED_MODES.includes(mode))
1194
- throw new UnsupportedModeError(mode);
1195
1696
  // A dark-launch recognizer names the capability its create needs. A build that does not
1196
1697
  // declare it cannot send what that create carries, so it refuses here, before any create;
1197
1698
  // a registry the API served never holds such an entry.
1198
- if (rec.capability !== undefined && !SUPPORTED_CAPABILITIES.includes(rec.capability))
1699
+ if (rec && rec.capability !== undefined && !SUPPORTED_CAPABILITIES.includes(rec.capability))
1199
1700
  throw new UnsupportedCapabilityError(rec.capability);
1200
1701
  // CyberSource Flex: the capture context and the Microform fields ride on the request (the
1201
1702
  // adapters read both from the card field iframe) and the API verifies them before anything
1202
1703
  // exists. They belong to a Flex request and nothing else, as the API holds them.
1203
- const flex = rec.psp === CYBERSOURCE_FLEX_PSP;
1704
+ const flex = rec?.psp === CYBERSOURCE_FLEX_PSP;
1204
1705
  if (!flex && (input.request.capture_context !== undefined || input.request.microform !== undefined)) {
1205
1706
  throw new Error('request.capture_context and request.microform belong to a CyberSource Flex card request.');
1206
1707
  }
@@ -1208,15 +1709,34 @@ export class VaultClient {
1208
1709
  throw new Error('A CyberSource Flex card request carries the capture context its card field iframe was loaded with (request.capture_context) '
1209
1710
  + 'and the Microform fields that loaded (request.microform); attachToCdp and attachToPlaywright read both.');
1210
1711
  }
1211
- // Say no rather than coerce. isCardRequest only ever pauses a POST, and the
1212
- // service stores the replay template with method 'POST' hardcoded — so a
1213
- // caller handing in a PUT or GET would get it silently rewritten and the
1214
- // cardholder's device would replay something the caller never asked for.
1215
- // A direct caller deserves to be told, not surprised.
1712
+ // The session key is not in a device_encrypt request: without the one the
1713
+ // page's init payload named, the device has nothing to encrypt under.
1714
+ // Refused before any create or prompt, the way the API refuses
1715
+ // payrails_session_key_required.
1716
+ const sidecar = input.request.payrails;
1717
+ if (mode === 'device_encrypt' && (!sidecar || typeof sidecar.encryption_public_key !== 'string' || !sidecar.encryption_public_key)) {
1718
+ throw new Error('A Payrails card save needs request.payrails.encryption_public_key, the session key from the checkout\'s Payrails init payload (the adapters capture it). Nothing was created.');
1719
+ }
1720
+ // Say no rather than coerce. Each recognizer's card request has exactly one
1721
+ // method (recognizerRequestMethod): a POST body, or for Worldpay eProtect the
1722
+ // GET whose query carries the card. The service stores the replay template
1723
+ // with that method, so a caller handing in another one would get it
1724
+ // silently rewritten and the cardholder's device would replay something the
1725
+ // caller never asked for. A direct caller deserves to be told, not surprised.
1216
1726
  const method = (input.request.method ?? 'POST').toUpperCase();
1217
- if (method !== 'POST') {
1218
- throw new Error(`checkout authorization is POST-only; got ${method} for ${redactUrl(input.request.url)}. ` +
1219
- 'A non-POST tokenizer needs recognizer support before it can be intercepted.');
1727
+ // A card endpoint has no recognizer: its card request is a POST, as every recognizer's is but eProtect's.
1728
+ const cardMethod = rec ? recognizerRequestMethod(rec) : 'POST';
1729
+ if (method !== cardMethod) {
1730
+ throw new Error(cardMethod === 'GET'
1731
+ ? `a ${rec?.psp} card registration is a GET; got ${method} for ${redactUrl(input.request.url)}.`
1732
+ : `checkout authorization is POST-only; got ${method} for ${redactUrl(input.request.url)}. ` +
1733
+ 'A non-POST tokenizer needs recognizer support before it can be intercepted.');
1734
+ }
1735
+ // A GET card request is its URL alone. This build completes one: Worldpay
1736
+ // eProtect's registration exactly as payment-core admits it, with no body.
1737
+ const eprotect = rec?.psp === EPROTECT_PSP;
1738
+ if (cardMethod === 'GET' && !(eprotect && isEprotectRegistration(input.request))) {
1739
+ throw new Error(`this ${rec?.psp} GET is not a card registration this version of @agent-cards/checkout can complete: ${redactUrl(input.request.url)}.`);
1220
1740
  }
1221
1741
  // The amount is a hint: a pair or nothing. Refuse half a pair here, before
1222
1742
  // a network call: the API would too, and a retry loop cannot fix a
@@ -1226,15 +1746,27 @@ export class VaultClient {
1226
1746
  if (hasAmount !== hasCurrency) {
1227
1747
  throw new Error('amount and currency go together: pass both or neither.');
1228
1748
  }
1229
- if (hasAmount && !validAmountInput(input.amount)) {
1230
- throw new Error('amount is an integer in the currency\'s smallest unit (2306 for $23.06), or a decimal string in normal units ("23.06").');
1231
- }
1749
+ if (hasAmount && !validAmountInput(input.amount))
1750
+ throw new Error(amountRefusal(input.amount, input.currency));
1232
1751
  // A processor whose request names no amount (AGENT_AMOUNT_PROCESSORS) is
1233
- // paid only for the pair passed here: it is all the approval can show.
1752
+ // paid only for the pair passed here: it is all the approval can show and,
1753
+ // for WSPay, the only figure the processor's answer is checked against.
1234
1754
  // Refused before any create or prompt; the API refuses the same create as
1235
1755
  // amount_required.
1236
- if (!hasAmount && AGENT_AMOUNT_PROCESSORS.includes(rec.psp))
1756
+ if (!hasAmount && rec && AGENT_AMOUNT_PROCESSORS.includes(rec.psp))
1237
1757
  throw new AmountRequiredError(rec.psp);
1758
+ // Nor is the agent's pair tied to the page by anything but the total the
1759
+ // page shows: without it the cardholder could approve one figure while the
1760
+ // page charges another. The API refuses the same create as page_amount_required.
1761
+ if (!input.pageAmount && rec && PAGE_AMOUNT_PROCESSORS.includes(rec.psp))
1762
+ throw new PageAmountRequiredError(rec.psp);
1763
+ // A Payrails save names no amount (the merchant charges the saved card
1764
+ // later, under another session), so the price the cardholder approves is
1765
+ // the one sent here: the agent's pair or else the page total. Refused
1766
+ // before any create or prompt, the way the API refuses amount_required.
1767
+ if (mode === 'device_encrypt' && !hasAmount && !input.pageAmount) {
1768
+ throw new Error('A Payrails card save names no amount, so pass amount and currency (or let the adapter read the page total): that is the amount the cardholder approves. Nothing was created and nothing was charged.');
1769
+ }
1238
1770
  const timeoutMs = input.timeoutMs ?? 15 * 60_000;
1239
1771
  if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
1240
1772
  throw new Error('timeoutMs must be a positive integer no larger than 2147483647.');
@@ -1246,7 +1778,21 @@ export class VaultClient {
1246
1778
  const priced = merchantProfile && merchantProfilePricedBySource(merchantProfile.id)
1247
1779
  ? await this.readMerchantTotal(input, merchantProfile, preparation, operationSignal) : null;
1248
1780
  let created;
1249
- const payload = {
1781
+ const payload = cardEndpoint ? {
1782
+ // A card endpoint's request travels only in merchant_card_endpoint: `request`
1783
+ // names its URL and nothing else, and the agent's amount is the only authority.
1784
+ user: input.user,
1785
+ merchant: input.merchant,
1786
+ amount: input.amount,
1787
+ currency: input.currency,
1788
+ psp: CARD_ENDPOINT_PSP,
1789
+ mode,
1790
+ ...(input.cardId ? { cardId: input.cardId } : {}),
1791
+ execution_mode: 'user_approval',
1792
+ checkout_origin: cardEndpoint.session.page_origin,
1793
+ request: { url: cardEndpoint.session.url, method: 'POST', headers: {}, body: '' },
1794
+ merchant_card_endpoint: { profile: cardEndpoint.profile, session: cardEndpoint.session },
1795
+ } : {
1250
1796
  user: input.user,
1251
1797
  merchant: input.merchant,
1252
1798
  // snake_case on the wire; camelCase is this SDK's convention.
@@ -1269,11 +1815,16 @@ export class VaultClient {
1269
1815
  endpoint: merchantProfile.sandboxDeclaration.endpoint, client_key: merchantProfile.sandboxDeclaration.clientKey, environment: 'test'
1270
1816
  } } : {}),
1271
1817
  ...(priced ? { merchant_total: priced.wire } : {}) } } : {}),
1818
+ // Only the profile's id: the API takes the leg, the key and its environment from the
1819
+ // profile Agentcard reviewed, never from the caller.
1820
+ ...(verifoneProfile ? { verifone: { profile: verifoneProfile.id } } : {}),
1272
1821
  // A prepared checkout already carries the page origin as merchant_origin.
1273
1822
  ...(preparation
1274
1823
  ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin, form_guidance: 'filled' }
1275
1824
  : input.pageOrigin ? { checkout_origin: input.pageOrigin } : {}),
1276
- request: {
1825
+ // A GET card request (Worldpay eProtect) is its URL alone: no body, and no
1826
+ // header the page sent, which the API would neither store nor replay.
1827
+ request: cardMethod === 'GET' ? { url: input.request.url, method: 'GET', headers: {}, body: '' } : {
1277
1828
  url: input.request.url,
1278
1829
  method: input.request.method,
1279
1830
  headers: { ...pickHeaders(input.request.headers, rec.passthroughHeaders),
@@ -1285,6 +1836,8 @@ export class VaultClient {
1285
1836
  microform: { version: input.request.microform.version, fields: [...input.request.microform.fields],
1286
1837
  ...(input.request.microform.trigger ? { trigger: input.request.microform.trigger } : {}) },
1287
1838
  } : {}),
1839
+ // Only the key: the token already rides in the passthrough authorization header.
1840
+ ...(mode === 'device_encrypt' && sidecar ? { payrails: { encryption_public_key: sidecar.encryption_public_key } } : {}),
1288
1841
  },
1289
1842
  };
1290
1843
  try {
@@ -1324,7 +1877,11 @@ export class VaultClient {
1324
1877
  // 10 seconds has at most 20 reads instead of 5 (15 additional reads).
1325
1878
  // Then restore the caller's interval. Human approval waiting, cancellation,
1326
1879
  // merchant deadlines, and the single processor request remain unchanged.
1327
- const preparedMercadoPollUntil = preparation?.psp === 'mercado_pago' ? Date.now() + 10_000 : 0;
1880
+ // A prepared Worldpay eProtect registration too, for 15 seconds: its
1881
+ // merchants give up on the paused script 10 to 15 seconds after Pay, so a
1882
+ // registration is read every 500 ms instead of waiting out 2 seconds.
1883
+ const preparedFastPollUntil = preparation?.psp === 'mercado_pago' ? Date.now() + 10_000
1884
+ : preparation?.psp === EPROTECT_PSP ? Date.now() + 15_000 : 0;
1328
1885
  let execution = {};
1329
1886
  let approvalDelivered = false;
1330
1887
  const deliverApproval = (state) => {
@@ -1370,7 +1927,7 @@ export class VaultClient {
1370
1927
  while (Date.now() < deadline) {
1371
1928
  if (stopSignal?.aborted)
1372
1929
  throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
1373
- const pollInterval = Date.now() < preparedMercadoPollUntil ? Math.min(this.pollIntervalMs, 500) : this.pollIntervalMs;
1930
+ const pollInterval = Date.now() < preparedFastPollUntil ? Math.min(this.pollIntervalMs, 500) : this.pollIntervalMs;
1374
1931
  await interruptibleSleep(Math.min(pollInterval, Math.max(0, deadline - Date.now())), stopSignal);
1375
1932
  if (stopSignal?.aborted)
1376
1933
  throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
@@ -1405,7 +1962,7 @@ export class VaultClient {
1405
1962
  // database forces these rows to cse. A finished read in any other mode
1406
1963
  // (token, a missing mode, a hosted form) is not one to act on, and never
1407
1964
  // reaches the code that would answer the merchant's paused request with it.
1408
- if (merchantProfile && (s.status === 'approved' || s.status === 'submitted_on_device') && s.mode !== 'cse') {
1965
+ if ((merchantProfile || verifoneProfile) && (s.status === 'approved' || s.status === 'submitted_on_device') && s.mode !== 'cse') {
1409
1966
  throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_hosted_mode_invalid');
1410
1967
  }
1411
1968
  if (s.status === 'submitted_on_device') {
@@ -1426,6 +1983,13 @@ export class VaultClient {
1426
1983
  }
1427
1984
  if (s.status === 'approved') {
1428
1985
  const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
1986
+ if (cardEndpoint || approvedMode === CARD_ENDPOINT_MODE) {
1987
+ // Only a card endpoint request is answered from a merchant's own answer, and
1988
+ // it is answered from nothing else.
1989
+ if (!cardEndpoint || approvedMode !== CARD_ENDPOINT_MODE)
1990
+ throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
1991
+ return cardEndpointReplay(s, authorizationId, cardEndpoint.profile, { ...amountAuthority, ...execution });
1992
+ }
1429
1993
  if (approvedMode === 'hosted_form') {
1430
1994
  // Never: the API finishes a hosted form as submitted_on_device, and
1431
1995
  // its database refuses `approved` on that mode. An answer that says
@@ -1437,19 +2001,30 @@ export class VaultClient {
1437
2001
  throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
1438
2002
  // A merchant-hosted create reads back only a merchant-hosted approval,
1439
2003
  // and a processor-hosted one never does.
2004
+ // A Verifone create reads back only a Verifone approval, and no other create ever does.
2005
+ if (verifoneProfile || (s.substitutions && typeof s.substitutions === 'object' && s.substitutions.kind === 'verifone')) {
2006
+ return { ...verifoneApproval(s, authorizationId, verifoneProfile), ...amountAuthority, ...execution };
2007
+ }
1440
2008
  if (merchantProfile || (s.substitutions && typeof s.substitutions === 'object' && 'kind' in s.substitutions)) {
1441
2009
  return { ...merchantHostedApproval(s, authorizationId, merchantProfile), ...(priced ? { merchantTotal: priced.approved } : {}),
1442
2010
  ...amountAuthority, ...execution };
1443
2011
  }
1444
- // The API serves a Fiserv envelope only briefly after the approval (it carries
1445
- // no timestamp, so whoever holds it could resubmit it). A read after that
1446
- // window says so instead of serving it; the capture was never continued here.
2012
+ // The API serves a Fiserv envelope, or a Shiji STS set, only briefly after the
2013
+ // approval (neither is bound to a session, so whoever holds it could resubmit
2014
+ // it). A read after that window says so instead of serving it; the request was
2015
+ // never continued here, and nothing here can pay with it.
1447
2016
  if (s.substitutions_expired === true)
1448
2017
  throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_expired');
1449
2018
  const sub = s.substitutions;
1450
- const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
1451
- && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
1452
- && Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
2019
+ // Shiji STS carries its fields at the body root (`at` is ''), and
2020
+ // exactly pan, expires and cvv, each a 128-byte ciphertext, beside
2021
+ // the page's reviewed getCvvId keys (no keys, no continuation);
2022
+ // every other processor names a holder path, as before.
2023
+ const shiji = rec?.psp === SHIJI_STS_PSP;
2024
+ const fieldsOk = shiji ? isShijiStsServedSubstitution(sub) && isShijiStsServedCompanion(s.shiji_sts_companion)
2025
+ : sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
2026
+ && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
2027
+ && Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
1453
2028
  if (!fieldsOk)
1454
2029
  throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
1455
2030
  // A Fiserv approval carries exactly its envelope's four members at
@@ -1473,12 +2048,39 @@ export class VaultClient {
1473
2048
  fields: { ...sub.fields },
1474
2049
  ...(removeRaw ? { remove: [...removeRaw] } : {}),
1475
2050
  },
2051
+ ...(shiji ? { shijiStsCompanion: Object.freeze({ keySequences: Object.freeze([...s.shiji_sts_companion.key_sequences]) }) } : {}),
1476
2052
  ...amountAuthority,
1477
2053
  ...execution,
1478
2054
  };
1479
2055
  }
1480
- if (approvedMode !== 'token')
2056
+ if (approvedMode === 'device_encrypt') {
2057
+ // The device sent the card to Payrails itself; this is Payrails' answer to it. It
2058
+ // enters the page only as a 201 for a card instrument, rebuilt from payment-core's
2059
+ // allow-list under a JSON content type (payrails.ts payrailsSaveAnswer). Anything else
2060
+ // is never handed over: the card may be saved, so the outcome is unknown, not declined.
2061
+ // Autopilot never runs this mode, so an autopilot answer is not one to act on either.
2062
+ if (execution.executionMode === 'autopilot')
2063
+ throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
2064
+ if (mode !== 'device_encrypt')
2065
+ throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
2066
+ const saved = s.response;
2067
+ const answer = saved && typeof saved === 'object' && Number.isInteger(saved.status) && typeof saved.body === 'string'
2068
+ ? payrailsSaveAnswer(saved.status, saved.body) : null;
2069
+ if (!answer)
2070
+ throw new PaymentOutcomeUnknownError(authorizationId, 'payrails_answer_unconfirmed');
2071
+ if (s.processor_context !== undefined)
2072
+ throw new PaymentOutcomeUnknownError(authorizationId, 'unexpected_processor_context');
2073
+ return { mode: 'device_encrypt', authorizationId, ...answer, ...amountAuthority, ...execution };
2074
+ }
2075
+ // A save created as device_encrypt is answered only as one: Payrails' raw answer never
2076
+ // passes through the token path below.
2077
+ if (approvedMode !== 'token' || mode === 'device_encrypt')
1481
2078
  throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
2079
+ // The API serves an approved answer whose landing url carries a processor's card token
2080
+ // (CreditGuard's) only for a window after the approval, this SDK's default timeoutMs; one it
2081
+ // no longer serves is an unknown outcome, never a page to continue.
2082
+ if (s.response_expired === true)
2083
+ throw new PaymentOutcomeUnknownError(authorizationId, 'response_expired');
1482
2084
  const response = s.response;
1483
2085
  if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
1484
2086
  || typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
@@ -1490,6 +2092,41 @@ export class VaultClient {
1490
2092
  if (flex && (response.status < 200 || response.status > 299 || !isCybersourceFlexTokenShape(response.body))) {
1491
2093
  throw new PaymentOutcomeUnknownError(authorizationId, 'cybersource_flex_token_unconfirmed');
1492
2094
  }
2095
+ if (rec?.psp === JUSPAY_PSP) {
2096
+ // The page receives only the allowlisted /txns answer for the paused
2097
+ // order, re-serialized here from the stored one (juspayPageAnswer),
2098
+ // with a JSON content type as its one header. Anything else is not an
2099
+ // answer to hand the page: the device may already have sent the payment.
2100
+ const answer = juspayPageAnswer(response.status, response.body, input.request);
2101
+ if (answer === null)
2102
+ throw new PaymentOutcomeUnknownError(authorizationId, 'juspay_response_unconfirmed');
2103
+ response.body = answer;
2104
+ response.headers = { 'content-type': 'application/json' };
2105
+ }
2106
+ if (rec?.psp === ACI_PSP) {
2107
+ // ACI: the paused card-number request, and the page's CVC and expiry requests the
2108
+ // adapters answer with the same bytes, get exactly ACI's rebuilt success answer under
2109
+ // JSON. Anything else is not an answer to hand the page, and the device may already have
2110
+ // put the real card on the checkout.
2111
+ const answer = aciCardLegAnswer(response);
2112
+ if (!answer)
2113
+ throw new PaymentOutcomeUnknownError(authorizationId, 'aci_answer_unconfirmed');
2114
+ response.status = answer.status;
2115
+ response.headers = answer.headers;
2116
+ response.body = answer.body;
2117
+ }
2118
+ if (eprotect) {
2119
+ // Worldpay eProtect: the paused <script> is answered the way Worldpay answers it,
2120
+ // `<callback>(<registration>)` under its script type, with the callback the paused URL
2121
+ // named and only a registration payment-core's judge accepts for that URL. Anything else
2122
+ // is not an answer to hand the page, and the device may already have registered the card.
2123
+ const answer = eprotectScriptAnswer(input.request.url, response);
2124
+ if (!answer)
2125
+ throw new PaymentOutcomeUnknownError(authorizationId, 'eprotect_registration_unconfirmed');
2126
+ response.status = answer.status;
2127
+ response.headers = answer.headers;
2128
+ response.body = answer.body;
2129
+ }
1493
2130
  if (shopOrder) {
1494
2131
  let receipt;
1495
2132
  try {
@@ -1502,6 +2139,26 @@ export class VaultClient {
1502
2139
  response.body = JSON.stringify(receipt);
1503
2140
  response.headers = { 'content-type': 'application/json' };
1504
2141
  }
2142
+ if (creditguardTxId !== null) {
2143
+ // A CreditGuard payment is always prepared and approved by the cardholder, and no
2144
+ // autopilot run sends one (payment-core and the autopilot authority refuse it), so an
2145
+ // approval the API reports as unattended is not handed to the page.
2146
+ if (execution.executionMode === 'autopilot')
2147
+ throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
2148
+ // CreditGuard's answer to the device's submit reaches the page only as payment-core
2149
+ // rebuilds it from its allow-list, for the paused transaction, as JSON: an answer that
2150
+ // is not an approval of this txId is an unknown outcome, never handed to the page.
2151
+ let rebuilt;
2152
+ try {
2153
+ if (response.status === 200)
2154
+ rebuilt = sanitizeCreditguardResponse(response.body, { txId: creditguardTxId });
2155
+ }
2156
+ catch { /* Never return an unvalidated processor body to the page. */ }
2157
+ if (rebuilt === undefined)
2158
+ throw new PaymentOutcomeUnknownError(authorizationId, 'creditguard_response_unconfirmed');
2159
+ response.body = rebuilt;
2160
+ response.headers = { 'content-type': 'application/json' };
2161
+ }
1505
2162
  if (checkoutContext) {
1506
2163
  let receipt;
1507
2164
  try {
@@ -1514,7 +2171,7 @@ export class VaultClient {
1514
2171
  response.body = JSON.stringify(receipt);
1515
2172
  response.headers = { 'content-type': 'application/json' };
1516
2173
  }
1517
- if (rec.psp === GMO_PSP) {
2174
+ if (rec?.psp === GMO_PSP) {
1518
2175
  // GMO's answer enters the page only as one clean single-use token under a JSON
1519
2176
  // content type (gmo.ts gmoTokenAnswer). Anything else is never handed over; the
1520
2177
  // card may have left the device, so the outcome is unknown rather than declined.
@@ -1525,6 +2182,18 @@ export class VaultClient {
1525
2182
  response.headers = answer.headers;
1526
2183
  response.body = answer.body;
1527
2184
  }
2185
+ if (rec?.psp === PRIMER_PSP) {
2186
+ // Primer's answer enters the page only as one single-use card token rebuilt from
2187
+ // payment-core's allow-list, under a JSON content type (primer.ts primerTokenAnswer).
2188
+ // Anything else, a multi-use token or vaultData included, is never handed over; the card
2189
+ // may have left the device, so the outcome is unknown rather than declined.
2190
+ const answer = primerTokenAnswer(response.status, response.body);
2191
+ if (!answer)
2192
+ throw new PaymentOutcomeUnknownError(authorizationId, 'primer_token_unconfirmed');
2193
+ response.status = answer.status;
2194
+ response.headers = answer.headers;
2195
+ response.body = answer.body;
2196
+ }
1528
2197
  let processorContext;
1529
2198
  if (mercadoContext) {
1530
2199
  try {
@@ -1595,7 +2264,9 @@ export class VaultClient {
1595
2264
  finally {
1596
2265
  // An attested action-required result retains its claim and reservation.
1597
2266
  // Reporting that state must not request cancellation of the same attempt.
1598
- if (!actionRequired && (input.merchantSignal?.aborted || ((preparation || nativeCheckout) && failed))) {
2267
+ // A card endpoint request that failed here has no page request left to answer, so
2268
+ // it is retired too: the device must not send the card for an answer nobody reads.
2269
+ if (!actionRequired && (input.merchantSignal?.aborted || ((preparation || nativeCheckout || cardEndpoint) && failed))) {
1599
2270
  // Drain a create acknowledgement even after the merchant aborts so its
1600
2271
  // known ID can be retired. An unacknowledged create remains unknown.
1601
2272
  // A started/finalized replay or failed cleanup never becomes a claimed
@@ -1688,6 +2359,65 @@ export class VaultClient {
1688
2359
  }
1689
2360
  return { paymentIntentId: pi.id, amount: pi.amount, currency: pi.currency };
1690
2361
  }
2362
+ /**
2363
+ * Ask whether the page may save the card with the Stripe payment method an
2364
+ * approval produced: a SetupIntent confirm that carries no card and pays with
2365
+ * exactly the approved payment method (the page's own, or written by the
2366
+ * adapters in place of a card the confirm carries again).
2367
+ * The API allows a card save only for a company Agentcard turned it on for,
2368
+ * for an approval given on the Vault page, not by voice, with no amount, and
2369
+ * a SetupIntent Stripe reports still confirmable on the same Stripe account;
2370
+ * any other answer rejects with a CheckoutApiError whose code says why (for
2371
+ * example `continuation_invalid` where it is off, `amount_mismatch`). Nothing
2372
+ * is charged: the adapters continue the page's request once this resolves.
2373
+ */
2374
+ async checkStripeSetupContinuation(authorizationId, request) {
2375
+ if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
2376
+ throw new Error('Invalid authorization ID.');
2377
+ const rec = findRecognizer(request.url, this.registry);
2378
+ if (rec?.psp !== 'stripe')
2379
+ throw new Error('Only a Stripe request can continue an approved Stripe payment method.');
2380
+ let confirmed = null;
2381
+ try {
2382
+ confirmed = /^\/v1\/setup_intents\/(seti_[A-Za-z0-9]+)\/confirm$/.exec(new URL(request.url).pathname)?.[1] ?? null;
2383
+ }
2384
+ catch {
2385
+ confirmed = null;
2386
+ }
2387
+ if (!confirmed)
2388
+ throw new Error('Only a Stripe SetupIntent confirmation can continue into a card save.');
2389
+ const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/continuations`, {
2390
+ request: { url: request.url, method: request.method, headers: pickHeaders(request.headers, rec.passthroughHeaders), body: request.body },
2391
+ }, AbortSignal.timeout(15_000));
2392
+ const si = result?.setup_intent;
2393
+ // The answer names the SetupIntent this page confirms, and no payment.
2394
+ if (result?.object !== 'checkout_continuation' || result.continuation !== 'allowed' || result.authorization !== authorizationId
2395
+ || result.payment_intent !== undefined || !si || si.id !== confirmed) {
2396
+ throw new Error('The continuation answer was not recognized.');
2397
+ }
2398
+ return { setupIntentId: confirmed };
2399
+ }
2400
+ /**
2401
+ * Create a merchant-session probe, the phase-0 read of the deterministic merchant total:
2402
+ * `user` is the org's connected user, who opens the returned Vault link and reads the
2403
+ * merchant's own total once through Agentcard's merchant-session relay. The capture is
2404
+ * the merchant's cookie jar as captureMerchantSession read it; the capture rules run
2405
+ * again here, so no bot-manager cookie and no signed-in session ever leaves. No card is
2406
+ * involved. The API answers 404 for every org not on its probe list.
2407
+ */
2408
+ async createMerchantSessionProbe(input) {
2409
+ const capture = merchantSessionCapture(input.capture.profile, input.capture.cookies);
2410
+ const created = await this.post('/v2/checkout/merchant-session-probes', { user: input.user, profile: capture.profile, session: { cookies: capture.cookies } }, AbortSignal.timeout(15_000));
2411
+ return { id: created.id, profile: created.profile, approvalUrl: created.approval_url, expiresAt: created.expires_at };
2412
+ }
2413
+ /**
2414
+ * Record the same read made from the autopilot egress by the rustls runner (the API's
2415
+ * egressResultSchema: statuses, header names, sizes, times, totals, hashes and the
2416
+ * certificate chain's SPKI hashes). Once per probe.
2417
+ */
2418
+ async recordMerchantSessionEgress(probeId, result) {
2419
+ await this.post(`/v2/checkout/merchant-session-probes/${encodeURIComponent(probeId)}/egress-result`, { result }, AbortSignal.timeout(15_000));
2420
+ }
1691
2421
  /**
1692
2422
  * After a merchant-hosted payment priced by the merchant's own total: what the merchant's
1693
2423
  * confirmation says it charged (merchant-total.ts merchantChargeReport builds `report`
@@ -1896,6 +2626,73 @@ export class VaultClient {
1896
2626
  return this.call(`/v2/checkout/preparations/${id}`, { signal }, false, undefined, 0);
1897
2627
  }
1898
2628
  }
2629
+ /**
2630
+ * A card endpoint request, checked whole before any network call: a profile in this
2631
+ * build's table; no other checkout context, Autopilot or preparation (the cardholder
2632
+ * approves every such payment); the agent's amount, the only authority here; a
2633
+ * session the API and the device would take (payment-core
2634
+ * merchantCardEndpointSessionRefusal: the profile's URL, headers, cookies, Referer and
2635
+ * body, paused on its checkout page); and the profile's status last, so that refusal
2636
+ * means every other check passed. Nothing it throws names the URL, the body or a
2637
+ * session value.
2638
+ */
2639
+ function admitCardEndpoint(input) {
2640
+ const named = input.request.merchant_card_endpoint;
2641
+ const profile = isRecord(named) && typeof named.profile === 'string' ? cardEndpointById(named.profile) : null;
2642
+ if (!profile || !isRecord(named))
2643
+ throw new Error('merchant_card_endpoint names no card endpoint Agentcard reviewed.');
2644
+ if (input.request.mercado_checkout !== undefined || hasStripeCheckoutMarker(input.request) || hasOwnedShopMarker(input.request)) {
2645
+ throw new CardEndpointRefusedError(profile.id, 'request_unsupported', 'the request carries another checkout\'s context');
2646
+ }
2647
+ if (input.executionMode === 'autopilot' || input.grantId !== undefined)
2648
+ throw new CardEndpointRefusedError(profile.id, 'approval_required');
2649
+ if (input.preparation || input.stripeCheckoutEnvironment !== undefined)
2650
+ throw new CardEndpointRefusedError(profile.id, 'preparation_unsupported');
2651
+ if (input.amount == null || typeof input.currency !== 'string' || !input.currency)
2652
+ throw new CardEndpointRefusedError(profile.id, 'amount_required');
2653
+ const session = named.session;
2654
+ if (!isRecord(session) || (input.request.method ?? 'POST').toUpperCase() !== 'POST' || input.request.url !== session.url) {
2655
+ throw new CardEndpointRefusedError(profile.id, 'request_unsupported', 'the request is not the session\'s card request');
2656
+ }
2657
+ const refusal = cardEndpointSessionRefusal(profile, session);
2658
+ if (refusal)
2659
+ throw new CardEndpointRefusedError(profile.id, 'request_unsupported', refusal);
2660
+ if (!cardEndpointLive(profile))
2661
+ throw new CardEndpointRefusedError(profile.id, 'not_enabled');
2662
+ return { profile: profile.id, session };
2663
+ }
2664
+ /**
2665
+ * The merchant's answer to the device's card request, as the API serves it once
2666
+ * (merchant_card_endpoint.answer), checked before any of it reaches the page: the
2667
+ * profile the request named, an HTTP status, string headers, Set-Cookie strings and a
2668
+ * body, or `withheld` with none of them. An answer the API no longer holds
2669
+ * (answer_expired) or cannot vouch for leaves the outcome unknown: the card was sent.
2670
+ */
2671
+ function cardEndpointReplay(s, authorizationId, profile, metadata) {
2672
+ const unknown = (reason) => new PaymentOutcomeUnknownError(authorizationId, reason);
2673
+ if (metadata.executionMode === 'autopilot')
2674
+ throw unknown('autopilot_submission_mode_invalid');
2675
+ const named = s.merchant_card_endpoint;
2676
+ if (!isRecord(named) || named.profile !== profile)
2677
+ throw unknown('merchant_answer_malformed');
2678
+ if (named.answer_expired === true)
2679
+ throw unknown('merchant_answer_expired');
2680
+ const answer = named.answer;
2681
+ if (!isRecord(answer) || !Number.isInteger(answer.status) || answer.status < 100 || answer.status > 599)
2682
+ throw unknown('merchant_answer_malformed');
2683
+ const base = { mode: CARD_ENDPOINT_MODE, authorizationId, profile, status: answer.status, ...metadata };
2684
+ if (answer.withheld !== undefined) {
2685
+ if (answer.withheld !== 'card_in_response' && answer.withheld !== 'unreadable_in_response')
2686
+ throw unknown('merchant_answer_malformed');
2687
+ return { ...base, headers: {}, setCookies: [], body: '', withheld: answer.withheld };
2688
+ }
2689
+ const headers = answer.headers;
2690
+ const setCookies = answer.set_cookies;
2691
+ if (answer.status < 200 || typeof answer.bodyText !== 'string' || !isRecord(headers) || !Object.values(headers).every((value) => typeof value === 'string')
2692
+ || !Array.isArray(setCookies) || !setCookies.every((value) => typeof value === 'string'))
2693
+ throw unknown('merchant_answer_malformed');
2694
+ return { ...base, headers: { ...headers }, setCookies: [...setCookies], body: answer.bodyText, withheld: null };
2695
+ }
1899
2696
  function executionMetadata(state, authorizationId, previous = {}) {
1900
2697
  if (state.execution_mode === undefined)
1901
2698
  return previous;
@@ -1909,6 +2706,15 @@ function executionMetadata(state, authorizationId, previous = {}) {
1909
2706
  throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_changed');
1910
2707
  return { executionMode: 'autopilot', grantId: state.grant_id };
1911
2708
  }
2709
+ /**
2710
+ * What authorize() creates a Verifone authorization as: a cse payment that only a
2711
+ * prepared checkout may make, forwarding no header but content-type. Never matched
2712
+ * against a URL: authorize() uses it only for an armed Verifone profile's request.
2713
+ */
2714
+ const VERIFONE_RECOGNIZER = Object.freeze({
2715
+ psp: 'verifone', match: /(?!)/, encoding: 'json', passthroughHeaders: [],
2716
+ clientSideEncrypted: true, mode: 'cse', preparationRequired: true,
2717
+ });
1912
2718
  /**
1913
2719
  * What authorize() creates a merchant-hosted authorization as: an Adyen cse
1914
2720
  * payment that only a prepared checkout may make (so an unprepared request is
@@ -1939,6 +2745,51 @@ function assertMerchantHostedRequest(input, profile) {
1939
2745
  if (verdict.verdict === 'pass')
1940
2746
  throw new Error(`not a card request Agentcard reviewed for ${profile.merchant}.`);
1941
2747
  }
2748
+ /**
2749
+ * Refuse, before any create or prompt, a Verifone request this client cannot pay: a profile
2750
+ * Agentcard has not turned on (observe), Autopilot (the cardholder approves every one), or a
2751
+ * request its rules do not pause (anything but its reviewed card request).
2752
+ */
2753
+ function assertVerifoneRequest(input, profile) {
2754
+ if (profile.status !== 'enabled') {
2755
+ throw new Error(`Agentcard has not turned on payments to ${profile.merchant} yet. Nothing was charged.`);
2756
+ }
2757
+ if (input.executionMode === 'autopilot' || input.grantId !== undefined) {
2758
+ throw new Error(`A payment to ${profile.merchant} needs the cardholder's approval every time; Autopilot cannot pay it.`);
2759
+ }
2760
+ const verdict = classifyVerifoneProfileRequest(profile, input.request.url, input.request.method ?? 'POST', input.request.body);
2761
+ if (verdict.verdict === 'abort')
2762
+ throw new Error(`Refusing to pay ${profile.merchant}: ${verdict.reason}. Nothing was charged.`);
2763
+ if (verdict.verdict === 'pass')
2764
+ throw new Error(`not a card request Agentcard reviewed for ${profile.merchant}.`);
2765
+ }
2766
+ /**
2767
+ * The approved read of a Verifone authorization, as the replay the adapters continue: the
2768
+ * device's blob for the profile (`kind: 'verifone'`), beside the profile and the SHA-256 of the
2769
+ * body the API checked at create. Anything else, a create that was not Verifone's, another
2770
+ * profile, or a blob the API no longer serves (`substitutions_expired`) is an unknown outcome:
2771
+ * the device may already have encrypted the card, and nothing here can pay with it.
2772
+ */
2773
+ function verifoneApproval(s, authorizationId, profile) {
2774
+ const malformed = () => new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
2775
+ if (!profile)
2776
+ throw malformed();
2777
+ if (s.substitutions_expired === true)
2778
+ throw new PaymentOutcomeUnknownError(authorizationId, 'substitutions_expired');
2779
+ const served = s.verifone;
2780
+ if (!isVerifoneSubstitution(s.substitutions, profile.id) || !isRecord(served) || served.profile !== profile.id
2781
+ || typeof served.body_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(served.body_sha256))
2782
+ throw malformed();
2783
+ return {
2784
+ mode: 'cse',
2785
+ kind: 'verifone',
2786
+ authorizationId,
2787
+ profile: profile.id,
2788
+ substitutions: { encoding: 'json', kind: 'verifone', profile: profile.id, blob: s.substitutions.blob },
2789
+ bodySha256: served.body_sha256,
2790
+ substitutionsExpiresAt: typeof s.substitutions_expires_at === 'string' ? s.substitutions_expires_at : null,
2791
+ };
2792
+ }
1942
2793
  const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
1943
2794
  const FORBIDDEN_STEPS = new Set(['__proto__', 'constructor', 'prototype']);
1944
2795
  /**