@agent-cards/sdk 0.20.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.
- package/CHANGELOG.md +51 -0
- package/PREFLIGHT.md +34 -0
- package/README.md +1217 -15
- package/dist/aci.d.ts +173 -0
- package/dist/aci.generated.d.ts +87 -0
- package/dist/aci.generated.js +306 -0
- package/dist/aci.js +371 -0
- package/dist/adyen-merchant-hosted.generated.d.ts +3 -3
- package/dist/adyen-merchant-hosted.generated.js +283 -49
- package/dist/agentcard.d.ts +9 -2
- package/dist/agentcard.js +4 -2
- package/dist/amadeus-checkout.generated.d.ts +57 -0
- package/dist/amadeus-checkout.generated.js +549 -0
- package/dist/amadeus.d.ts +41 -0
- package/dist/amadeus.js +81 -0
- package/dist/builtin-registry.generated.js +1 -1
- package/dist/card-endpoint.d.ts +177 -0
- package/dist/card-endpoint.js +286 -0
- package/dist/card-fields.generated.d.ts +5 -3
- package/dist/card-fields.generated.js +284 -7
- package/dist/cdp.d.ts +6 -1
- package/dist/cdp.js +983 -147
- package/dist/client.d.ts +493 -13
- package/dist/client.js +1100 -82
- package/dist/creditguard.generated.d.ts +79 -0
- package/dist/creditguard.generated.js +662 -0
- package/dist/cse-body.js +11 -1
- package/dist/cybersource-flex.d.ts +183 -0
- package/dist/cybersource-flex.generated.d.ts +92 -0
- package/dist/cybersource-flex.generated.js +689 -0
- package/dist/cybersource-flex.js +275 -0
- package/dist/datatrans.d.ts +96 -0
- package/dist/datatrans.generated.d.ts +64 -0
- package/dist/datatrans.generated.js +426 -0
- package/dist/datatrans.js +178 -0
- package/dist/elavon-converge.d.ts +37 -0
- package/dist/elavon-converge.generated.d.ts +49 -0
- package/dist/elavon-converge.generated.js +439 -0
- package/dist/elavon-converge.js +84 -0
- package/dist/eprotect.d.ts +152 -0
- package/dist/eprotect.generated.d.ts +77 -0
- package/dist/eprotect.generated.js +498 -0
- package/dist/eprotect.js +222 -0
- package/dist/gmo.d.ts +63 -0
- package/dist/gmo.generated.d.ts +60 -0
- package/dist/gmo.generated.js +586 -0
- package/dist/gmo.js +119 -0
- package/dist/hosted-form.d.ts +3 -2
- package/dist/hosted-form.js +3 -2
- package/dist/index.d.ts +19 -3
- package/dist/index.js +6 -1
- package/dist/juspay-gate.d.ts +82 -0
- package/dist/juspay-gate.js +126 -0
- package/dist/juspay.generated.d.ts +89 -0
- package/dist/juspay.generated.js +705 -0
- package/dist/lifecycle.d.ts +62 -6
- package/dist/lifecycle.js +123 -8
- package/dist/merchant-card-endpoints.generated.d.ts +63 -0
- package/dist/merchant-card-endpoints.generated.js +1103 -0
- package/dist/merchant-hosted.d.ts +4 -1
- package/dist/merchant-hosted.js +5 -2
- package/dist/merchant-session-probe.d.ts +48 -0
- package/dist/merchant-session-probe.js +53 -0
- package/dist/merchant-session.generated.d.ts +69 -0
- package/dist/merchant-session.generated.js +919 -0
- package/dist/payrails.d.ts +133 -0
- package/dist/payrails.generated.d.ts +73 -0
- package/dist/payrails.generated.js +596 -0
- package/dist/payrails.js +305 -0
- package/dist/pelecard.d.ts +32 -0
- package/dist/pelecard.generated.d.ts +32 -0
- package/dist/pelecard.generated.js +283 -0
- package/dist/pelecard.js +42 -0
- package/dist/pre-claim.d.ts +84 -3
- package/dist/pre-claim.js +987 -9
- package/dist/preflight-catalog.json +2310 -263
- package/dist/preflight-schemas.json +187 -6
- package/dist/preflight.generated.d.ts +1 -1
- package/dist/preflight.generated.js +247 -4
- package/dist/preparation.d.ts +5 -2
- package/dist/preparation.js +36 -10
- package/dist/prepared-processor.d.ts +86 -5
- package/dist/prepared-processor.js +157 -6
- package/dist/primer.d.ts +53 -0
- package/dist/primer.generated.d.ts +38 -0
- package/dist/primer.generated.js +624 -0
- package/dist/primer.js +82 -0
- package/dist/registry.d.ts +114 -12
- package/dist/registry.js +85 -11
- package/dist/shiji-sts.d.ts +144 -0
- package/dist/shiji-sts.generated.d.ts +72 -0
- package/dist/shiji-sts.generated.js +913 -0
- package/dist/shiji-sts.js +222 -0
- package/dist/stripe-checkout.generated.d.ts +1 -1
- package/dist/stripe-checkout.generated.js +735 -10
- package/dist/stripe-setup.d.ts +16 -0
- package/dist/stripe-setup.js +61 -0
- package/dist/substitutions.generated.js +187 -29
- package/dist/verifone.d.ts +68 -0
- package/dist/verifone.generated.d.ts +51 -0
- package/dist/verifone.generated.js +1401 -0
- package/dist/verifone.js +119 -0
- package/dist/voice-types.d.ts +27 -0
- package/dist/voice.d.ts +6 -1
- package/dist/voice.js +6 -1
- package/dist/wspay.d.ts +41 -0
- package/dist/wspay.generated.d.ts +41 -0
- package/dist/wspay.generated.js +370 -0
- package/dist/wspay.js +65 -0
- package/examples/preflight/kernel-native/inventory.json +1 -1
- package/package.json +3 -3
package/dist/client.js
CHANGED
|
@@ -1,23 +1,43 @@
|
|
|
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';
|
|
12
20
|
import { Languages } from './languages.js';
|
|
13
21
|
import { RequestRegistry } from './requests.js';
|
|
14
22
|
import { AgentcardError } from './voice-types.js';
|
|
23
|
+
import { GMO_CAPABILITY, GMO_PSP, gmoTokenAnswer } from './gmo.js';
|
|
24
|
+
import { isCybersourceFlexTokenShape } from './cybersource-flex.generated.js';
|
|
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';
|
|
15
33
|
/**
|
|
16
34
|
* The modes this SDK can finish. Asked for on syncRegistry (the API serves
|
|
17
35
|
* only recognizers in these modes, so a request this build cannot complete
|
|
18
|
-
* 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.
|
|
19
39
|
*/
|
|
20
|
-
export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
|
|
40
|
+
export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form', 'device_encrypt'];
|
|
21
41
|
/**
|
|
22
42
|
* Registry features this SDK honours, asked for on syncRegistry next to the
|
|
23
43
|
* modes. `card_fields`: it reads a recognizer's cardFields and claims only a
|
|
@@ -31,31 +51,256 @@ export const SUPPORTED_REGISTRY_FEATURES = ['card_fields', 'checkout_sessions'];
|
|
|
31
51
|
/**
|
|
32
52
|
* Dark-launch capabilities this build can finish. Sent on syncRegistry as
|
|
33
53
|
* ?capabilities= so the API also serves the recognizers gated behind one of
|
|
34
|
-
* these
|
|
54
|
+
* these, and only to an org Agentcard enabled for the processor.
|
|
55
|
+
* `fiserv_card_capture`: Fiserv Commerce Hub's Secure Data Capture card
|
|
35
56
|
* capture, which this build pauses and pays only through a prepared checkout
|
|
36
57
|
* (pre-claim.ts, fiserv.ts). The API serves Fiserv's recognizer only to a build
|
|
37
58
|
* that lists it and only for a company Agentcard turned Fiserv on for, so an older
|
|
38
59
|
* SDK never pauses a capture it cannot finish and no SDK pauses one for a company
|
|
39
60
|
* Fiserv is off for; the API also refuses a Fiserv payment for every such company.
|
|
61
|
+
* `gmo_shared_pk`: GMO token v2's getToken on the shared-key path, which this
|
|
62
|
+
* build judges before it pauses (gmo.ts) and pays only for the amount the
|
|
63
|
+
* agent passes. A gated recognizer never sits in BUILTIN_REGISTRY, so it is
|
|
64
|
+
* paused only after a sync.
|
|
65
|
+
* `capture_context`: CyberSource Flex Microform, whose create carries the capture
|
|
66
|
+
* context this build reads from the card field iframe (cybersource-flex.ts); the API
|
|
67
|
+
* serves Flex's recognizer the same way, only for a company Agentcard turned it on for.
|
|
68
|
+
* A gated recognizer is never in BUILTIN_REGISTRY, so a build reaches it only through a
|
|
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.
|
|
40
157
|
*/
|
|
41
|
-
export const SUPPORTED_CAPABILITIES = ['fiserv_card_capture'];
|
|
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'];
|
|
159
|
+
/**
|
|
160
|
+
* Processors whose paused request names no amount at all, so the pair the
|
|
161
|
+
* agent passes is the amount the cardholder approves: authorize() refuses one
|
|
162
|
+
* without it (AmountRequiredError), and the API answers 400 amount_required
|
|
163
|
+
* for the same case. GMO's card token request carries none: the merchant's
|
|
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.
|
|
173
|
+
*/
|
|
174
|
+
const AGENT_AMOUNT_PROCESSORS = [GMO_PSP, PELECARD_PSP, WSPAY_PSP];
|
|
175
|
+
/** How AmountRequiredError names a processor. */
|
|
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];
|
|
42
185
|
/** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
|
|
43
186
|
export function validAmountInput(amount) {
|
|
44
187
|
if (typeof amount === 'number')
|
|
45
188
|
return Number.isSafeInteger(amount) && amount >= 0;
|
|
46
189
|
return typeof amount === 'string' && /^\d{1,15}\.\d{1,3}$/.test(amount.trim());
|
|
47
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
|
+
}
|
|
48
254
|
const AMOUNT_AUTHORITIES = ['processor', 'agent', 'page', 'none'];
|
|
49
255
|
export class CheckoutPreparationError extends Error {
|
|
50
256
|
preparationId;
|
|
51
257
|
reason;
|
|
52
|
-
|
|
53
|
-
|
|
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}` : ''}`);
|
|
54
267
|
this.preparationId = preparationId;
|
|
55
268
|
this.reason = reason;
|
|
56
269
|
this.name = 'CheckoutPreparationError';
|
|
270
|
+
if (detail)
|
|
271
|
+
this.detail = detail;
|
|
57
272
|
}
|
|
58
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
|
+
}
|
|
59
304
|
export class CardEncryptedError extends Error {
|
|
60
305
|
psp;
|
|
61
306
|
constructor(psp) {
|
|
@@ -78,6 +323,50 @@ export class UnsupportedModeError extends Error {
|
|
|
78
323
|
this.name = 'UnsupportedModeError';
|
|
79
324
|
}
|
|
80
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* The recognizer is gated behind a dark-launch capability this SDK build does not
|
|
328
|
+
* declare (SUPPORTED_CAPABILITIES), so it cannot send what that processor's create
|
|
329
|
+
* needs. Thrown locally before any create or prompt, and terminal. A registry the
|
|
330
|
+
* API served never holds such an entry (it serves a gated one only to a build that
|
|
331
|
+
* declares it); a hand-built one can.
|
|
332
|
+
*/
|
|
333
|
+
export class UnsupportedCapabilityError extends Error {
|
|
334
|
+
capability;
|
|
335
|
+
constructor(capability) {
|
|
336
|
+
super(`the checkout needs the "${capability}" capability, which this version of @agent-cards/checkout cannot complete; upgrade the SDK.`);
|
|
337
|
+
this.capability = capability;
|
|
338
|
+
this.name = 'UnsupportedCapabilityError';
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* placeholderCard() found no stored card to describe (`card_not_found`), the stored card's
|
|
343
|
+
* network has no documented placeholder number (`brand_unsupported`), or the stored card
|
|
344
|
+
* carries no usable expiry (`expiry_unknown`). Nothing was created and nothing was charged.
|
|
345
|
+
*/
|
|
346
|
+
export class PlaceholderCardError extends Error {
|
|
347
|
+
reason;
|
|
348
|
+
constructor(reason) {
|
|
349
|
+
super(`no placeholder card: ${reason}`);
|
|
350
|
+
this.reason = reason;
|
|
351
|
+
this.name = 'PlaceholderCardError';
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The placeholder number per network: a documented test card that the pinned Microform
|
|
356
|
+
* card-type table detects as that network and nothing else. Visa, Mastercard, Amex and JCB
|
|
357
|
+
* are CyberSource's own test cards (developer.cybersource.com hello-world testing guide).
|
|
358
|
+
* Discover is not: CyberSource's 6011111111111117 falls in the table's Maestro range, so a
|
|
359
|
+
* merchant that allows Discover but not Maestro refuses it in the card field itself; Stripe's
|
|
360
|
+
* documented Discover test card 6011000990139424 is detected as Discover alone. A network
|
|
361
|
+
* outside this list has no placeholder and placeholderCard() refuses it rather than guess.
|
|
362
|
+
*/
|
|
363
|
+
const PLACEHOLDER_NUMBERS = Object.freeze({
|
|
364
|
+
visa: '4111111111111111',
|
|
365
|
+
mastercard: '5555555555554444',
|
|
366
|
+
amex: '378282246310005',
|
|
367
|
+
discover: '6011000990139424',
|
|
368
|
+
jcb: '3566111111111113',
|
|
369
|
+
});
|
|
81
370
|
/**
|
|
82
371
|
* The recognizer says this processor is preparation-required, and no preparation
|
|
83
372
|
* was passed. Its card may be sent only after the cardholder approved a
|
|
@@ -94,6 +383,48 @@ export class PreparationRequiredError extends Error {
|
|
|
94
383
|
this.name = 'PreparationRequiredError';
|
|
95
384
|
}
|
|
96
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* The paused request names no amount and none was passed. A GMO card token
|
|
388
|
+
* request carries no amount (the merchant's server charges the token later),
|
|
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.
|
|
398
|
+
*/
|
|
399
|
+
export class AmountRequiredError extends Error {
|
|
400
|
+
psp;
|
|
401
|
+
code = 'amount_required';
|
|
402
|
+
constructor(psp) {
|
|
403
|
+
const processor = PROCESSOR_NAMES[psp] ?? psp;
|
|
404
|
+
super(`A ${processor} payment request names no amount, so pass amount and currency: that is the amount the cardholder approves. Nothing was created and nothing was charged.`);
|
|
405
|
+
this.psp = psp;
|
|
406
|
+
this.name = 'AmountRequiredError';
|
|
407
|
+
}
|
|
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
|
+
}
|
|
97
428
|
export class ApprovalTimeoutError extends Error {
|
|
98
429
|
constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
|
|
99
430
|
}
|
|
@@ -437,10 +768,13 @@ export class CheckoutApiError extends Error {
|
|
|
437
768
|
* already has, or already answered, the prompt for this submission, and
|
|
438
769
|
* once that prior authorization is declined or expired the same form is a
|
|
439
770
|
* new question. The adapters quiet the page's re-post the way they quiet a
|
|
440
|
-
* 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.
|
|
441
775
|
*/
|
|
442
776
|
get permanent() {
|
|
443
|
-
if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission')
|
|
777
|
+
if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission' || this.isStaleFingerprint)
|
|
444
778
|
return false;
|
|
445
779
|
// Nor a refusal by the company preset (a 403 carrying `preset` in the
|
|
446
780
|
// envelope): the company's rules refused THIS purchase, at this merchant,
|
|
@@ -450,6 +784,16 @@ export class CheckoutApiError extends Error {
|
|
|
450
784
|
return false;
|
|
451
785
|
return this.status >= 400 && this.status < 500 && this.status !== 429;
|
|
452
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
|
+
}
|
|
453
797
|
}
|
|
454
798
|
/** The client-credentials exchange was refused: the RFC 6749 error, with the status the token endpoint answered. */
|
|
455
799
|
export class CheckoutAuthError extends Error {
|
|
@@ -497,18 +841,46 @@ function parseErrorEnvelope(bodyText) {
|
|
|
497
841
|
}
|
|
498
842
|
return { code: null, details: {} };
|
|
499
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;
|
|
500
858
|
/**
|
|
501
859
|
* A URL as it may appear in an error message: origin + path only. A paused
|
|
502
860
|
* request's URL can carry a client secret in its query string, and error
|
|
503
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.
|
|
504
869
|
*/
|
|
505
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;
|
|
506
875
|
try {
|
|
507
876
|
const u = new URL(raw);
|
|
508
|
-
|
|
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}`;
|
|
509
880
|
}
|
|
510
881
|
catch {
|
|
511
|
-
|
|
882
|
+
const bare = raw.split(/[?#]/)[0].replace(PATH_SECRET, '$1{client_secret}');
|
|
883
|
+
return /creditguard\.co\.il/i.test(bare) ? maskCreditguardTxId(bare) : bare;
|
|
512
884
|
}
|
|
513
885
|
}
|
|
514
886
|
/**
|
|
@@ -540,6 +912,13 @@ const FISERV_PREPARATION_REFUSALS = [
|
|
|
540
912
|
'processor_unavailable', 'unsupported_checkout', 'merchant_profile_required', 'unknown_merchant_profile',
|
|
541
913
|
'merchant_profile_key_changed', 'fiserv_key_refused', 'sandbox_host_on_live_row', 'live_host_on_sandbox_row',
|
|
542
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'];
|
|
543
922
|
/** Default backoff for a 502 amount_unverifiable at create: two retries, then give up. */
|
|
544
923
|
const UNVERIFIABLE_RETRY_DELAYS_MS = [500, 1500];
|
|
545
924
|
const PREPARATION_READ_RETRY_DELAYS_MS = [250, 500];
|
|
@@ -553,6 +932,20 @@ function transientReadNetworkError(error) {
|
|
|
553
932
|
const code = error.cause?.code ?? error.code;
|
|
554
933
|
return typeof code === 'string' && TRANSIENT_READ_NETWORK_CODES.has(code);
|
|
555
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']);
|
|
556
949
|
export class VaultClient {
|
|
557
950
|
opts;
|
|
558
951
|
baseUrl;
|
|
@@ -568,6 +961,12 @@ export class VaultClient {
|
|
|
568
961
|
* in 'observe' until a sync reads that the API enabled it (armServedProfiles).
|
|
569
962
|
*/
|
|
570
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 = [];
|
|
571
970
|
/** This client's own sandbox declarations (sandboxMerchants), armed at construction, one per profile. */
|
|
572
971
|
declaredProfiles;
|
|
573
972
|
preparations = new WeakSet();
|
|
@@ -671,9 +1070,14 @@ export class VaultClient {
|
|
|
671
1070
|
mergedRegistry() {
|
|
672
1071
|
return this.extraRecognizers.length ? [...this.extraRecognizers, ...this.registry] : this.registry;
|
|
673
1072
|
}
|
|
674
|
-
/**
|
|
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
|
+
*/
|
|
675
1078
|
recognizerOf(url, method = 'POST') {
|
|
676
|
-
|
|
1079
|
+
const rec = findRecognizer(url, this.mergedRegistry(), { sandbox: this.sandbox });
|
|
1080
|
+
return rec !== null && isRecognizerRequest(rec, url, method) ? rec : null;
|
|
677
1081
|
}
|
|
678
1082
|
/** Refresh recognizers from the API so new PSPs work without a redeploy. */
|
|
679
1083
|
async syncRegistry() {
|
|
@@ -688,9 +1092,31 @@ export class VaultClient {
|
|
|
688
1092
|
let raw;
|
|
689
1093
|
// ?capabilities= is the dark-launch half of the same negotiation: a
|
|
690
1094
|
// processor gated behind a capability is served only when this build lists
|
|
691
|
-
// it
|
|
692
|
-
//
|
|
693
|
-
//
|
|
1095
|
+
// it (SUPPORTED_CAPABILITIES: fiserv_card_capture, gmo_shared_pk,
|
|
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.
|
|
694
1120
|
const capabilities = SUPPORTED_CAPABILITIES.length ? `&capabilities=${SUPPORTED_CAPABILITIES.join(',')}` : '';
|
|
695
1121
|
// merchant_profiles=1 asks for the reviewed Adyen merchant profiles too, each
|
|
696
1122
|
// as a `{ merchant_profile }` entry after the recognizers; an API that
|
|
@@ -707,12 +1133,31 @@ export class VaultClient {
|
|
|
707
1133
|
if (!Array.isArray(raw))
|
|
708
1134
|
return;
|
|
709
1135
|
const isProfile = (e) => !!e && typeof e === 'object' && 'merchant_profile' in e;
|
|
710
|
-
this.registry = raw.filter((e) => !isProfile(e)).map((e) => ({
|
|
1136
|
+
this.registry = raw.filter((e) => !isProfile(e) && !isVerifoneProfileEntry(e)).map((e) => ({
|
|
711
1137
|
...e,
|
|
712
1138
|
match: new RegExp(e.match, 'i'),
|
|
713
1139
|
passthroughHeaders: e.passthroughHeaders.map((h) => new RegExp(h, 'i')),
|
|
714
1140
|
}));
|
|
715
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);
|
|
716
1161
|
}
|
|
717
1162
|
/**
|
|
718
1163
|
* The reviewed Adyen merchant profile whose card endpoint (or a sibling of it,
|
|
@@ -740,28 +1185,73 @@ export class VaultClient {
|
|
|
740
1185
|
merchantProfileUrlPatterns() {
|
|
741
1186
|
return merchantProfileUrlPatterns([...this.declaredProfiles, ...this.merchantProfiles]);
|
|
742
1187
|
}
|
|
743
|
-
/**
|
|
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
|
+
*/
|
|
744
1205
|
isCardRequest(url, method = 'POST') {
|
|
745
1206
|
return this.recognizerOf(url, method) !== null;
|
|
746
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
|
+
}
|
|
747
1221
|
/**
|
|
748
1222
|
* What happens to a card request (by URL) whose body carries no card: null
|
|
749
1223
|
* when it carries one, so it is a card request as usual; 'continue' when the
|
|
750
1224
|
* recognizer marks the endpoint as one where such requests are routine and
|
|
751
1225
|
* never ours; 'refuse' otherwise, such as a Stripe confirmation paying with
|
|
752
|
-
* a method this checkout never approved. The adapters
|
|
753
|
-
*
|
|
754
|
-
*
|
|
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.
|
|
755
1238
|
*/
|
|
756
|
-
withoutCard(url, body) {
|
|
1239
|
+
withoutCard(url, body, handedTo = []) {
|
|
757
1240
|
if (body == null)
|
|
758
1241
|
return null;
|
|
759
|
-
const
|
|
760
|
-
|
|
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';
|
|
761
1251
|
}
|
|
762
1252
|
/**
|
|
763
|
-
* How the card would reach the processor on this request (`token`, `cse
|
|
764
|
-
* or `
|
|
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
|
|
765
1255
|
* registry does not recognize it. The adapters read it to decide whether an
|
|
766
1256
|
* approval outlives the page's own request: a hosted form is a navigation
|
|
767
1257
|
* and cannot.
|
|
@@ -786,12 +1276,48 @@ export class VaultClient {
|
|
|
786
1276
|
const registry = this.mergedRegistry();
|
|
787
1277
|
return this.sandbox ? [...deriveCardUrlPatterns(registry), ...LOOPBACK_URL_PATTERNS] : deriveCardUrlPatterns(registry);
|
|
788
1278
|
}
|
|
1279
|
+
/**
|
|
1280
|
+
* What to type into a checkout so the page's own card facts match the stored card that
|
|
1281
|
+
* pays (see PlaceholderCard): read from GET /api/v2/vault_cards, which lists each stored card's
|
|
1282
|
+
* network and expiry. `cardId` names the stored card; omitted, the user's most recently added
|
|
1283
|
+
* card. Pin the returned `cardId` on the attachment or authorize(), so the approval page
|
|
1284
|
+
* preselects that card: without a pin the page preselects its own choice (its recommended
|
|
1285
|
+
* card, or the first card the cardholder's device can open), which can be another card. After
|
|
1286
|
+
* prepare(), pass the prepared checkout's `cardId`, the card the cardholder already approved.
|
|
1287
|
+
* The facts hold only while the cardholder pays with that card. Throws PlaceholderCardError
|
|
1288
|
+
* when there is no such card, its network has no placeholder, or it carries no expiry.
|
|
1289
|
+
*/
|
|
1290
|
+
async placeholderCard(input) {
|
|
1291
|
+
if (!input || typeof input.user !== 'string' || !input.user)
|
|
1292
|
+
throw new Error('placeholderCard needs the user whose stored card will pay.');
|
|
1293
|
+
// The card list lives under /api/v2 only: the API aliases /v2/checkout alone without the prefix.
|
|
1294
|
+
const list = await this.get(`/api/v2/vault_cards?user_id=${encodeURIComponent(input.user)}`, input.signal);
|
|
1295
|
+
const cards = Array.isArray(list?.data) ? list.data : [];
|
|
1296
|
+
const card = (input.cardId === undefined ? cards[0] : cards.find((entry) => entry?.id === input.cardId));
|
|
1297
|
+
if (!card || typeof card !== 'object' || typeof card.id !== 'string' || !card.id)
|
|
1298
|
+
throw new PlaceholderCardError('card_not_found');
|
|
1299
|
+
const brand = typeof card.brand === 'string' ? card.brand.toLowerCase() : '';
|
|
1300
|
+
const number = Object.prototype.hasOwnProperty.call(PLACEHOLDER_NUMBERS, brand) ? PLACEHOLDER_NUMBERS[brand] : undefined;
|
|
1301
|
+
if (!number)
|
|
1302
|
+
throw new PlaceholderCardError('brand_unsupported');
|
|
1303
|
+
const month = card.expiry_month, year = card.expiry_year;
|
|
1304
|
+
if (!Number.isSafeInteger(month) || month < 1 || month > 12
|
|
1305
|
+
|| !Number.isSafeInteger(year) || year < 2000 || year > 2099)
|
|
1306
|
+
throw new PlaceholderCardError('expiry_unknown');
|
|
1307
|
+
return Object.freeze({
|
|
1308
|
+
cardId: card.id, brand, number, securityCode: brand === 'amex' ? '1234' : '123',
|
|
1309
|
+
expiryMonth: String(month).padStart(2, '0'), expiryYear: String(year),
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
789
1312
|
/** Wait for real device approval before the caller starts native tokenization. */
|
|
790
1313
|
async prepareCheckout(input) {
|
|
791
1314
|
input = { ...input };
|
|
792
1315
|
const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
|
|
793
1316
|
if (!validPreparationEnvironment(input.psp, input.environment))
|
|
794
1317
|
throw fail('unsupported_processor');
|
|
1318
|
+
const pageRefusal = paymentPageRefusal(input);
|
|
1319
|
+
if (pageRefusal)
|
|
1320
|
+
throw fail(pageRefusal);
|
|
795
1321
|
// A merchant-hosted preparation pays one reviewed profile's card request: the
|
|
796
1322
|
// profile must be armed here, enabled, and paid in its own environment.
|
|
797
1323
|
// A Fiserv checkout names the key Agentcard pinned for its merchant, in that key's own
|
|
@@ -799,7 +1325,21 @@ export class VaultClient {
|
|
|
799
1325
|
// this preparation pays must carry an envelope under that key (matchesPreparation).
|
|
800
1326
|
let profile = null;
|
|
801
1327
|
let fiservPin = null;
|
|
802
|
-
|
|
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') {
|
|
803
1343
|
if (input.merchantProfile === undefined)
|
|
804
1344
|
throw fail('merchant_profile_required');
|
|
805
1345
|
fiservPin = fiservKeyPinById(input.merchantProfile);
|
|
@@ -821,11 +1361,25 @@ export class VaultClient {
|
|
|
821
1361
|
throw fail('unsupported_processor');
|
|
822
1362
|
}
|
|
823
1363
|
const declaration = profile?.sandboxDeclaration;
|
|
824
|
-
|
|
825
|
-
|
|
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
|
+
}
|
|
826
1376
|
const origin = new URL(input.merchantOrigin);
|
|
827
1377
|
if (!(origin.protocol === 'https:' || (origin.protocol === 'http:' && origin.hostname === 'localhost')) || origin.origin !== input.merchantOrigin)
|
|
828
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');
|
|
829
1383
|
if (!input.checkoutKey || !input.user || !input.merchant)
|
|
830
1384
|
throw fail('checkout_context_required');
|
|
831
1385
|
// The cardholder approves a payment to the merchant the key pin belongs to, so a Fiserv
|
|
@@ -833,6 +1387,15 @@ export class VaultClient {
|
|
|
833
1387
|
// authorization it pays then name one merchant, never a label the caller chose.
|
|
834
1388
|
if (fiservPin && input.merchant !== fiservPin.merchant)
|
|
835
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);
|
|
836
1399
|
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
837
1400
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
838
1401
|
throw fail('timeout_invalid');
|
|
@@ -850,7 +1413,10 @@ export class VaultClient {
|
|
|
850
1413
|
environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
|
|
851
1414
|
...(profile ? { merchant_profile: profile.id } : {}),
|
|
852
1415
|
...(fiservPin ? { merchant_profile: fiservPin.id } : {}),
|
|
1416
|
+
...(verifone ? { merchant_profile: verifone.id } : {}),
|
|
853
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 } : {}),
|
|
854
1420
|
}, AbortSignal.timeout(30_000), signal);
|
|
855
1421
|
if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
|
|
856
1422
|
throw fail('create_unconfirmed');
|
|
@@ -881,12 +1447,16 @@ export class VaultClient {
|
|
|
881
1447
|
// A merchant-hosted preparation names the merchant its reviewed profile names, and
|
|
882
1448
|
// a declared test endpoint names its host (declareSandboxMerchant), never the caller's label.
|
|
883
1449
|
// A Fiserv preparation names the merchant its key pin belongs to (input.merchant, checked above), and that pin.
|
|
884
|
-
|
|
885
|
-
|| state.
|
|
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)
|
|
886
1454
|
|| !servesDeclaration(state.sandbox_declaration, declaration)
|
|
887
1455
|
|| !Number.isSafeInteger(state.amount) || (typeof input.amount === 'number' && state.amount !== input.amount) || state.currency !== input.currency.toLowerCase()
|
|
888
1456
|
|| state.psp !== input.psp || state.mode !== preparationMode(input.psp) || state.environment !== input.environment
|
|
889
|
-
|| 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)))
|
|
890
1460
|
throw fail('ready_unconfirmed', id);
|
|
891
1461
|
const prepared = Object.freeze({
|
|
892
1462
|
id: preparationId, status: 'ready', psp: input.psp, environment: input.environment, mode: preparationMode(input.psp), expiresAt: state.ready_expires_at,
|
|
@@ -896,7 +1466,10 @@ export class VaultClient {
|
|
|
896
1466
|
paymentStatus: 'not_started', amountAuthority: 'agent',
|
|
897
1467
|
...(profile ? { merchantProfile: profile.id } : {}),
|
|
898
1468
|
...(fiservPin ? { merchantProfile: fiservPin.id } : {}),
|
|
1469
|
+
...(verifone ? { merchantProfile: verifone.id } : {}),
|
|
899
1470
|
...(declaration ? { sandboxDeclaration: declaration } : {}),
|
|
1471
|
+
...(input.psp === 'creditguard' ? { paymentPageUrl: input.paymentPageUrl } : {}),
|
|
1472
|
+
...(eprotect ? { paypageId: input.paypageId } : {}),
|
|
900
1473
|
});
|
|
901
1474
|
this.preparations.add(prepared);
|
|
902
1475
|
ready = true;
|
|
@@ -911,13 +1484,17 @@ export class VaultClient {
|
|
|
911
1484
|
catch (error) {
|
|
912
1485
|
if (error instanceof CheckoutPreparationError)
|
|
913
1486
|
throw error;
|
|
1487
|
+
const refusal = input.psp === 'creditguard' && id === null ? creditguardPreparationRefusal(error) : null;
|
|
1488
|
+
if (refusal)
|
|
1489
|
+
throw refusal;
|
|
914
1490
|
// A refusal the API answered before any preparation existed names its rule: one
|
|
915
1491
|
// PREPARATION_REFUSALS names for any checkout, and, for a Fiserv checkout only, one
|
|
916
1492
|
// FISERV_PREPARATION_REFUSALS names or a 502 cse_key_unavailable. Every other
|
|
917
1493
|
// processor's other refusals stay preparation_unconfirmed, as before.
|
|
918
1494
|
if (!id && !signal.aborted && error instanceof CheckoutApiError && error.code
|
|
919
1495
|
&& ((error.status >= 400 && error.status < 500 && (PREPARATION_REFUSALS.includes(error.code)
|
|
920
|
-
|| (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))))
|
|
921
1498
|
|| (input.psp === 'fiserv' && error.status === 502 && error.code === 'cse_key_unavailable')))
|
|
922
1499
|
throw fail(error.code);
|
|
923
1500
|
throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
|
|
@@ -935,6 +1512,30 @@ export class VaultClient {
|
|
|
935
1512
|
if (state?.id !== id || !['cancelled', 'expired'].includes(state.status))
|
|
936
1513
|
throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
|
|
937
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
|
+
}
|
|
938
1539
|
async observePreparation(id, guidance, reason) {
|
|
939
1540
|
try {
|
|
940
1541
|
await this.post(`/v2/checkout/preparations/${id}/observe`, {
|
|
@@ -961,6 +1562,9 @@ export class VaultClient {
|
|
|
961
1562
|
* response to replay into the browser. Your process never sees a card.
|
|
962
1563
|
*/
|
|
963
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);
|
|
964
1568
|
const mercadoContext = input.request.mercado_checkout === undefined ? undefined : parseMercadoCheckoutContext(input.request.mercado_checkout);
|
|
965
1569
|
if (mercadoContext && (!isMercadoTokenRequest(input.request.url, input.request.method)
|
|
966
1570
|
|| input.executionMode === 'autopilot' || input.grantId))
|
|
@@ -1021,14 +1625,16 @@ export class VaultClient {
|
|
|
1021
1625
|
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
1022
1626
|
// An armed merchant profile's endpoint (merchant-hosted.ts): the reviewed
|
|
1023
1627
|
// merchant's own server, never a processor host, so no recognizer matches it.
|
|
1024
|
-
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);
|
|
1025
1631
|
// A preparation pays the profile it was made for and nothing else: a
|
|
1026
1632
|
// merchant-hosted one never a processor's request or another profile's (two
|
|
1027
1633
|
// profiles could share an endpoint), and a processor-hosted one never a
|
|
1028
1634
|
// merchant's own endpoint. Checked here, not left to the matcher above, so the
|
|
1029
1635
|
// create below can only ever name the profile the cardholder prepared. A Fiserv
|
|
1030
1636
|
// preparation names a key pin, not a reviewed profile (matchesPreparation binds it).
|
|
1031
|
-
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)
|
|
1032
1638
|
|| (preparation.sandboxDeclaration?.endpoint ?? null) !== (merchantProfile?.sandboxDeclaration?.endpoint ?? null))) {
|
|
1033
1639
|
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
1034
1640
|
}
|
|
@@ -1038,46 +1644,99 @@ export class VaultClient {
|
|
|
1038
1644
|
}
|
|
1039
1645
|
if (merchantProfile)
|
|
1040
1646
|
assertMerchantHostedRequest(input, merchantProfile);
|
|
1041
|
-
|
|
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')
|
|
1042
1653
|
: findRecognizer(input.request.url, this.registry, { sandbox: this.sandbox });
|
|
1043
|
-
if (!rec)
|
|
1654
|
+
if (!cardEndpoint && !rec)
|
|
1044
1655
|
throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
|
|
1045
1656
|
// A client-side-encrypted processor is only takeable when its entry says
|
|
1046
1657
|
// the vault produces that ciphertext itself (mode cse); a bare
|
|
1047
1658
|
// clientSideEncrypted entry (an older registry, a hand-built one) still
|
|
1048
1659
|
// refuses here, exactly as before.
|
|
1049
|
-
const mode = rec.mode ?? 'token';
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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);
|
|
1695
|
+
}
|
|
1696
|
+
// A dark-launch recognizer names the capability its create needs. A build that does not
|
|
1697
|
+
// declare it cannot send what that create carries, so it refuses here, before any create;
|
|
1698
|
+
// a registry the API served never holds such an entry.
|
|
1699
|
+
if (rec && rec.capability !== undefined && !SUPPORTED_CAPABILITIES.includes(rec.capability))
|
|
1700
|
+
throw new UnsupportedCapabilityError(rec.capability);
|
|
1701
|
+
// CyberSource Flex: the capture context and the Microform fields ride on the request (the
|
|
1702
|
+
// adapters read both from the card field iframe) and the API verifies them before anything
|
|
1703
|
+
// exists. They belong to a Flex request and nothing else, as the API holds them.
|
|
1704
|
+
const flex = rec?.psp === CYBERSOURCE_FLEX_PSP;
|
|
1705
|
+
if (!flex && (input.request.capture_context !== undefined || input.request.microform !== undefined)) {
|
|
1706
|
+
throw new Error('request.capture_context and request.microform belong to a CyberSource Flex card request.');
|
|
1707
|
+
}
|
|
1708
|
+
if (flex && !validCybersourceFlexSidecar(input.request.capture_context, input.request.microform)) {
|
|
1709
|
+
throw new Error('A CyberSource Flex card request carries the capture context its card field iframe was loaded with (request.capture_context) '
|
|
1710
|
+
+ 'and the Microform fields that loaded (request.microform); attachToCdp and attachToPlaywright read both.');
|
|
1067
1711
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
//
|
|
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.
|
|
1077
1726
|
const method = (input.request.method ?? 'POST').toUpperCase();
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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)}.`);
|
|
1081
1740
|
}
|
|
1082
1741
|
// The amount is a hint: a pair or nothing. Refuse half a pair here, before
|
|
1083
1742
|
// a network call: the API would too, and a retry loop cannot fix a
|
|
@@ -1087,8 +1746,26 @@ export class VaultClient {
|
|
|
1087
1746
|
if (hasAmount !== hasCurrency) {
|
|
1088
1747
|
throw new Error('amount and currency go together: pass both or neither.');
|
|
1089
1748
|
}
|
|
1090
|
-
if (hasAmount && !validAmountInput(input.amount))
|
|
1091
|
-
throw new Error(
|
|
1749
|
+
if (hasAmount && !validAmountInput(input.amount))
|
|
1750
|
+
throw new Error(amountRefusal(input.amount, input.currency));
|
|
1751
|
+
// A processor whose request names no amount (AGENT_AMOUNT_PROCESSORS) is
|
|
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.
|
|
1754
|
+
// Refused before any create or prompt; the API refuses the same create as
|
|
1755
|
+
// amount_required.
|
|
1756
|
+
if (!hasAmount && rec && AGENT_AMOUNT_PROCESSORS.includes(rec.psp))
|
|
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.');
|
|
1092
1769
|
}
|
|
1093
1770
|
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
1094
1771
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
@@ -1101,7 +1778,21 @@ export class VaultClient {
|
|
|
1101
1778
|
const priced = merchantProfile && merchantProfilePricedBySource(merchantProfile.id)
|
|
1102
1779
|
? await this.readMerchantTotal(input, merchantProfile, preparation, operationSignal) : null;
|
|
1103
1780
|
let created;
|
|
1104
|
-
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
|
+
} : {
|
|
1105
1796
|
user: input.user,
|
|
1106
1797
|
merchant: input.merchant,
|
|
1107
1798
|
// snake_case on the wire; camelCase is this SDK's convention.
|
|
@@ -1124,17 +1815,29 @@ export class VaultClient {
|
|
|
1124
1815
|
endpoint: merchantProfile.sandboxDeclaration.endpoint, client_key: merchantProfile.sandboxDeclaration.clientKey, environment: 'test'
|
|
1125
1816
|
} } : {}),
|
|
1126
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 } } : {}),
|
|
1127
1821
|
// A prepared checkout already carries the page origin as merchant_origin.
|
|
1128
1822
|
...(preparation
|
|
1129
1823
|
? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin, form_guidance: 'filled' }
|
|
1130
1824
|
: input.pageOrigin ? { checkout_origin: input.pageOrigin } : {}),
|
|
1131
|
-
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: '' } : {
|
|
1132
1828
|
url: input.request.url,
|
|
1133
1829
|
method: input.request.method,
|
|
1134
1830
|
headers: { ...pickHeaders(input.request.headers, rec.passthroughHeaders),
|
|
1135
1831
|
...(checkoutContext ? { [STRIPE_CHECKOUT_CONTEXT_HEADER]: encodeStripeCheckoutContext(checkoutContext) } : {}) },
|
|
1136
1832
|
body: input.request.body,
|
|
1137
1833
|
...(mercadoContext ? { mercado_checkout: mercadoContext } : {}),
|
|
1834
|
+
...(flex ? {
|
|
1835
|
+
capture_context: input.request.capture_context,
|
|
1836
|
+
microform: { version: input.request.microform.version, fields: [...input.request.microform.fields],
|
|
1837
|
+
...(input.request.microform.trigger ? { trigger: input.request.microform.trigger } : {}) },
|
|
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 } } : {}),
|
|
1138
1841
|
},
|
|
1139
1842
|
};
|
|
1140
1843
|
try {
|
|
@@ -1174,7 +1877,11 @@ export class VaultClient {
|
|
|
1174
1877
|
// 10 seconds has at most 20 reads instead of 5 (15 additional reads).
|
|
1175
1878
|
// Then restore the caller's interval. Human approval waiting, cancellation,
|
|
1176
1879
|
// merchant deadlines, and the single processor request remain unchanged.
|
|
1177
|
-
|
|
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;
|
|
1178
1885
|
let execution = {};
|
|
1179
1886
|
let approvalDelivered = false;
|
|
1180
1887
|
const deliverApproval = (state) => {
|
|
@@ -1220,7 +1927,7 @@ export class VaultClient {
|
|
|
1220
1927
|
while (Date.now() < deadline) {
|
|
1221
1928
|
if (stopSignal?.aborted)
|
|
1222
1929
|
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
1223
|
-
const pollInterval = Date.now() <
|
|
1930
|
+
const pollInterval = Date.now() < preparedFastPollUntil ? Math.min(this.pollIntervalMs, 500) : this.pollIntervalMs;
|
|
1224
1931
|
await interruptibleSleep(Math.min(pollInterval, Math.max(0, deadline - Date.now())), stopSignal);
|
|
1225
1932
|
if (stopSignal?.aborted)
|
|
1226
1933
|
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
@@ -1255,7 +1962,7 @@ export class VaultClient {
|
|
|
1255
1962
|
// database forces these rows to cse. A finished read in any other mode
|
|
1256
1963
|
// (token, a missing mode, a hosted form) is not one to act on, and never
|
|
1257
1964
|
// reaches the code that would answer the merchant's paused request with it.
|
|
1258
|
-
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') {
|
|
1259
1966
|
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_hosted_mode_invalid');
|
|
1260
1967
|
}
|
|
1261
1968
|
if (s.status === 'submitted_on_device') {
|
|
@@ -1276,6 +1983,13 @@ export class VaultClient {
|
|
|
1276
1983
|
}
|
|
1277
1984
|
if (s.status === 'approved') {
|
|
1278
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
|
+
}
|
|
1279
1993
|
if (approvedMode === 'hosted_form') {
|
|
1280
1994
|
// Never: the API finishes a hosted form as submitted_on_device, and
|
|
1281
1995
|
// its database refuses `approved` on that mode. An answer that says
|
|
@@ -1287,19 +2001,30 @@ export class VaultClient {
|
|
|
1287
2001
|
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
|
|
1288
2002
|
// A merchant-hosted create reads back only a merchant-hosted approval,
|
|
1289
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
|
+
}
|
|
1290
2008
|
if (merchantProfile || (s.substitutions && typeof s.substitutions === 'object' && 'kind' in s.substitutions)) {
|
|
1291
2009
|
return { ...merchantHostedApproval(s, authorizationId, merchantProfile), ...(priced ? { merchantTotal: priced.approved } : {}),
|
|
1292
2010
|
...amountAuthority, ...execution };
|
|
1293
2011
|
}
|
|
1294
|
-
// The API serves a Fiserv envelope only briefly after the
|
|
1295
|
-
//
|
|
1296
|
-
// window says so instead of serving it; the
|
|
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.
|
|
1297
2016
|
if (s.substitutions_expired === true)
|
|
1298
2017
|
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_expired');
|
|
1299
2018
|
const sub = s.substitutions;
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
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);
|
|
1303
2028
|
if (!fieldsOk)
|
|
1304
2029
|
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
|
|
1305
2030
|
// A Fiserv approval carries exactly its envelope's four members at
|
|
@@ -1323,17 +2048,85 @@ export class VaultClient {
|
|
|
1323
2048
|
fields: { ...sub.fields },
|
|
1324
2049
|
...(removeRaw ? { remove: [...removeRaw] } : {}),
|
|
1325
2050
|
},
|
|
2051
|
+
...(shiji ? { shijiStsCompanion: Object.freeze({ keySequences: Object.freeze([...s.shiji_sts_companion.key_sequences]) }) } : {}),
|
|
1326
2052
|
...amountAuthority,
|
|
1327
2053
|
...execution,
|
|
1328
2054
|
};
|
|
1329
2055
|
}
|
|
1330
|
-
if (approvedMode
|
|
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')
|
|
1331
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');
|
|
1332
2084
|
const response = s.response;
|
|
1333
2085
|
if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
|
|
1334
2086
|
|| typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
|
|
1335
2087
|
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_response_malformed');
|
|
1336
2088
|
}
|
|
2089
|
+
// A Flex approval answers the card field with the transient token CyberSource signed for
|
|
2090
|
+
// the device's sealed card, which the device checked before it approved. Anything else
|
|
2091
|
+
// is not an answer to hand the field iframe: the device may already have sent the card.
|
|
2092
|
+
if (flex && (response.status < 200 || response.status > 299 || !isCybersourceFlexTokenShape(response.body))) {
|
|
2093
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'cybersource_flex_token_unconfirmed');
|
|
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
|
+
}
|
|
1337
2130
|
if (shopOrder) {
|
|
1338
2131
|
let receipt;
|
|
1339
2132
|
try {
|
|
@@ -1346,6 +2139,26 @@ export class VaultClient {
|
|
|
1346
2139
|
response.body = JSON.stringify(receipt);
|
|
1347
2140
|
response.headers = { 'content-type': 'application/json' };
|
|
1348
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
|
+
}
|
|
1349
2162
|
if (checkoutContext) {
|
|
1350
2163
|
let receipt;
|
|
1351
2164
|
try {
|
|
@@ -1358,6 +2171,29 @@ export class VaultClient {
|
|
|
1358
2171
|
response.body = JSON.stringify(receipt);
|
|
1359
2172
|
response.headers = { 'content-type': 'application/json' };
|
|
1360
2173
|
}
|
|
2174
|
+
if (rec?.psp === GMO_PSP) {
|
|
2175
|
+
// GMO's answer enters the page only as one clean single-use token under a JSON
|
|
2176
|
+
// content type (gmo.ts gmoTokenAnswer). Anything else is never handed over; the
|
|
2177
|
+
// card may have left the device, so the outcome is unknown rather than declined.
|
|
2178
|
+
const answer = gmoTokenAnswer(response.status, response.body);
|
|
2179
|
+
if (!answer)
|
|
2180
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'gmo_token_unconfirmed');
|
|
2181
|
+
response.status = answer.status;
|
|
2182
|
+
response.headers = answer.headers;
|
|
2183
|
+
response.body = answer.body;
|
|
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
|
+
}
|
|
1361
2197
|
let processorContext;
|
|
1362
2198
|
if (mercadoContext) {
|
|
1363
2199
|
try {
|
|
@@ -1428,7 +2264,9 @@ export class VaultClient {
|
|
|
1428
2264
|
finally {
|
|
1429
2265
|
// An attested action-required result retains its claim and reservation.
|
|
1430
2266
|
// Reporting that state must not request cancellation of the same attempt.
|
|
1431
|
-
|
|
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))) {
|
|
1432
2270
|
// Drain a create acknowledgement even after the merchant aborts so its
|
|
1433
2271
|
// known ID can be retired. An unacknowledged create remains unknown.
|
|
1434
2272
|
// A started/finalized replay or failed cleanup never becomes a claimed
|
|
@@ -1521,6 +2359,65 @@ export class VaultClient {
|
|
|
1521
2359
|
}
|
|
1522
2360
|
return { paymentIntentId: pi.id, amount: pi.amount, currency: pi.currency };
|
|
1523
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
|
+
}
|
|
1524
2421
|
/**
|
|
1525
2422
|
* After a merchant-hosted payment priced by the merchant's own total: what the merchant's
|
|
1526
2423
|
* confirmation says it charged (merchant-total.ts merchantChargeReport builds `report`
|
|
@@ -1729,6 +2626,73 @@ export class VaultClient {
|
|
|
1729
2626
|
return this.call(`/v2/checkout/preparations/${id}`, { signal }, false, undefined, 0);
|
|
1730
2627
|
}
|
|
1731
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
|
+
}
|
|
1732
2696
|
function executionMetadata(state, authorizationId, previous = {}) {
|
|
1733
2697
|
if (state.execution_mode === undefined)
|
|
1734
2698
|
return previous;
|
|
@@ -1742,6 +2706,15 @@ function executionMetadata(state, authorizationId, previous = {}) {
|
|
|
1742
2706
|
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_changed');
|
|
1743
2707
|
return { executionMode: 'autopilot', grantId: state.grant_id };
|
|
1744
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
|
+
});
|
|
1745
2718
|
/**
|
|
1746
2719
|
* What authorize() creates a merchant-hosted authorization as: an Adyen cse
|
|
1747
2720
|
* payment that only a prepared checkout may make (so an unprepared request is
|
|
@@ -1772,6 +2745,51 @@ function assertMerchantHostedRequest(input, profile) {
|
|
|
1772
2745
|
if (verdict.verdict === 'pass')
|
|
1773
2746
|
throw new Error(`not a card request Agentcard reviewed for ${profile.merchant}.`);
|
|
1774
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
|
+
}
|
|
1775
2793
|
const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
1776
2794
|
const FORBIDDEN_STEPS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
1777
2795
|
/**
|