@agent-cards/sdk 0.21.0 → 0.23.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 +87 -2
- package/PREFLIGHT.md +32 -0
- package/README.md +1177 -23
- 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 +302 -56
- 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-facts.d.ts +96 -0
- package/dist/card-endpoint-facts.js +275 -0
- package/dist/card-endpoint-page.d.ts +54 -0
- package/dist/card-endpoint-page.js +69 -0
- package/dist/card-endpoint.d.ts +298 -0
- package/dist/card-endpoint.js +438 -0
- package/dist/card-fields.generated.d.ts +5 -3
- package/dist/card-fields.generated.js +284 -7
- package/dist/cdp.d.ts +5 -1
- package/dist/cdp.js +1155 -141
- package/dist/client.d.ts +456 -20
- package/dist/client.js +1072 -104
- 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 +30 -0
- package/dist/cybersource-flex.generated.d.ts +27 -1
- package/dist/cybersource-flex.generated.js +42 -2
- package/dist/cybersource-flex.js +46 -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/ekashu.d.ts +35 -0
- package/dist/ekashu.js +55 -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/hosted-form.d.ts +3 -2
- package/dist/hosted-form.js +3 -2
- package/dist/index.d.ts +17 -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 +60 -6
- package/dist/lifecycle.js +122 -8
- package/dist/merchant-card-endpoints.generated.d.ts +122 -0
- package/dist/merchant-card-endpoints.generated.js +4261 -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 +81 -2
- package/dist/pre-claim.js +1023 -5
- package/dist/preflight-catalog.json +2267 -288
- package/dist/preflight-schemas.json +183 -6
- package/dist/preflight.generated.d.ts +1 -1
- package/dist/preflight.generated.js +243 -4
- package/dist/preparation.d.ts +12 -3
- package/dist/preparation.js +63 -12
- package/dist/prepared-processor.d.ts +84 -5
- package/dist/prepared-processor.js +147 -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 +100 -8
- 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 +764 -9
- 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 +1413 -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,11 +1,19 @@
|
|
|
1
|
-
import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, LOOPBACK_URL_PATTERNS, findRecognizer, recognizerRequiresPreparation, } from './registry.js';
|
|
1
|
+
import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, LOOPBACK_URL_PATTERNS, findRecognizer, isRecognizerRequest, recognizerRequestMethod, recognizerRequiresPreparation, urlsOnProcessorHosts, } from './registry.js';
|
|
2
2
|
import { armServedProfiles, classifyProfileRequest, declareSandboxMerchant, merchantProfileEnvironment, merchantProfileFor, merchantProfileUrlPatterns, } from './merchant-hosted.js';
|
|
3
3
|
import { merchantAmountExchanges, merchantAmountFromResponses, merchantProfilePricedBySource, merchantTotalWire, MerchantTotalWait, } from './merchant-total.js';
|
|
4
|
+
import { ACI_CAPABILITY, ACI_PSP, aciCardLegAnswer, redactAciCheckoutId } from './aci.js';
|
|
5
|
+
import { EPROTECT_CAPABILITY, EPROTECT_PSP, eprotectCheckoutRefusal, eprotectScriptAnswer, isEprotectRegistration } from './eprotect.js';
|
|
4
6
|
import { isMercadoTokenRequest, parseMercadoCheckoutContext, validateMercadoProcessorContext } from './mercado-checkout.generated.js';
|
|
5
7
|
import { matchesPreparation, preparationMode, validPreparationEnvironment } from './prepared-processor.js';
|
|
6
8
|
import { fiservKeyPinById, isFiservSubstitution, requiresFiservPreparation } from './fiserv.js';
|
|
9
|
+
import { armServedVerifoneProfiles, classifyVerifoneProfileRequest, isVerifoneProfileEntry, isVerifoneSubstitution, verifoneProfileFor, verifoneProfileUrlPatterns, } from './verifone.js';
|
|
7
10
|
import { hasOwnedShopMarker, parseOwnedShopOrder, parseOwnedShopReceipt } from './owned-shop.generated.js';
|
|
8
11
|
import { requestWithoutCard } from './card-fields.generated.js';
|
|
12
|
+
import { merchantSessionCapture } from './merchant-session-probe.js';
|
|
13
|
+
import { creditguardPageProcessUrl, creditguardPpDomainHost, creditguardPreparationEnvironment, creditguardProcessTxId, sanitizeCreditguardResponse, } from './creditguard.generated.js';
|
|
14
|
+
import { CONVERGE_CAPABILITY } from './elavon-converge.js';
|
|
15
|
+
import { SHIJI_STS_CAPABILITY, SHIJI_STS_PSP, isShijiStsServedCompanion, isShijiStsServedSubstitution } from './shiji-sts.js';
|
|
16
|
+
import { AMADEUS_CHECKOUT_CAPABILITY } from './amadeus-checkout.generated.js';
|
|
9
17
|
import { classifyStripeCheckoutRequest, encodeStripeCheckoutContext, hasStripeCheckoutMarker, parseStripeCheckoutContext, parseStripeCheckoutResponse, STRIPE_CHECKOUT_CONTEXT_HEADER, } from './stripe-checkout.generated.js';
|
|
10
18
|
import { VoiceModule } from './voice.js';
|
|
11
19
|
import { BuyModule } from './buy.js';
|
|
@@ -13,14 +21,24 @@ import { Languages } from './languages.js';
|
|
|
13
21
|
import { RequestRegistry } from './requests.js';
|
|
14
22
|
import { AgentcardError } from './voice-types.js';
|
|
15
23
|
import { GMO_CAPABILITY, GMO_PSP, gmoTokenAnswer } from './gmo.js';
|
|
16
|
-
import { isCybersourceFlexTokenShape } from './cybersource-flex.generated.js';
|
|
17
|
-
import { CYBERSOURCE_FLEX_CAPABILITY, CYBERSOURCE_FLEX_PSP, validCybersourceFlexSidecar } from './cybersource-flex.js';
|
|
24
|
+
import { admitCybersourceFlexPreparation, isCybersourceFlexTokenShape, sameCybersourceFlexPreparation } 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 { merchantCardEndpointFactsRefusal as sharedFactsRefusal, readMerchantCardEndpointFacts as sharedReadFacts, } from './merchant-card-endpoints.generated.js';
|
|
29
|
+
import { DATATRANS_CAPABILITY } from './datatrans.js';
|
|
30
|
+
import { PELECARD_CAPABILITY, PELECARD_PSP } from './pelecard.js';
|
|
31
|
+
import { PRIMER_CAPABILITY, PRIMER_PSP, primerTokenAnswer } from './primer.js';
|
|
32
|
+
import { PAYRAILS_CAPABILITY, payrailsSaveAnswer } from './payrails.js';
|
|
33
|
+
import { WSPAY_CAPABILITY, WSPAY_PSP } from './wspay.js';
|
|
18
34
|
/**
|
|
19
35
|
* The modes this SDK can finish. Asked for on syncRegistry (the API serves
|
|
20
36
|
* only recognizers in these modes, so a request this build cannot complete
|
|
21
|
-
* is never paused) and sent on every create.
|
|
37
|
+
* is never paused) and sent on every create. `device_encrypt`: the adapters
|
|
38
|
+
* capture the Payrails session key the page's init payload names (payrails.ts),
|
|
39
|
+
* and the cardholder's device encrypts and sends the save.
|
|
22
40
|
*/
|
|
23
|
-
export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
|
|
41
|
+
export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form', 'device_encrypt'];
|
|
24
42
|
/**
|
|
25
43
|
* Registry features this SDK honours, asked for on syncRegistry next to the
|
|
26
44
|
* modes. `card_fields`: it reads a recognizer's cardFields and claims only a
|
|
@@ -50,35 +68,243 @@ export const SUPPORTED_REGISTRY_FEATURES = ['card_fields', 'checkout_sessions'];
|
|
|
50
68
|
* serves Flex's recognizer the same way, only for a company Agentcard turned it on for.
|
|
51
69
|
* A gated recognizer is never in BUILTIN_REGISTRY, so a build reaches it only through a
|
|
52
70
|
* sync that declares its capability.
|
|
71
|
+
* `juspay_txns.v1`: Juspay's card flow (juspay-gate.ts), whose page tokenize
|
|
72
|
+
* this build answers locally and whose /txns it pauses, served and refused by
|
|
73
|
+
* the same rule. A gated recognizer is never in BUILTIN_REGISTRY, so a build
|
|
74
|
+
* reaches it only through a sync that declares its capability.
|
|
75
|
+
* `datatrans_secure_fields_tokenize`: Datatrans Secure Fields' TOKENIZE card
|
|
76
|
+
* post, which this build judges before it pauses, under the card-on-file
|
|
77
|
+
* policy served with the entry, and answers again for a repeat of the same
|
|
78
|
+
* form (datatrans.ts). A gated recognizer never sits in BUILTIN_REGISTRY, so
|
|
79
|
+
* it is paused only after a sync.
|
|
80
|
+
* `authorize_net_dpm_form`: the Authorize.Net Direct Post Method and SIM card
|
|
81
|
+
* form, one form navigation to transact.dll that the cardholder's device submits
|
|
82
|
+
* itself (hosted_form). This build finishes it with the hosted_form flow it
|
|
83
|
+
* already speaks, holds the bare endpoint the entry names (bareEndpoint in
|
|
84
|
+
* findRecognizer), and reads the form's keys without regard to case
|
|
85
|
+
* (card-fields.js). The API serves it, like Fiserv's, only to an org Agentcard
|
|
86
|
+
* enabled for that processor; every other org is served the ungated catalog.
|
|
87
|
+
* `pelecard_transaction`: Pelecard's card submission, which this build judges
|
|
88
|
+
* before it pauses (pelecard.ts) and pays only for the amount the agent passes.
|
|
89
|
+
* Declaring it is not enough: the API serves a gated recognizer only to an org
|
|
90
|
+
* Agentcard has enabled for that processor, so for every other org this build
|
|
91
|
+
* syncs the same catalog as one that declares nothing and leaves the processor's
|
|
92
|
+
* requests alone. A gated recognizer never sits in BUILTIN_REGISTRY, so it is
|
|
93
|
+
* paused only after a sync.
|
|
94
|
+
* `aci_split_card.v1`: ACI's COPYandPAY widget, whose card reaches ACI as
|
|
95
|
+
* several requests to one checkout. This build pauses the card-number request
|
|
96
|
+
* for a prepared checkout, holds the page's own CVC and expiry requests and
|
|
97
|
+
* answers them with that request's answer, and continues the page's /payment
|
|
98
|
+
* submission once, with the placeholder expiry removed (aci.ts). A gated
|
|
99
|
+
* recognizer is never in BUILTIN_REGISTRY, so a build reaches it only through a
|
|
100
|
+
* sync that declares its capability, and whether an org may pay through it
|
|
101
|
+
* stays the API's decision.
|
|
102
|
+
* `creditguard_pps_process`: CreditGuard's hosted payment page (Hyp PPS), whose
|
|
103
|
+
* card submit this build pays only under a prepared checkout that names the
|
|
104
|
+
* framed page (prepare({ psp: 'creditguard', environment, paymentPageUrl })). The
|
|
105
|
+
* API serves CreditGuard's recognizer on the same terms as Fiserv's.
|
|
106
|
+
* `primer_payment_instruments`: Primer's card tokenization, which this build
|
|
107
|
+
* judges before it pauses (primer.ts) and hands back to the page only as a
|
|
108
|
+
* single-use token. A merchant's client session can save the card after the
|
|
109
|
+
* payment with no sign in that request, so the API creates a Primer
|
|
110
|
+
* authorization only for a test-mode client and refuses a live one (400
|
|
111
|
+
* invalid_primer_request, live_keep_on_file) until the cardholder can consent
|
|
112
|
+
* to that save; this build then aborts the paused request with nothing sent. A
|
|
113
|
+
* gated recognizer never sits in BUILTIN_REGISTRY, so it is paused only after a
|
|
114
|
+
* sync.
|
|
115
|
+
* `payrails_device_encrypt`: Payrails' card-form save, which this build pauses
|
|
116
|
+
* only once it has bound the save to a captured session (payrails.ts). A gated
|
|
117
|
+
* recognizer never sits in BUILTIN_REGISTRY, so it is paused only after a sync.
|
|
118
|
+
* `converge_hpp_process`: Elavon Converge's Hosted Payment Page card request
|
|
119
|
+
* (elavon-converge.ts), paused in token mode and judged by the pre-claim gate.
|
|
120
|
+
* A gated recognizer never sits in BUILTIN_REGISTRY, so it is paused only after a
|
|
121
|
+
* sync, and the API also refuses a Converge payment for every company the
|
|
122
|
+
* processor is off for.
|
|
123
|
+
* `worldpay_eprotect_jsonp`: Worldpay eProtect, whose card rides in the query of
|
|
124
|
+
* a JSONP <script> GET that this build pauses, binds to a prepared checkout and
|
|
125
|
+
* answers with the registration in the script's own callback (eprotect.ts). A
|
|
126
|
+
* gated recognizer is never in BUILTIN_REGISTRY, so a build reaches either only
|
|
127
|
+
* through a sync that declares its capability, and the API serves it on that
|
|
128
|
+
* sync only for a company it has turned the processor on for.
|
|
129
|
+
* `wspay_confirm`: WSPay Components' card confirm, which this build judges
|
|
130
|
+
* before it pauses (wspay.ts) and pays only for the amount the agent passes.
|
|
131
|
+
* The API serves a gated recognizer only to a build that lists its capability
|
|
132
|
+
* and only for a company Agentcard turned that processor on for, so an older
|
|
133
|
+
* SDK never pauses a request it cannot finish and no SDK pauses one for a
|
|
134
|
+
* company the processor is off for; the API also refuses that processor's
|
|
135
|
+
* payment for every such company. A gated recognizer never sits in
|
|
136
|
+
* BUILTIN_REGISTRY, so it is paused only after a sync.
|
|
137
|
+
* `shiji_sts_pinned_key`: this build swaps Shiji STS's ciphertexts at the body
|
|
138
|
+
* root under the pinned key, binds the getCvvId companion to the getTokenId it
|
|
139
|
+
* continued, and refuses every other Shiji request in the browser (shiji-sts.ts).
|
|
140
|
+
* The API serves it on the same terms, only for a company Agentcard turned Shiji
|
|
141
|
+
* STS on for.
|
|
142
|
+
* `amadeus_checkout_actions.v1`: Amadeus Checkout (the Checkout Web SDK add and
|
|
143
|
+
* the Data Collection Page store), served on the same terms. This build lets every
|
|
144
|
+
* other action on those URLs through without claiming a preparation, so the page's
|
|
145
|
+
* own `load` never spends or blocks one, takes the card request only through a
|
|
146
|
+
* prepared checkout bound to the approved page's host, and fails a paused SDK add
|
|
147
|
+
* the device has not started 20 seconds after the pause (AMADEUS_SEND_WINDOW_MS).
|
|
148
|
+
* `worldline_mycheckout_form`: Worldline MyCheckout's card form (DHL Express),
|
|
149
|
+
* a top-level form POST this build holds like Tranzila's (mode hosted_form) and
|
|
150
|
+
* answers with the submission notice once the cardholder's device submitted the
|
|
151
|
+
* form itself. The page's cancel and its other payment methods on the same URL
|
|
152
|
+
* carry no card and continue untouched. The API serves the recognizer only to a
|
|
153
|
+
* build that lists it and only for a company Agentcard turned it on for.
|
|
154
|
+
* `verifone_card_capture`: a reviewed Verifone merchant's own card request (a
|
|
155
|
+
* verifone.js blob sent to the merchant's server), which this build pauses and pays
|
|
156
|
+
* only through a prepared checkout (verifone.ts); the API serves the Verifone
|
|
157
|
+
* profiles only to a build that lists it, for a company Agentcard turned Verifone on for.
|
|
158
|
+
* `ekashu_hosted_payment_page`: NMI's hosted payment page, whose card-free entry this
|
|
159
|
+
* build pauses for the cardholder's device and whose card page this build never lets
|
|
160
|
+
* the agent's browser post to (pre-claim.ts, ekashu.ts); served on the same terms.
|
|
53
161
|
*/
|
|
54
|
-
export const SUPPORTED_CAPABILITIES = ['fiserv_card_capture', GMO_CAPABILITY, CYBERSOURCE_FLEX_CAPABILITY];
|
|
162
|
+
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', 'ekashu_hosted_payment_page'];
|
|
55
163
|
/**
|
|
56
164
|
* Processors whose paused request names no amount at all, so the pair the
|
|
57
165
|
* agent passes is the amount the cardholder approves: authorize() refuses one
|
|
58
166
|
* without it (AmountRequiredError), and the API answers 400 amount_required
|
|
59
167
|
* for the same case. GMO's card token request carries none: the merchant's
|
|
60
|
-
* server charges the token later, for the amount it decides.
|
|
168
|
+
* server charges the token later, for the amount it decides. Pelecard's card
|
|
169
|
+
* submission carries none either: the merchant's server bound the total, the
|
|
170
|
+
* currency and the action type to the payment page when it created it, and no
|
|
171
|
+
* Pelecard answer states them. WSPay's confirm carries none either: WSPay fixed
|
|
172
|
+
* the amount when the merchant's server created the payment, so the agent's
|
|
173
|
+
* pair is also the figure the API compares with WSPay's answer when that
|
|
174
|
+
* answer names one. A WSPay 3-D Secure step answers with no amount, so on that
|
|
175
|
+
* path nothing Agentcard sees confirms what WSPay charges once the challenge
|
|
176
|
+
* completes.
|
|
61
177
|
*/
|
|
62
|
-
const AGENT_AMOUNT_PROCESSORS = [GMO_PSP];
|
|
178
|
+
const AGENT_AMOUNT_PROCESSORS = [GMO_PSP, PELECARD_PSP, WSPAY_PSP];
|
|
63
179
|
/** How AmountRequiredError names a processor. */
|
|
64
|
-
const PROCESSOR_NAMES = { [GMO_PSP]: 'GMO' };
|
|
180
|
+
const PROCESSOR_NAMES = { [GMO_PSP]: 'GMO', [PELECARD_PSP]: 'Pelecard', [WSPAY_PSP]: 'WSPay' };
|
|
181
|
+
/**
|
|
182
|
+
* Processors whose request and answer name no amount at all, so the total the
|
|
183
|
+
* payment page shows (`pageAmount`) is the one figure that ties the agent's pair
|
|
184
|
+
* to what the page charges: authorize() refuses one without it
|
|
185
|
+
* (PageAmountRequiredError), and the API answers 400 page_amount_required for the
|
|
186
|
+
* same case. Pelecard only.
|
|
187
|
+
*/
|
|
188
|
+
const PAGE_AMOUNT_PROCESSORS = [PELECARD_PSP];
|
|
65
189
|
/** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
|
|
66
190
|
export function validAmountInput(amount) {
|
|
67
191
|
if (typeof amount === 'number')
|
|
68
192
|
return Number.isSafeInteger(amount) && amount >= 0;
|
|
69
193
|
return typeof amount === 'string' && /^\d{1,15}\.\d{1,3}$/.test(amount.trim());
|
|
70
194
|
}
|
|
195
|
+
// The API reads an amount with Stripe's decimal places, which are not always ISO 4217's: MGA has
|
|
196
|
+
// none, IQD and LYD have two. Kept equal to payment-core's stripeMinorUnitDigits by a test.
|
|
197
|
+
const ZERO_DECIMAL_CURRENCIES = ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'];
|
|
198
|
+
const THREE_DECIMAL_CURRENCIES = ['BHD', 'JOD', 'KWD', 'OMR', 'TND'];
|
|
199
|
+
/** Digits after the point when the API reads an amount in `currency`. */
|
|
200
|
+
export function amountDigits(currency) {
|
|
201
|
+
const code = currency.trim().toUpperCase();
|
|
202
|
+
return ZERO_DECIMAL_CURRENCIES.includes(code) ? 0 : THREE_DECIMAL_CURRENCIES.includes(code) ? 3 : 2;
|
|
203
|
+
}
|
|
204
|
+
/** `minor` in the forms the API accepts, labelled with the amount it means; null when the API refuses it. */
|
|
205
|
+
function amountOption(minor, currency) {
|
|
206
|
+
const digits = amountDigits(currency);
|
|
207
|
+
// A three-decimal amount ends in 0 in the smallest unit (5120 for 5.120 KWD), as the API requires.
|
|
208
|
+
if (!Number.isSafeInteger(minor) || minor < 0 || (digits === 3 && minor % 10 !== 0))
|
|
209
|
+
return null;
|
|
210
|
+
const code = currency.trim().toUpperCase();
|
|
211
|
+
if (digits === 0)
|
|
212
|
+
return `For ${minor} ${code}, send the number ${minor}.`;
|
|
213
|
+
const padded = String(minor).padStart(digits + 1, '0');
|
|
214
|
+
const decimal = `${padded.slice(0, -digits)}.${padded.slice(-digits)}`;
|
|
215
|
+
return `For ${decimal} ${code}, send the number ${minor} or the string "${decimal}".`;
|
|
216
|
+
}
|
|
217
|
+
/** Every amount `figure` can mean in `currency`, each in the forms the API accepts. */
|
|
218
|
+
function amountOptions(figure, currency) {
|
|
219
|
+
const digits = amountDigits(currency);
|
|
220
|
+
const whole = /^\d{1,15}$/.test(figure);
|
|
221
|
+
const decimal = /^(\d{1,15})\.(\d{1,3})$/.exec(figure);
|
|
222
|
+
// Digits alone can mean the smallest unit or normal units, so both readings are offered, each
|
|
223
|
+
// labelled with the amount it means, and the agent picks the one it intended.
|
|
224
|
+
const readings = whole ? (digits === 0 ? [Number(figure)] : [Number(figure), Number(figure) * 10 ** digits])
|
|
225
|
+
// More digits after the point than the currency has (23.065 dollars) is no amount at all.
|
|
226
|
+
: decimal && (decimal[2].length <= digits || Number(decimal[2].slice(digits)) === 0)
|
|
227
|
+
? [Number(decimal[1] + decimal[2].padEnd(digits, '0').slice(0, digits))] : [];
|
|
228
|
+
return readings.map(minor => amountOption(minor, currency)).filter((option) => option !== null);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Why an amount is refused, worded for the agent that sent it: the rule, the value it passed, and
|
|
232
|
+
* each amount that value can mean, in the forms the API accepts. An agent told only the rule sends
|
|
233
|
+
* the same value again, and a tool harness that serializes every argument as text turns 2667 into
|
|
234
|
+
* "2667" on each try. A value that does not read as a price (long, or more digits than any amount)
|
|
235
|
+
* is described and never echoed, so a card number typed into the wrong field cannot reach a log
|
|
236
|
+
* line. A value below zero is named as such and never offered back as a positive amount.
|
|
237
|
+
*/
|
|
238
|
+
export function amountRefusal(amount, currency) {
|
|
239
|
+
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").';
|
|
240
|
+
// Ten digits is $99,999,999.99 in cents, and no more of a card number than its first six and last four.
|
|
241
|
+
const readable = typeof amount === 'string' ? amount.length <= 24 && (amount.match(/\d/g)?.length ?? 0) <= 10
|
|
242
|
+
: typeof amount === 'number' && Number.isFinite(amount) && Math.abs(amount) < 1e10;
|
|
243
|
+
if (!readable) {
|
|
244
|
+
const kind = typeof amount === 'string' ? 'a string' : typeof amount === 'number' ? 'a number' : amount === null ? 'null' : `a value of type ${typeof amount}`;
|
|
245
|
+
return `${rule} You passed ${kind}${typeof amount === 'string' || typeof amount === 'number' ? ' that does not read as an amount' : ''}.`;
|
|
246
|
+
}
|
|
247
|
+
const got = typeof amount === 'string' ? `the string ${JSON.stringify(amount)}` : `the number ${amount}`;
|
|
248
|
+
const code = typeof currency === 'string' && /^[a-z]{3}$/i.test(currency.trim()) ? currency.trim() : null;
|
|
249
|
+
// The figure inside a display string: "$23.06", "USD 23.06" and "23.06 usd" read as 23.06.
|
|
250
|
+
let figure = String(amount).trim().replace(/^[$€£¥₹]\s*/, '');
|
|
251
|
+
if (code)
|
|
252
|
+
figure = figure.replace(new RegExp(`^${code}\\s*|\\s*${code}$`, 'gi'), '');
|
|
253
|
+
if (figure.startsWith('-'))
|
|
254
|
+
return `${rule} You passed ${got}, which is below zero.`;
|
|
255
|
+
const options = code ? amountOptions(figure, code) : [];
|
|
256
|
+
return `${rule} You passed ${got}.${options.length ? ` ${options.join(' ')}` : ''}`;
|
|
257
|
+
}
|
|
71
258
|
const AMOUNT_AUTHORITIES = ['processor', 'agent', 'page', 'none'];
|
|
72
259
|
export class CheckoutPreparationError extends Error {
|
|
73
260
|
preparationId;
|
|
74
261
|
reason;
|
|
75
|
-
|
|
76
|
-
|
|
262
|
+
/**
|
|
263
|
+
* The API's own reason code under `reason`, when it gave one: a CreditGuard
|
|
264
|
+
* transaction refused as `creditguard_transaction_refused` names what the
|
|
265
|
+
* Vault cannot finish here (`three_d_secure`, `otp`, `installments`, ...).
|
|
266
|
+
*/
|
|
267
|
+
detail;
|
|
268
|
+
/** `explanation` follows the reason in the message: what the caller passed and what to send instead. */
|
|
269
|
+
constructor(preparationId, reason, detail, explanation) {
|
|
270
|
+
super(`Checkout preparation unavailable: ${reason}${detail ? ` (${detail})` : ''}${explanation ? `. ${explanation}` : ''}`);
|
|
77
271
|
this.preparationId = preparationId;
|
|
78
272
|
this.reason = reason;
|
|
79
273
|
this.name = 'CheckoutPreparationError';
|
|
274
|
+
if (detail)
|
|
275
|
+
this.detail = detail;
|
|
80
276
|
}
|
|
81
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Why a preparation's payment page is refused before any request, or null. CreditGuard needs the page
|
|
280
|
+
* the checkout framed, on a host of the preparation's environment (payment-core's own reading of it,
|
|
281
|
+
* the one the API applies); no other processor takes one.
|
|
282
|
+
*/
|
|
283
|
+
function paymentPageRefusal(input) {
|
|
284
|
+
if (input.psp !== 'creditguard')
|
|
285
|
+
return input.paymentPageUrl === undefined ? null : 'payment_page_unsupported';
|
|
286
|
+
if (input.paymentPageUrl === undefined)
|
|
287
|
+
return 'payment_page_required';
|
|
288
|
+
const processUrl = creditguardPageProcessUrl(input.paymentPageUrl);
|
|
289
|
+
if (processUrl === null)
|
|
290
|
+
return 'unsupported_payment_page';
|
|
291
|
+
return creditguardPreparationEnvironment(new URL(processUrl).hostname) === input.environment ? null : 'payment_page_environment_mismatch';
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The API's refusals of a CreditGuard preparation, answered before any row or prompt: each names
|
|
295
|
+
* something the caller can act on (a page the Vault cannot finish, an amount CreditGuard does not
|
|
296
|
+
* hold, a company not enabled), so it is reported as its own reason rather than as an unconfirmed
|
|
297
|
+
* preparation. A transaction refusal carries CreditGuard's reason as `detail`.
|
|
298
|
+
*/
|
|
299
|
+
const CREDITGUARD_PREPARATION_REFUSALS = ['payment_page_required', 'unsupported_payment_page',
|
|
300
|
+
'sandbox_host_on_live_row', 'live_host_on_sandbox_row', 'creditguard_transaction_refused', 'creditguard_transaction_unavailable',
|
|
301
|
+
'amount_mismatch', 'processor_unavailable'];
|
|
302
|
+
function creditguardPreparationRefusal(error) {
|
|
303
|
+
if (!(error instanceof CheckoutApiError) || error.code === null || !CREDITGUARD_PREPARATION_REFUSALS.includes(error.code))
|
|
304
|
+
return null;
|
|
305
|
+
const reason = error.details.reason;
|
|
306
|
+
return new CheckoutPreparationError(null, error.code, typeof reason === 'string' && /^[a-z0-9_]{1,64}$/.test(reason) ? reason : undefined);
|
|
307
|
+
}
|
|
82
308
|
export class CardEncryptedError extends Error {
|
|
83
309
|
psp;
|
|
84
310
|
constructor(psp) {
|
|
@@ -164,10 +390,15 @@ export class PreparationRequiredError extends Error {
|
|
|
164
390
|
/**
|
|
165
391
|
* The paused request names no amount and none was passed. A GMO card token
|
|
166
392
|
* request carries no amount (the merchant's server charges the token later),
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
393
|
+
* and neither does a Pelecard card submission (the merchant's server bound it
|
|
394
|
+
* to the payment page when it created it) or a WSPay confirm (WSPay fixed it
|
|
395
|
+
* when the merchant's server created the payment), so the `amount` and
|
|
396
|
+
* `currency` you pass are what the cardholder approves, and for WSPay the
|
|
397
|
+
* figure the API compares with WSPay's answer when that answer names one (a
|
|
398
|
+
* 3-D Secure step names none). Thrown locally before any create or
|
|
399
|
+
* prompt, and terminal: the same request without an amount fails the same
|
|
400
|
+
* way. The API answers 400 amount_required for the same case. A page total
|
|
401
|
+
* (`pageAmount`) does not count.
|
|
171
402
|
*/
|
|
172
403
|
export class AmountRequiredError extends Error {
|
|
173
404
|
psp;
|
|
@@ -179,6 +410,25 @@ export class AmountRequiredError extends Error {
|
|
|
179
410
|
this.name = 'AmountRequiredError';
|
|
180
411
|
}
|
|
181
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* The paused request names no amount, and the total its payment page shows
|
|
415
|
+
* (`pageAmount`) was not passed. Pelecard's merchant bound the total to the
|
|
416
|
+
* payment page when it created it, and no Pelecard request or answer states
|
|
417
|
+
* it, so the page's total is the one figure that ties the `amount` the
|
|
418
|
+
* cardholder approves to what the page charges. Thrown locally before any
|
|
419
|
+
* create or prompt; the API answers 400 page_amount_required for the same
|
|
420
|
+
* case. Not terminal: the page's next submission reads the total again.
|
|
421
|
+
*/
|
|
422
|
+
export class PageAmountRequiredError extends Error {
|
|
423
|
+
psp;
|
|
424
|
+
code = 'page_amount_required';
|
|
425
|
+
constructor(psp) {
|
|
426
|
+
const processor = psp === PELECARD_PSP ? 'Pelecard' : psp;
|
|
427
|
+
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.`);
|
|
428
|
+
this.psp = psp;
|
|
429
|
+
this.name = 'PageAmountRequiredError';
|
|
430
|
+
}
|
|
431
|
+
}
|
|
182
432
|
export class ApprovalTimeoutError extends Error {
|
|
183
433
|
constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
|
|
184
434
|
}
|
|
@@ -190,6 +440,15 @@ export const RUNTIME_CANCEL_REASONS = ['merchant_request_aborted', 'merchant_nev
|
|
|
190
440
|
export class PaymentOutcomeUnknownError extends Error {
|
|
191
441
|
authorizationId;
|
|
192
442
|
reason;
|
|
443
|
+
/**
|
|
444
|
+
* False when the API confirmed it retired this authorization before any processor
|
|
445
|
+
* request started: authorize() cancels an authorization it could not finish (a card
|
|
446
|
+
* endpoint, a prepared or a native checkout, or a request the page abandoned), and the
|
|
447
|
+
* cancel came back with `processor_request_started: false`, so nothing reached the
|
|
448
|
+
* processor or the merchant. Null when that is not known. The controller still reads
|
|
449
|
+
* `outcome_unknown`; a card form navigation's page reads this to say nothing was sent.
|
|
450
|
+
*/
|
|
451
|
+
processorRequestStarted = null;
|
|
193
452
|
constructor(authorizationId, reason) {
|
|
194
453
|
super(`payment outcome unknown${authorizationId ? ` for ${authorizationId}` : ''}: ${reason}; check the merchant order before retrying`);
|
|
195
454
|
this.authorizationId = authorizationId;
|
|
@@ -522,10 +781,13 @@ export class CheckoutApiError extends Error {
|
|
|
522
781
|
* already has, or already answered, the prompt for this submission, and
|
|
523
782
|
* once that prior authorization is declined or expired the same form is a
|
|
524
783
|
* new question. The adapters quiet the page's re-post the way they quiet a
|
|
525
|
-
* decline instead of latching the attachment.
|
|
784
|
+
* decline instead of latching the attachment. NOT a stale form fingerprint
|
|
785
|
+
* either (isStaleFingerprint): the bytes of that form are refused for good,
|
|
786
|
+
* but the merchant's page renders a fresh fingerprint on reload, so the
|
|
787
|
+
* page's next form is a new question and is quieted the same way.
|
|
526
788
|
*/
|
|
527
789
|
get permanent() {
|
|
528
|
-
if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission')
|
|
790
|
+
if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission' || this.isStaleFingerprint)
|
|
529
791
|
return false;
|
|
530
792
|
// Nor a refusal by the company preset (a 403 carrying `preset` in the
|
|
531
793
|
// envelope): the company's rules refused THIS purchase, at this merchant,
|
|
@@ -535,6 +797,16 @@ export class CheckoutApiError extends Error {
|
|
|
535
797
|
return false;
|
|
536
798
|
return this.status >= 400 && this.status < 500 && this.status !== 429;
|
|
537
799
|
}
|
|
800
|
+
/**
|
|
801
|
+
* True for a 400 `hosted_form_field_refused` whose reason is
|
|
802
|
+
* `stale_fingerprint`: an Authorize.Net DPM or SIM form signed more than 40
|
|
803
|
+
* minutes before the create, or ahead of the API's clock. Authorize.Net
|
|
804
|
+
* would refuse that fingerprint itself, and the page renders a fresh one
|
|
805
|
+
* when it reloads, so the refusal holds for this form only.
|
|
806
|
+
*/
|
|
807
|
+
get isStaleFingerprint() {
|
|
808
|
+
return this.status === 400 && this.code === 'hosted_form_field_refused' && this.details.reason === 'stale_fingerprint';
|
|
809
|
+
}
|
|
538
810
|
}
|
|
539
811
|
/** The client-credentials exchange was refused: the RFC 6749 error, with the status the token endpoint answered. */
|
|
540
812
|
export class CheckoutAuthError extends Error {
|
|
@@ -582,18 +854,46 @@ function parseErrorEnvelope(bodyText) {
|
|
|
582
854
|
}
|
|
583
855
|
return { code: null, details: {} };
|
|
584
856
|
}
|
|
857
|
+
const CREDITGUARD_HOST = /(?:^|\.)creditguard\.co\.il\.?$/i;
|
|
858
|
+
function maskCreditguardTxId(path) {
|
|
859
|
+
return path.replace(/(\/txn\/)[^/]+/gi, '$1{txId}')
|
|
860
|
+
.replace(/[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi, '{txId}');
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Hosts that put a per-payment secret in the PATH: Monri Components and
|
|
864
|
+
* WSPay's build of it name the payment's client_secret in
|
|
865
|
+
* /v2/payment/{client_secret}/confirm (and /show, /status), and with the page's
|
|
866
|
+
* public token that secret confirms the payment and reads its status.
|
|
867
|
+
*/
|
|
868
|
+
const PATH_SECRET_HOSTS = ['proxy.wspay.biz', 'proxy-test.wspay.biz', 'ipg.monri.com', 'ipgtest.monri.com'];
|
|
869
|
+
/** The segment after a `payment` segment, in any case (the WSPay proxy is IIS) or after an escaped slash. */
|
|
870
|
+
const PATH_SECRET = /(\/payment(?:\/|%2f))[^/]*/gi;
|
|
585
871
|
/**
|
|
586
872
|
* A URL as it may appear in an error message: origin + path only. A paused
|
|
587
873
|
* request's URL can carry a client secret in its query string, and error
|
|
588
874
|
* messages travel further than anyone intends (onEvent, logs, crash reports).
|
|
875
|
+
* CreditGuard puts one in the path: on its hosts the transaction id after
|
|
876
|
+
* /txn/ is that transaction's only credential (payment-core's creditguard.js),
|
|
877
|
+
* so it reads {txId}, as does any other id-shaped segment there.
|
|
878
|
+
* On the Monri and WSPay hosts the secret is a path segment, so it is replaced
|
|
879
|
+
* with `{client_secret}`: https://proxy.wspay.biz/v2/payment/{client_secret}/confirm.
|
|
880
|
+
* A string that is not a URL keeps the part before any `?` or `#`, with the
|
|
881
|
+
* same segment masked.
|
|
589
882
|
*/
|
|
590
883
|
export function redactUrl(raw) {
|
|
884
|
+
// An ACI checkout id is that checkout's only capability; it never rides on an event.
|
|
885
|
+
const aci = redactAciCheckoutId(raw);
|
|
886
|
+
if (aci !== null)
|
|
887
|
+
return aci;
|
|
591
888
|
try {
|
|
592
889
|
const u = new URL(raw);
|
|
593
|
-
|
|
890
|
+
// A trailing dot names the same host, so it cannot let the secret through.
|
|
891
|
+
const path = PATH_SECRET_HOSTS.includes(u.hostname.replace(/\.$/, '')) ? u.pathname.replace(PATH_SECRET, '$1{client_secret}') : u.pathname;
|
|
892
|
+
return `${u.origin}${CREDITGUARD_HOST.test(u.hostname) ? maskCreditguardTxId(path) : path}`;
|
|
594
893
|
}
|
|
595
894
|
catch {
|
|
596
|
-
|
|
895
|
+
const bare = raw.split(/[?#]/)[0].replace(PATH_SECRET, '$1{client_secret}');
|
|
896
|
+
return /creditguard\.co\.il/i.test(bare) ? maskCreditguardTxId(bare) : bare;
|
|
597
897
|
}
|
|
598
898
|
}
|
|
599
899
|
/**
|
|
@@ -625,6 +925,33 @@ const FISERV_PREPARATION_REFUSALS = [
|
|
|
625
925
|
'processor_unavailable', 'unsupported_checkout', 'merchant_profile_required', 'unknown_merchant_profile',
|
|
626
926
|
'merchant_profile_key_changed', 'fiserv_key_refused', 'sandbox_host_on_live_row', 'live_host_on_sandbox_row',
|
|
627
927
|
];
|
|
928
|
+
/**
|
|
929
|
+
* The API's refusals of a Worldpay eProtect preparation, before any preparation exists, that
|
|
930
|
+
* prepareCheckout surfaces by name: a live checkout's merchant is not the name the reviewed pin for
|
|
931
|
+
* its paypageId carries (`merchant_profile_mismatch`, which this build also refuses itself when it
|
|
932
|
+
* holds that pin). Only for an eProtect checkout.
|
|
933
|
+
*/
|
|
934
|
+
const EPROTECT_PREPARATION_REFUSALS = ['merchant_profile_mismatch'];
|
|
935
|
+
/**
|
|
936
|
+
* Refusals a CyberSource Flex preparation that tokenizes before Pay answers (4xx) before any
|
|
937
|
+
* preparation exists, surfaced as the CheckoutPreparationError reason: the processor is off for
|
|
938
|
+
* this company (`processor_unavailable`), the environment is not the client's mode, or the capture
|
|
939
|
+
* context or fields read from the page cannot be verified or rebuilt. Only for such a preparation;
|
|
940
|
+
* a Flex preparation without a capture context keeps `preparation_unconfirmed`, as before.
|
|
941
|
+
*/
|
|
942
|
+
const CYBERSOURCE_FLEX_PREPARATION_REFUSALS = [
|
|
943
|
+
'processor_unavailable', 'sandbox_host_on_live_row', 'live_host_on_sandbox_row', 'capture_context_invalid', 'microform_unsupported',
|
|
944
|
+
'cybersource_environment_mismatch',
|
|
945
|
+
];
|
|
946
|
+
/** Why payment-core refused a Flex preparation's capture context or fields, as a CheckoutPreparationError reason. */
|
|
947
|
+
function cybersourceFlexPreparationRefusal(error) {
|
|
948
|
+
const reason = error?.reason;
|
|
949
|
+
if (reason === 'microform_version' || reason === 'fields')
|
|
950
|
+
return 'microform_unsupported';
|
|
951
|
+
if (reason === 'environment_mismatch')
|
|
952
|
+
return 'cybersource_environment_mismatch';
|
|
953
|
+
return 'capture_context_invalid';
|
|
954
|
+
}
|
|
628
955
|
/** Default backoff for a 502 amount_unverifiable at create: two retries, then give up. */
|
|
629
956
|
const UNVERIFIABLE_RETRY_DELAYS_MS = [500, 1500];
|
|
630
957
|
const PREPARATION_READ_RETRY_DELAYS_MS = [250, 500];
|
|
@@ -638,6 +965,20 @@ function transientReadNetworkError(error) {
|
|
|
638
965
|
const code = error.cause?.code ?? error.code;
|
|
639
966
|
return typeof code === 'string' && TRANSIENT_READ_NETWORK_CODES.has(code);
|
|
640
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* Processors whose payment without a card can spend a token the processor
|
|
970
|
+
* minted from the approved card. Nuvei answers a card it is handed (Fields
|
|
971
|
+
* getToken, or either leg of createPayment) with a ccTempToken, and can save
|
|
972
|
+
* it for a userPaymentOptionId; a clientPayment.do carrying only one of those
|
|
973
|
+
* pays with the approved card, and nothing binds it to the approved amount.
|
|
974
|
+
* So once the attachment has handed Nuvei the card, such a payment is refused
|
|
975
|
+
* (after the holds) instead of continuing. Before that, the token was issued
|
|
976
|
+
* elsewhere (Air France's server issues it, behind Amadeus) and it continues.
|
|
977
|
+
* Stripe is not listed: a card-free PaymentIntent confirm is bound by the API
|
|
978
|
+
* (checkStripeContinuation), and the confirmations its passWithoutCard names
|
|
979
|
+
* continue by design (payment-core definitions.js).
|
|
980
|
+
*/
|
|
981
|
+
const CARD_FREE_AFTER_HANDOFF_REFUSED = new Set(['nuvei']);
|
|
641
982
|
export class VaultClient {
|
|
642
983
|
opts;
|
|
643
984
|
baseUrl;
|
|
@@ -653,6 +994,12 @@ export class VaultClient {
|
|
|
653
994
|
* in 'observe' until a sync reads that the API enabled it (armServedProfiles).
|
|
654
995
|
*/
|
|
655
996
|
merchantProfiles = armServedProfiles();
|
|
997
|
+
/**
|
|
998
|
+
* The reviewed Verifone profiles this client arms: only the ones the API served on the
|
|
999
|
+
* last sync, which it serves only to a company Agentcard turned Verifone on for
|
|
1000
|
+
* (armServedVerifoneProfiles). None before a sync.
|
|
1001
|
+
*/
|
|
1002
|
+
verifoneProfiles = [];
|
|
656
1003
|
/** This client's own sandbox declarations (sandboxMerchants), armed at construction, one per profile. */
|
|
657
1004
|
declaredProfiles;
|
|
658
1005
|
preparations = new WeakSet();
|
|
@@ -756,9 +1103,14 @@ export class VaultClient {
|
|
|
756
1103
|
mergedRegistry() {
|
|
757
1104
|
return this.extraRecognizers.length ? [...this.extraRecognizers, ...this.registry] : this.registry;
|
|
758
1105
|
}
|
|
759
|
-
/**
|
|
1106
|
+
/**
|
|
1107
|
+
* The recogniser that claims a card request, or null; what a paused request names as
|
|
1108
|
+
* `recognizer`. A POST to a recognised endpoint, or for a recognizer whose card request is a
|
|
1109
|
+
* GET (Worldpay eProtect) that exact GET, query included.
|
|
1110
|
+
*/
|
|
760
1111
|
recognizerOf(url, method = 'POST') {
|
|
761
|
-
|
|
1112
|
+
const rec = findRecognizer(url, this.mergedRegistry(), { sandbox: this.sandbox });
|
|
1113
|
+
return rec !== null && isRecognizerRequest(rec, url, method) ? rec : null;
|
|
762
1114
|
}
|
|
763
1115
|
/** Refresh recognizers from the API so new PSPs work without a redeploy. */
|
|
764
1116
|
async syncRegistry() {
|
|
@@ -774,13 +1126,30 @@ export class VaultClient {
|
|
|
774
1126
|
// ?capabilities= is the dark-launch half of the same negotiation: a
|
|
775
1127
|
// processor gated behind a capability is served only when this build lists
|
|
776
1128
|
// it (SUPPORTED_CAPABILITIES: fiserv_card_capture, gmo_shared_pk,
|
|
777
|
-
// capture_context
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
//
|
|
1129
|
+
// capture_context, juspay_txns.v1, datatrans_secure_fields_tokenize,
|
|
1130
|
+
// authorize_net_dpm_form, pelecard_transaction, aci_split_card.v1,
|
|
1131
|
+
// creditguard_pps_process, primer_payment_instruments,
|
|
1132
|
+
// payrails_device_encrypt, converge_hpp_process, worldpay_eprotect_jsonp,
|
|
1133
|
+
// wspay_confirm, shiji_sts_pinned_key, amadeus_checkout_actions.v1,
|
|
1134
|
+
// worldline_mycheckout_form, verifone_card_capture) and
|
|
1135
|
+
// the caller's company is one Agentcard turned that processor on for, so this
|
|
1136
|
+
// build arms Fiserv's card capture, GMO's getToken, CyberSource Flex's card
|
|
1137
|
+
// fields, Juspay's card flow, Datatrans's Secure Fields card post, the
|
|
1138
|
+
// Authorize.Net DPM or SIM form, Pelecard's card submission, ACI's split card,
|
|
1139
|
+
// CreditGuard's hosted page submit, Primer's card tokenization, Payrails' card
|
|
1140
|
+
// save, Converge's payment page, Worldpay eProtect's registration, WSPay's
|
|
1141
|
+
// card confirm, Shiji STS's card leg, Amadeus Checkout's card add and
|
|
1142
|
+
// Worldline MyCheckout's card form only for
|
|
1143
|
+
// such a company, and an older build never receives a recognizer it cannot
|
|
1144
|
+
// judge. Every other org's registry never recognizes a Flex, Juspay, Datatrans,
|
|
1145
|
+
// Pelecard, ACI, CreditGuard, Primer, Payrails, Converge, eProtect, WSPay, Shiji STS, Amadeus or
|
|
1146
|
+
// MyCheckout host or the
|
|
1147
|
+
// transact.dll form, so its Microform, Juspay, Datatrans, Authorize.Net DPM or
|
|
1148
|
+
// SIM, Pelecard, ACI, CreditGuard, Primer, Payrails, Converge, eProtect, WSPay,
|
|
1149
|
+
// Shiji STS, Amadeus and MyCheckout requests go through untouched; the create checks the org again (403
|
|
1150
|
+
// processor_unavailable), and the SDK refuses ACI's card-number request,
|
|
1151
|
+
// CreditGuard's submit, an eProtect registration and an Amadeus card add locally
|
|
1152
|
+
// without a prepared checkout.
|
|
784
1153
|
const capabilities = SUPPORTED_CAPABILITIES.length ? `&capabilities=${SUPPORTED_CAPABILITIES.join(',')}` : '';
|
|
785
1154
|
// merchant_profiles=1 asks for the reviewed Adyen merchant profiles too, each
|
|
786
1155
|
// as a `{ merchant_profile }` entry after the recognizers; an API that
|
|
@@ -797,12 +1166,31 @@ export class VaultClient {
|
|
|
797
1166
|
if (!Array.isArray(raw))
|
|
798
1167
|
return;
|
|
799
1168
|
const isProfile = (e) => !!e && typeof e === 'object' && 'merchant_profile' in e;
|
|
800
|
-
this.registry = raw.filter((e) => !isProfile(e)).map((e) => ({
|
|
1169
|
+
this.registry = raw.filter((e) => !isProfile(e) && !isVerifoneProfileEntry(e)).map((e) => ({
|
|
801
1170
|
...e,
|
|
802
1171
|
match: new RegExp(e.match, 'i'),
|
|
803
1172
|
passthroughHeaders: e.passthroughHeaders.map((h) => new RegExp(h, 'i')),
|
|
804
1173
|
}));
|
|
805
1174
|
this.merchantProfiles = armServedProfiles(raw.filter(isProfile));
|
|
1175
|
+
this.verifoneProfiles = armServedVerifoneProfiles(raw.filter(isVerifoneProfileEntry));
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* The armed Verifone profile whose endpoint origin this URL is on, with its status on
|
|
1179
|
+
* this client; null for any other URL, and for every URL before a sync served one. The
|
|
1180
|
+
* adapters pause every request to that origin and judge it with
|
|
1181
|
+
* classifyVerifoneProfileRequest. A raw runtime that pauses one must never continue a
|
|
1182
|
+
* body carrying a verifone.js blob there unless authorize() paid it.
|
|
1183
|
+
*/
|
|
1184
|
+
verifoneProfileOf(url) {
|
|
1185
|
+
return verifoneProfileFor(this.verifoneProfiles, url);
|
|
1186
|
+
}
|
|
1187
|
+
/** The armed Verifone profile with this id, or null. */
|
|
1188
|
+
verifoneProfile(id) {
|
|
1189
|
+
return this.verifoneProfiles.find((profile) => profile.id === id) ?? null;
|
|
1190
|
+
}
|
|
1191
|
+
/** Fetch.enable globs for every armed Verifone profile's endpoint origins; none before a sync served one. */
|
|
1192
|
+
verifoneProfileUrlPatterns() {
|
|
1193
|
+
return verifoneProfileUrlPatterns(this.verifoneProfiles);
|
|
806
1194
|
}
|
|
807
1195
|
/**
|
|
808
1196
|
* The reviewed Adyen merchant profile whose card endpoint (or a sibling of it,
|
|
@@ -830,28 +1218,73 @@ export class VaultClient {
|
|
|
830
1218
|
merchantProfileUrlPatterns() {
|
|
831
1219
|
return merchantProfileUrlPatterns([...this.declaredProfiles, ...this.merchantProfiles]);
|
|
832
1220
|
}
|
|
833
|
-
/**
|
|
1221
|
+
/**
|
|
1222
|
+
* The card-on-file policy the API served with the recognizer for this
|
|
1223
|
+
* request (Recognizer.cardOnFile), or null when no recognizer matches or
|
|
1224
|
+
* its entry names none. The Datatrans gate refuses a post that asks to keep
|
|
1225
|
+
* the card on file only while this is `refuse` or a value it does not know.
|
|
1226
|
+
*/
|
|
1227
|
+
cardOnFilePolicy(url, method = 'POST') {
|
|
1228
|
+
if (method.toUpperCase() !== 'POST')
|
|
1229
|
+
return null;
|
|
1230
|
+
const rec = findRecognizer(url, this.registry);
|
|
1231
|
+
return rec && typeof rec.cardOnFile === 'string' ? rec.cardOnFile : null;
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* True when this request is a card tokenization we can take over: a POST to
|
|
1235
|
+
* a recognized endpoint, or for a recognizer whose card request is a GET
|
|
1236
|
+
* (Worldpay eProtect) that exact GET, query included.
|
|
1237
|
+
*/
|
|
834
1238
|
isCardRequest(url, method = 'POST') {
|
|
835
1239
|
return this.recognizerOf(url, method) !== null;
|
|
836
1240
|
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Whether the current registry holds this processor's recognizer. A
|
|
1243
|
+
* processor that ships dark is served only to a build that declares its
|
|
1244
|
+
* capability (syncRegistry does), so before a sync, or against an API that
|
|
1245
|
+
* withheld it, the adapters leave that processor's traffic exactly as they
|
|
1246
|
+
* found it. prepare() asks this for ACI, whose card-number request names a
|
|
1247
|
+
* checkout id the merchant mints only at Pay time, and for a processor whose
|
|
1248
|
+
* card request is a GET (Worldpay eProtect), since that request is recognized
|
|
1249
|
+
* by a query the page has not written yet.
|
|
1250
|
+
*/
|
|
1251
|
+
recognizesProcessor(psp) {
|
|
1252
|
+
return this.registry.some((rec) => rec.psp === psp);
|
|
1253
|
+
}
|
|
837
1254
|
/**
|
|
838
1255
|
* What happens to a card request (by URL) whose body carries no card: null
|
|
839
1256
|
* when it carries one, so it is a card request as usual; 'continue' when the
|
|
840
1257
|
* recognizer marks the endpoint as one where such requests are routine and
|
|
841
1258
|
* never ours; 'refuse' otherwise, such as a Stripe confirmation paying with
|
|
842
|
-
* a method this checkout never approved. The adapters
|
|
843
|
-
*
|
|
844
|
-
*
|
|
1259
|
+
* a method this checkout never approved. The adapters continue a 'continue'
|
|
1260
|
+
* in every state and judge a 'refuse' after their holds. A body that could
|
|
1261
|
+
* not be read is not judged here.
|
|
1262
|
+
*
|
|
1263
|
+
* `handedTo` lists the requests the attachment has already handed the card
|
|
1264
|
+
* to (CheckoutLifecycle.cardHandoffs). Once one of them went to the same
|
|
1265
|
+
* processor, a processor in CARD_FREE_AFTER_HANDOFF_REFUSED answers 'refuse'
|
|
1266
|
+
* where it would have answered 'continue'.
|
|
1267
|
+
*
|
|
1268
|
+
* A sandbox client's loopback fixture is judged on the processor's own
|
|
1269
|
+
* hosts, as it is recognised (urlsOnProcessorHosts), so a card-free request
|
|
1270
|
+
* the catalog lets through there continues on the fixture too.
|
|
845
1271
|
*/
|
|
846
|
-
withoutCard(url, body) {
|
|
1272
|
+
withoutCard(url, body, handedTo = []) {
|
|
847
1273
|
if (body == null)
|
|
848
1274
|
return null;
|
|
849
|
-
const
|
|
850
|
-
|
|
1275
|
+
const registry = this.mergedRegistry();
|
|
1276
|
+
const rec = findRecognizer(url, registry, { sandbox: this.sandbox });
|
|
1277
|
+
if (!rec)
|
|
1278
|
+
return null;
|
|
1279
|
+
const answers = urlsOnProcessorHosts(url, rec, { sandbox: this.sandbox }).map((judged) => requestWithoutCard(rec, judged, body));
|
|
1280
|
+
const answer = answers.includes(null) ? null : answers.includes('continue') ? 'continue' : 'refuse';
|
|
1281
|
+
if (answer !== 'continue' || !CARD_FREE_AFTER_HANDOFF_REFUSED.has(rec.psp))
|
|
1282
|
+
return answer;
|
|
1283
|
+
return handedTo.some((handed) => findRecognizer(handed, registry, { sandbox: this.sandbox })?.psp === rec.psp) ? 'refuse' : 'continue';
|
|
851
1284
|
}
|
|
852
1285
|
/**
|
|
853
|
-
* How the card would reach the processor on this request (`token`, `cse
|
|
854
|
-
* or `
|
|
1286
|
+
* How the card would reach the processor on this request (`token`, `cse`,
|
|
1287
|
+
* `hosted_form` or `device_encrypt`; absent on the entry means `token`), or null when the
|
|
855
1288
|
* registry does not recognize it. The adapters read it to decide whether an
|
|
856
1289
|
* approval outlives the page's own request: a hosted form is a navigation
|
|
857
1290
|
* and cannot.
|
|
@@ -915,6 +1348,9 @@ export class VaultClient {
|
|
|
915
1348
|
const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
|
|
916
1349
|
if (!validPreparationEnvironment(input.psp, input.environment))
|
|
917
1350
|
throw fail('unsupported_processor');
|
|
1351
|
+
const pageRefusal = paymentPageRefusal(input);
|
|
1352
|
+
if (pageRefusal)
|
|
1353
|
+
throw fail(pageRefusal);
|
|
918
1354
|
// A merchant-hosted preparation pays one reviewed profile's card request: the
|
|
919
1355
|
// profile must be armed here, enabled, and paid in its own environment.
|
|
920
1356
|
// A Fiserv checkout names the key Agentcard pinned for its merchant, in that key's own
|
|
@@ -922,7 +1358,21 @@ export class VaultClient {
|
|
|
922
1358
|
// this preparation pays must carry an envelope under that key (matchesPreparation).
|
|
923
1359
|
let profile = null;
|
|
924
1360
|
let fiservPin = null;
|
|
925
|
-
|
|
1361
|
+
// A Verifone checkout names the reviewed profile of the merchant, armed and enabled here, in
|
|
1362
|
+
// that profile's own environment; the API then names that profile's merchant on the approval.
|
|
1363
|
+
let verifone = null;
|
|
1364
|
+
if (input.psp === 'verifone') {
|
|
1365
|
+
if (input.merchantProfile === undefined)
|
|
1366
|
+
throw fail('merchant_profile_required');
|
|
1367
|
+
verifone = typeof input.merchantProfile === 'string' ? this.verifoneProfile(input.merchantProfile) : null;
|
|
1368
|
+
if (!verifone)
|
|
1369
|
+
throw fail('processor_interception_unavailable');
|
|
1370
|
+
if (verifone.status !== 'enabled')
|
|
1371
|
+
throw fail('unsupported_checkout');
|
|
1372
|
+
if (verifone.environment !== input.environment)
|
|
1373
|
+
throw fail('unsupported_processor');
|
|
1374
|
+
}
|
|
1375
|
+
else if (input.psp === 'fiserv') {
|
|
926
1376
|
if (input.merchantProfile === undefined)
|
|
927
1377
|
throw fail('merchant_profile_required');
|
|
928
1378
|
fiservPin = fiservKeyPinById(input.merchantProfile);
|
|
@@ -944,11 +1394,44 @@ export class VaultClient {
|
|
|
944
1394
|
throw fail('unsupported_processor');
|
|
945
1395
|
}
|
|
946
1396
|
const declaration = profile?.sandboxDeclaration;
|
|
947
|
-
|
|
948
|
-
|
|
1397
|
+
// Worldpay eProtect: the preparation names the Worldpay merchant its registration may name
|
|
1398
|
+
// (paypageId) and the paying card, whose expiry the agent types at the merchant because
|
|
1399
|
+
// Worldpay never receives one. The API refuses either missing, and a paypageId on any other
|
|
1400
|
+
// processor; refusing here asks nobody to approve a preparation that could never bind.
|
|
1401
|
+
const eprotect = input.psp === EPROTECT_PSP;
|
|
1402
|
+
if (eprotect ? typeof input.paypageId !== 'string' || !/^[A-Za-z0-9]{1,64}$/.test(input.paypageId) : input.paypageId !== undefined)
|
|
1403
|
+
throw fail('paypage_id_invalid');
|
|
1404
|
+
if (eprotect && !input.cardId)
|
|
1405
|
+
throw fail('card_required');
|
|
1406
|
+
// CyberSource Flex, tokenized before Pay: the loaded card field's capture context and fields,
|
|
1407
|
+
// verified here against the keys payment-core pins before anything is created (the API and the
|
|
1408
|
+
// cardholder's device verify them again).
|
|
1409
|
+
if (input.tokenizeBeforePay !== undefined && typeof input.tokenizeBeforePay !== 'boolean')
|
|
1410
|
+
throw fail('tokenize_before_pay_invalid');
|
|
1411
|
+
const flexContext = input.cybersourceFlex;
|
|
1412
|
+
if (input.tokenizeBeforePay === true && !flexContext)
|
|
1413
|
+
throw fail('microform_not_loaded');
|
|
1414
|
+
if (flexContext) {
|
|
1415
|
+
if (input.psp !== CYBERSOURCE_FLEX_PSP)
|
|
1416
|
+
throw fail('unsupported_processor');
|
|
1417
|
+
try {
|
|
1418
|
+
await admitCybersourceFlexPreparation({ capture_context: flexContext.captureContext,
|
|
1419
|
+
microform: { version: flexContext.microform.version, fields: [...flexContext.microform.fields] }, environment: input.environment }, { crypto: globalThis.crypto });
|
|
1420
|
+
}
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
throw fail(cybersourceFlexPreparationRefusal(error));
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if (!validAmountInput(input.amount) || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency)) {
|
|
1426
|
+
throw new CheckoutPreparationError(null, 'amount_required', undefined, input.amount != null && !validAmountInput(input.amount) ? amountRefusal(input.amount, input.currency) : undefined);
|
|
1427
|
+
}
|
|
949
1428
|
const origin = new URL(input.merchantOrigin);
|
|
950
1429
|
if (!(origin.protocol === 'https:' || (origin.protocol === 'http:' && origin.hostname === 'localhost')) || origin.origin !== input.merchantOrigin)
|
|
951
1430
|
throw fail('merchant_origin_invalid');
|
|
1431
|
+
// CreditGuard's submit names the page that frames it (ppDomain), and only an HTTPS page under a
|
|
1432
|
+
// dotted hostname can be bound to it, so the API would refuse any other merchant page.
|
|
1433
|
+
if (input.psp === 'creditguard' && (origin.protocol !== 'https:' || creditguardPpDomainHost(btoa(origin.hostname)) !== origin.hostname))
|
|
1434
|
+
throw fail('merchant_origin_invalid');
|
|
952
1435
|
if (!input.checkoutKey || !input.user || !input.merchant)
|
|
953
1436
|
throw fail('checkout_context_required');
|
|
954
1437
|
// The cardholder approves a payment to the merchant the key pin belongs to, so a Fiserv
|
|
@@ -956,6 +1439,15 @@ export class VaultClient {
|
|
|
956
1439
|
// authorization it pays then name one merchant, never a label the caller chose.
|
|
957
1440
|
if (fiservPin && input.merchant !== fiservPin.merchant)
|
|
958
1441
|
throw fail('merchant_profile_mismatch');
|
|
1442
|
+
// A live Worldpay eProtect checkout whose paypageId this build pins for the merchant origin names
|
|
1443
|
+
// that pin's merchant, the one the API shows on the approval (eprotectCheckoutRefusal): the
|
|
1444
|
+
// approval, the handle this returns and every authorization it pays then name one merchant, and no
|
|
1445
|
+
// approval is retired over a label. The API refuses a paypageId with no pin (unsupported_checkout);
|
|
1446
|
+
// test mode needs no pin.
|
|
1447
|
+
const eprotectRefusal = eprotect ? eprotectCheckoutRefusal({ environment: input.environment, paypageId: input.paypageId,
|
|
1448
|
+
merchantOrigin: input.merchantOrigin, merchant: input.merchant }) : null;
|
|
1449
|
+
if (eprotectRefusal)
|
|
1450
|
+
throw fail(eprotectRefusal);
|
|
959
1451
|
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
960
1452
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
961
1453
|
throw fail('timeout_invalid');
|
|
@@ -973,7 +1465,12 @@ export class VaultClient {
|
|
|
973
1465
|
environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
|
|
974
1466
|
...(profile ? { merchant_profile: profile.id } : {}),
|
|
975
1467
|
...(fiservPin ? { merchant_profile: fiservPin.id } : {}),
|
|
1468
|
+
...(verifone ? { merchant_profile: verifone.id } : {}),
|
|
976
1469
|
...(declaration ? { sandbox_declaration: { endpoint: declaration.endpoint, client_key: declaration.clientKey, environment: 'test' } } : {}),
|
|
1470
|
+
...(input.psp === 'creditguard' ? { payment_page_url: input.paymentPageUrl } : {}),
|
|
1471
|
+
...(eprotect ? { paypage_id: input.paypageId } : {}),
|
|
1472
|
+
...(flexContext ? { capture_context: flexContext.captureContext,
|
|
1473
|
+
microform: { version: flexContext.microform.version, fields: [...flexContext.microform.fields] } } : {}),
|
|
977
1474
|
}, AbortSignal.timeout(30_000), signal);
|
|
978
1475
|
if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
|
|
979
1476
|
throw fail('create_unconfirmed');
|
|
@@ -1004,12 +1501,18 @@ export class VaultClient {
|
|
|
1004
1501
|
// A merchant-hosted preparation names the merchant its reviewed profile names, and
|
|
1005
1502
|
// a declared test endpoint names its host (declareSandboxMerchant), never the caller's label.
|
|
1006
1503
|
// A Fiserv preparation names the merchant its key pin belongs to (input.merchant, checked above), and that pin.
|
|
1007
|
-
|
|
1008
|
-
|| state.
|
|
1504
|
+
// A Verifone preparation names the merchant its reviewed profile names, as a merchant-hosted one.
|
|
1505
|
+
|| state.user !== input.user || state.merchant !== (profile ? profile.merchant : verifone ? verifone.merchant : input.merchant)
|
|
1506
|
+
|| state.merchant_origin !== input.merchantOrigin
|
|
1507
|
+
|| state.merchant_profile !== (profile ? profile.id : fiservPin ? fiservPin.id : verifone ? verifone.id : undefined)
|
|
1009
1508
|
|| !servesDeclaration(state.sandbox_declaration, declaration)
|
|
1010
1509
|
|| !Number.isSafeInteger(state.amount) || (typeof input.amount === 'number' && state.amount !== input.amount) || state.currency !== input.currency.toLowerCase()
|
|
1011
1510
|
|| state.psp !== input.psp || state.mode !== preparationMode(input.psp) || state.environment !== input.environment
|
|
1012
|
-
|| state.checkout_key !== input.checkoutKey
|
|
1511
|
+
|| state.checkout_key !== input.checkoutKey
|
|
1512
|
+
// eProtect: the merchant and the card the cardholder approved are the ones asked for.
|
|
1513
|
+
|| (eprotect && (state.paypage_id !== input.paypageId || state.card_id !== input.cardId))
|
|
1514
|
+
// The API says a preparation tokenizes before Pay exactly when this one carried a capture context.
|
|
1515
|
+
|| (state.tokenize_before_pay === true) !== !!flexContext)
|
|
1013
1516
|
throw fail('ready_unconfirmed', id);
|
|
1014
1517
|
const prepared = Object.freeze({
|
|
1015
1518
|
id: preparationId, status: 'ready', psp: input.psp, environment: input.environment, mode: preparationMode(input.psp), expiresAt: state.ready_expires_at,
|
|
@@ -1019,7 +1522,12 @@ export class VaultClient {
|
|
|
1019
1522
|
paymentStatus: 'not_started', amountAuthority: 'agent',
|
|
1020
1523
|
...(profile ? { merchantProfile: profile.id } : {}),
|
|
1021
1524
|
...(fiservPin ? { merchantProfile: fiservPin.id } : {}),
|
|
1525
|
+
...(verifone ? { merchantProfile: verifone.id } : {}),
|
|
1022
1526
|
...(declaration ? { sandboxDeclaration: declaration } : {}),
|
|
1527
|
+
...(input.psp === 'creditguard' ? { paymentPageUrl: input.paymentPageUrl } : {}),
|
|
1528
|
+
...(eprotect ? { paypageId: input.paypageId } : {}),
|
|
1529
|
+
...(flexContext ? { cybersourceFlex: Object.freeze({ captureContext: flexContext.captureContext,
|
|
1530
|
+
microform: Object.freeze({ version: flexContext.microform.version, fields: Object.freeze([...flexContext.microform.fields]) }) }) } : {}),
|
|
1023
1531
|
});
|
|
1024
1532
|
this.preparations.add(prepared);
|
|
1025
1533
|
ready = true;
|
|
@@ -1034,13 +1542,18 @@ export class VaultClient {
|
|
|
1034
1542
|
catch (error) {
|
|
1035
1543
|
if (error instanceof CheckoutPreparationError)
|
|
1036
1544
|
throw error;
|
|
1545
|
+
const refusal = input.psp === 'creditguard' && id === null ? creditguardPreparationRefusal(error) : null;
|
|
1546
|
+
if (refusal)
|
|
1547
|
+
throw refusal;
|
|
1037
1548
|
// A refusal the API answered before any preparation existed names its rule: one
|
|
1038
1549
|
// PREPARATION_REFUSALS names for any checkout, and, for a Fiserv checkout only, one
|
|
1039
1550
|
// FISERV_PREPARATION_REFUSALS names or a 502 cse_key_unavailable. Every other
|
|
1040
1551
|
// processor's other refusals stay preparation_unconfirmed, as before.
|
|
1041
1552
|
if (!id && !signal.aborted && error instanceof CheckoutApiError && error.code
|
|
1042
1553
|
&& ((error.status >= 400 && error.status < 500 && (PREPARATION_REFUSALS.includes(error.code)
|
|
1043
|
-
|| (input.psp === 'fiserv' && FISERV_PREPARATION_REFUSALS.includes(error.code))
|
|
1554
|
+
|| (input.psp === 'fiserv' && FISERV_PREPARATION_REFUSALS.includes(error.code))
|
|
1555
|
+
|| (input.psp === EPROTECT_PSP && EPROTECT_PREPARATION_REFUSALS.includes(error.code))
|
|
1556
|
+
|| (!!flexContext && CYBERSOURCE_FLEX_PREPARATION_REFUSALS.includes(error.code))))
|
|
1044
1557
|
|| (input.psp === 'fiserv' && error.status === 502 && error.code === 'cse_key_unavailable')))
|
|
1045
1558
|
throw fail(error.code);
|
|
1046
1559
|
throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
|
|
@@ -1058,6 +1571,30 @@ export class VaultClient {
|
|
|
1058
1571
|
if (state?.id !== id || !['cancelled', 'expired'].includes(state.status))
|
|
1059
1572
|
throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
|
|
1060
1573
|
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Retire a ready preparation whose authorization create has not answered, and name the request
|
|
1576
|
+
* that already holds it. Resolves null once the API confirms no merchant request can bind the
|
|
1577
|
+
* preparation any more (cancelled or expired: the API takes a cancel and a create's bind under one
|
|
1578
|
+
* lock), or the ID of the authorization it is already bound to, which the caller retires like any
|
|
1579
|
+
* other (cancelAuthorization). Rejects when the API confirms neither. The Amadeus send window uses
|
|
1580
|
+
* it (amadeus.ts).
|
|
1581
|
+
*/
|
|
1582
|
+
async retirePreparation(id) {
|
|
1583
|
+
if (!/^cprep_[A-Za-z0-9_-]{1,128}$/.test(id))
|
|
1584
|
+
throw new CheckoutPreparationError(null, 'id_invalid');
|
|
1585
|
+
try {
|
|
1586
|
+
await this.cancelPreparation(id);
|
|
1587
|
+
return null;
|
|
1588
|
+
}
|
|
1589
|
+
catch { /* bound, or unanswered: read it */ }
|
|
1590
|
+
const state = await this.get(`/v2/checkout/preparations/${id}`, AbortSignal.timeout(3_000));
|
|
1591
|
+
if (state?.id === id && (state.status === 'cancelled' || state.status === 'expired'))
|
|
1592
|
+
return null;
|
|
1593
|
+
if (state?.id === id && state.status === 'bound' && typeof state.authorization_id === 'string'
|
|
1594
|
+
&& /^cauth_[A-Za-z0-9_-]{1,128}$/.test(state.authorization_id))
|
|
1595
|
+
return state.authorization_id;
|
|
1596
|
+
throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
|
|
1597
|
+
}
|
|
1061
1598
|
async observePreparation(id, guidance, reason) {
|
|
1062
1599
|
try {
|
|
1063
1600
|
await this.post(`/v2/checkout/preparations/${id}/observe`, {
|
|
@@ -1084,6 +1621,9 @@ export class VaultClient {
|
|
|
1084
1621
|
* response to replay into the browser. Your process never sees a card.
|
|
1085
1622
|
*/
|
|
1086
1623
|
async authorize(input) {
|
|
1624
|
+
// A reviewed merchant's own card endpoint is checked whole before anything else
|
|
1625
|
+
// (admitCardEndpoint): the profile, the approval, the amount and the session.
|
|
1626
|
+
const cardEndpoint = input.request.merchant_card_endpoint === undefined ? null : admitCardEndpoint(input);
|
|
1087
1627
|
const mercadoContext = input.request.mercado_checkout === undefined ? undefined : parseMercadoCheckoutContext(input.request.mercado_checkout);
|
|
1088
1628
|
if (mercadoContext && (!isMercadoTokenRequest(input.request.url, input.request.method)
|
|
1089
1629
|
|| input.executionMode === 'autopilot' || input.grantId))
|
|
@@ -1144,14 +1684,16 @@ export class VaultClient {
|
|
|
1144
1684
|
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
1145
1685
|
// An armed merchant profile's endpoint (merchant-hosted.ts): the reviewed
|
|
1146
1686
|
// merchant's own server, never a processor host, so no recognizer matches it.
|
|
1147
|
-
const merchantProfile = nativeCheckout || ownedShop || mercadoContext ? null : this.merchantProfileOf(input.request.url);
|
|
1687
|
+
const merchantProfile = cardEndpoint || nativeCheckout || ownedShop || mercadoContext ? null : this.merchantProfileOf(input.request.url);
|
|
1688
|
+
// An armed Verifone profile's origin (verifone.ts): the reviewed merchant's own server.
|
|
1689
|
+
const verifoneProfile = cardEndpoint || nativeCheckout || ownedShop || mercadoContext || merchantProfile ? null : this.verifoneProfileOf(input.request.url);
|
|
1148
1690
|
// A preparation pays the profile it was made for and nothing else: a
|
|
1149
1691
|
// merchant-hosted one never a processor's request or another profile's (two
|
|
1150
1692
|
// profiles could share an endpoint), and a processor-hosted one never a
|
|
1151
1693
|
// merchant's own endpoint. Checked here, not left to the matcher above, so the
|
|
1152
1694
|
// create below can only ever name the profile the cardholder prepared. A Fiserv
|
|
1153
1695
|
// preparation names a key pin, not a reviewed profile (matchesPreparation binds it).
|
|
1154
|
-
if (preparation && (((preparation.psp === 'fiserv' ? null : preparation.merchantProfile) ?? null) !== (merchantProfile?.id ?? null)
|
|
1696
|
+
if (preparation && (((preparation.psp === 'fiserv' || preparation.psp === 'verifone' ? null : preparation.merchantProfile) ?? null) !== (merchantProfile?.id ?? null)
|
|
1155
1697
|
|| (preparation.sandboxDeclaration?.endpoint ?? null) !== (merchantProfile?.sandboxDeclaration?.endpoint ?? null))) {
|
|
1156
1698
|
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
1157
1699
|
}
|
|
@@ -1161,46 +1703,64 @@ export class VaultClient {
|
|
|
1161
1703
|
}
|
|
1162
1704
|
if (merchantProfile)
|
|
1163
1705
|
assertMerchantHostedRequest(input, merchantProfile);
|
|
1164
|
-
|
|
1706
|
+
// A Verifone preparation pays only its profile's reviewed leg (matchesPreparation, above);
|
|
1707
|
+
// here a request at an armed Verifone merchant is held to that profile's own rules.
|
|
1708
|
+
if (verifoneProfile)
|
|
1709
|
+
assertVerifoneRequest(input, verifoneProfile);
|
|
1710
|
+
// A card endpoint is no processor: its profile, never the registry, says where it goes.
|
|
1711
|
+
const rec = cardEndpoint ? null : merchantProfile ? MERCHANT_HOSTED_RECOGNIZER : verifoneProfile ? VERIFONE_RECOGNIZER : nativeCheckout ? this.registry.find(entry => entry.psp === 'stripe' && (entry.mode ?? 'token') === 'token')
|
|
1165
1712
|
: findRecognizer(input.request.url, this.registry, { sandbox: this.sandbox });
|
|
1166
|
-
if (!rec)
|
|
1713
|
+
if (!cardEndpoint && !rec)
|
|
1167
1714
|
throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
|
|
1168
1715
|
// A client-side-encrypted processor is only takeable when its entry says
|
|
1169
1716
|
// the vault produces that ciphertext itself (mode cse); a bare
|
|
1170
1717
|
// clientSideEncrypted entry (an older registry, a hand-built one) still
|
|
1171
1718
|
// refuses here, exactly as before.
|
|
1172
|
-
const mode = rec.mode ?? 'token';
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1719
|
+
const mode = rec ? rec.mode ?? 'token' : CARD_ENDPOINT_MODE;
|
|
1720
|
+
// The transaction a CreditGuard submit pays; its approved answer is rebuilt for it alone.
|
|
1721
|
+
const creditguardTxId = rec?.psp === 'creditguard' ? creditguardProcessTxId(input.request.url) : null;
|
|
1722
|
+
if (rec) {
|
|
1723
|
+
if (preparation && (rec.psp !== preparation.psp || mode !== preparationMode(preparation.psp)))
|
|
1724
|
+
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
1725
|
+
// A preparation-required processor may not be taken over on an ordinary
|
|
1726
|
+
// authorization: the cardholder must have approved a prepare() first. Refuse
|
|
1727
|
+
// locally, before any create or prompt, when none is active, through the
|
|
1728
|
+
// same strict rule the API applies (recognizerRequiresPreparation). Fiserv,
|
|
1729
|
+
// aci, CreditGuard and amadeus_checkout set it (a CreditGuard submit is the
|
|
1730
|
+
// sale and names no amount); the API serves each only on a sync that declares
|
|
1731
|
+
// its capability,
|
|
1732
|
+
// and only to a company Agentcard turned it on for. This build carries
|
|
1733
|
+
// Fiserv's own rules, so it also requires the preparation for a Fiserv
|
|
1734
|
+
// request whatever the served entry says (requiresFiservPreparation): a
|
|
1735
|
+
// registry that dropped the flag never opens an ordinary Fiserv authorization.
|
|
1736
|
+
if ((recognizerRequiresPreparation(rec) || requiresFiservPreparation(rec.psp, input.request.url)) && !preparation)
|
|
1737
|
+
throw new PreparationRequiredError(rec.psp);
|
|
1738
|
+
// The merchant's server picks what it charges Adyen, so the agent's amount is
|
|
1739
|
+
// what the API holds the body's own amount to: a merchant-hosted payment names one.
|
|
1740
|
+
if (merchantProfile && input.amount == null) {
|
|
1741
|
+
throw new Error(`A payment to ${merchantProfile.merchant} names its amount: pass amount and currency, the amount the cardholder approves.`);
|
|
1742
|
+
}
|
|
1743
|
+
if (verifoneProfile && input.amount == null) {
|
|
1744
|
+
throw new Error(`A payment to ${verifoneProfile.merchant} names its amount: pass amount and currency, the amount the cardholder approves.`);
|
|
1745
|
+
}
|
|
1746
|
+
// device_encrypt (Payrails) also encrypts in-page; the device rebuilds that
|
|
1747
|
+
// ciphertext under the session key the request carries.
|
|
1748
|
+
if (rec.clientSideEncrypted && mode !== 'cse' && mode !== 'device_encrypt')
|
|
1749
|
+
throw new CardEncryptedError(rec.psp);
|
|
1750
|
+
if (rec.psp === 'creditguard' && creditguardTxId === null)
|
|
1751
|
+
throw new Error(`not a CreditGuard card submit: ${redactUrl(input.request.url)}`);
|
|
1752
|
+
if (mode === CARD_ENDPOINT_MODE || !SUPPORTED_MODES.includes(mode))
|
|
1753
|
+
throw new UnsupportedModeError(mode);
|
|
1190
1754
|
}
|
|
1191
|
-
if (rec.clientSideEncrypted && mode !== 'cse')
|
|
1192
|
-
throw new CardEncryptedError(rec.psp);
|
|
1193
|
-
if (!SUPPORTED_MODES.includes(mode))
|
|
1194
|
-
throw new UnsupportedModeError(mode);
|
|
1195
1755
|
// A dark-launch recognizer names the capability its create needs. A build that does not
|
|
1196
1756
|
// declare it cannot send what that create carries, so it refuses here, before any create;
|
|
1197
1757
|
// a registry the API served never holds such an entry.
|
|
1198
|
-
if (rec.capability !== undefined && !SUPPORTED_CAPABILITIES.includes(rec.capability))
|
|
1758
|
+
if (rec && rec.capability !== undefined && !SUPPORTED_CAPABILITIES.includes(rec.capability))
|
|
1199
1759
|
throw new UnsupportedCapabilityError(rec.capability);
|
|
1200
1760
|
// CyberSource Flex: the capture context and the Microform fields ride on the request (the
|
|
1201
1761
|
// adapters read both from the card field iframe) and the API verifies them before anything
|
|
1202
1762
|
// exists. They belong to a Flex request and nothing else, as the API holds them.
|
|
1203
|
-
const flex = rec
|
|
1763
|
+
const flex = rec?.psp === CYBERSOURCE_FLEX_PSP;
|
|
1204
1764
|
if (!flex && (input.request.capture_context !== undefined || input.request.microform !== undefined)) {
|
|
1205
1765
|
throw new Error('request.capture_context and request.microform belong to a CyberSource Flex card request.');
|
|
1206
1766
|
}
|
|
@@ -1208,15 +1768,39 @@ export class VaultClient {
|
|
|
1208
1768
|
throw new Error('A CyberSource Flex card request carries the capture context its card field iframe was loaded with (request.capture_context) '
|
|
1209
1769
|
+ 'and the Microform fields that loaded (request.microform); attachToCdp and attachToPlaywright read both.');
|
|
1210
1770
|
}
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
|
|
1771
|
+
// The session key is not in a device_encrypt request: without the one the
|
|
1772
|
+
// page's init payload named, the device has nothing to encrypt under.
|
|
1773
|
+
// Refused before any create or prompt, the way the API refuses
|
|
1774
|
+
// payrails_session_key_required.
|
|
1775
|
+
const sidecar = input.request.payrails;
|
|
1776
|
+
if (mode === 'device_encrypt' && (!sidecar || typeof sidecar.encryption_public_key !== 'string' || !sidecar.encryption_public_key)) {
|
|
1777
|
+
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.');
|
|
1778
|
+
}
|
|
1779
|
+
// A preparation that tokenized before Pay pays only a field request loaded under the capture
|
|
1780
|
+
// context and fields its cardholder approved: the device holds CyberSource's token for that context.
|
|
1781
|
+
if (preparation?.cybersourceFlex && !(flex && sameCybersourceFlexPreparation({ capture_context: preparation.cybersourceFlex.captureContext, microform: preparation.cybersourceFlex.microform }, { capture_context: input.request.capture_context, microform: input.request.microform }))) {
|
|
1782
|
+
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
1783
|
+
}
|
|
1784
|
+
// Say no rather than coerce. Each recognizer's card request has exactly one
|
|
1785
|
+
// method (recognizerRequestMethod): a POST body, or for Worldpay eProtect the
|
|
1786
|
+
// GET whose query carries the card. The service stores the replay template
|
|
1787
|
+
// with that method, so a caller handing in another one would get it
|
|
1788
|
+
// silently rewritten and the cardholder's device would replay something the
|
|
1789
|
+
// caller never asked for. A direct caller deserves to be told, not surprised.
|
|
1216
1790
|
const method = (input.request.method ?? 'POST').toUpperCase();
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1791
|
+
// A card endpoint has no recognizer: its card request is a POST, as every recognizer's is but eProtect's.
|
|
1792
|
+
const cardMethod = rec ? recognizerRequestMethod(rec) : 'POST';
|
|
1793
|
+
if (method !== cardMethod) {
|
|
1794
|
+
throw new Error(cardMethod === 'GET'
|
|
1795
|
+
? `a ${rec?.psp} card registration is a GET; got ${method} for ${redactUrl(input.request.url)}.`
|
|
1796
|
+
: `checkout authorization is POST-only; got ${method} for ${redactUrl(input.request.url)}. ` +
|
|
1797
|
+
'A non-POST tokenizer needs recognizer support before it can be intercepted.');
|
|
1798
|
+
}
|
|
1799
|
+
// A GET card request is its URL alone. This build completes one: Worldpay
|
|
1800
|
+
// eProtect's registration exactly as payment-core admits it, with no body.
|
|
1801
|
+
const eprotect = rec?.psp === EPROTECT_PSP;
|
|
1802
|
+
if (cardMethod === 'GET' && !(eprotect && isEprotectRegistration(input.request))) {
|
|
1803
|
+
throw new Error(`this ${rec?.psp} GET is not a card registration this version of @agent-cards/checkout can complete: ${redactUrl(input.request.url)}.`);
|
|
1220
1804
|
}
|
|
1221
1805
|
// The amount is a hint: a pair or nothing. Refuse half a pair here, before
|
|
1222
1806
|
// a network call: the API would too, and a retry loop cannot fix a
|
|
@@ -1226,15 +1810,27 @@ export class VaultClient {
|
|
|
1226
1810
|
if (hasAmount !== hasCurrency) {
|
|
1227
1811
|
throw new Error('amount and currency go together: pass both or neither.');
|
|
1228
1812
|
}
|
|
1229
|
-
if (hasAmount && !validAmountInput(input.amount))
|
|
1230
|
-
throw new Error(
|
|
1231
|
-
}
|
|
1813
|
+
if (hasAmount && !validAmountInput(input.amount))
|
|
1814
|
+
throw new Error(amountRefusal(input.amount, input.currency));
|
|
1232
1815
|
// A processor whose request names no amount (AGENT_AMOUNT_PROCESSORS) is
|
|
1233
|
-
// paid only for the pair passed here: it is all the approval can show
|
|
1816
|
+
// paid only for the pair passed here: it is all the approval can show and,
|
|
1817
|
+
// for WSPay, the only figure the processor's answer is checked against.
|
|
1234
1818
|
// Refused before any create or prompt; the API refuses the same create as
|
|
1235
1819
|
// amount_required.
|
|
1236
|
-
if (!hasAmount && AGENT_AMOUNT_PROCESSORS.includes(rec.psp))
|
|
1820
|
+
if (!hasAmount && rec && AGENT_AMOUNT_PROCESSORS.includes(rec.psp))
|
|
1237
1821
|
throw new AmountRequiredError(rec.psp);
|
|
1822
|
+
// Nor is the agent's pair tied to the page by anything but the total the
|
|
1823
|
+
// page shows: without it the cardholder could approve one figure while the
|
|
1824
|
+
// page charges another. The API refuses the same create as page_amount_required.
|
|
1825
|
+
if (!input.pageAmount && rec && PAGE_AMOUNT_PROCESSORS.includes(rec.psp))
|
|
1826
|
+
throw new PageAmountRequiredError(rec.psp);
|
|
1827
|
+
// A Payrails save names no amount (the merchant charges the saved card
|
|
1828
|
+
// later, under another session), so the price the cardholder approves is
|
|
1829
|
+
// the one sent here: the agent's pair or else the page total. Refused
|
|
1830
|
+
// before any create or prompt, the way the API refuses amount_required.
|
|
1831
|
+
if (mode === 'device_encrypt' && !hasAmount && !input.pageAmount) {
|
|
1832
|
+
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.');
|
|
1833
|
+
}
|
|
1238
1834
|
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
1239
1835
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
1240
1836
|
throw new Error('timeoutMs must be a positive integer no larger than 2147483647.');
|
|
@@ -1246,7 +1842,21 @@ export class VaultClient {
|
|
|
1246
1842
|
const priced = merchantProfile && merchantProfilePricedBySource(merchantProfile.id)
|
|
1247
1843
|
? await this.readMerchantTotal(input, merchantProfile, preparation, operationSignal) : null;
|
|
1248
1844
|
let created;
|
|
1249
|
-
const payload = {
|
|
1845
|
+
const payload = cardEndpoint ? {
|
|
1846
|
+
// A card endpoint's request travels only in merchant_card_endpoint: `request`
|
|
1847
|
+
// names its URL and nothing else, and the agent's amount is the only authority.
|
|
1848
|
+
user: input.user,
|
|
1849
|
+
merchant: input.merchant,
|
|
1850
|
+
amount: input.amount,
|
|
1851
|
+
currency: input.currency,
|
|
1852
|
+
psp: CARD_ENDPOINT_PSP,
|
|
1853
|
+
mode,
|
|
1854
|
+
...(input.cardId ? { cardId: input.cardId } : {}),
|
|
1855
|
+
execution_mode: 'user_approval',
|
|
1856
|
+
checkout_origin: cardEndpoint.session.page_origin,
|
|
1857
|
+
request: { url: cardEndpoint.session.url, method: 'POST', headers: {}, body: '' },
|
|
1858
|
+
merchant_card_endpoint: { profile: cardEndpoint.profile, session: cardEndpoint.session, ...(cardEndpoint.facts ? { facts: cardEndpoint.facts } : {}) },
|
|
1859
|
+
} : {
|
|
1250
1860
|
user: input.user,
|
|
1251
1861
|
merchant: input.merchant,
|
|
1252
1862
|
// snake_case on the wire; camelCase is this SDK's convention.
|
|
@@ -1269,11 +1879,16 @@ export class VaultClient {
|
|
|
1269
1879
|
endpoint: merchantProfile.sandboxDeclaration.endpoint, client_key: merchantProfile.sandboxDeclaration.clientKey, environment: 'test'
|
|
1270
1880
|
} } : {}),
|
|
1271
1881
|
...(priced ? { merchant_total: priced.wire } : {}) } } : {}),
|
|
1882
|
+
// Only the profile's id: the API takes the leg, the key and its environment from the
|
|
1883
|
+
// profile Agentcard reviewed, never from the caller.
|
|
1884
|
+
...(verifoneProfile ? { verifone: { profile: verifoneProfile.id } } : {}),
|
|
1272
1885
|
// A prepared checkout already carries the page origin as merchant_origin.
|
|
1273
1886
|
...(preparation
|
|
1274
1887
|
? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin, form_guidance: 'filled' }
|
|
1275
1888
|
: input.pageOrigin ? { checkout_origin: input.pageOrigin } : {}),
|
|
1276
|
-
request:
|
|
1889
|
+
// A GET card request (Worldpay eProtect) is its URL alone: no body, and no
|
|
1890
|
+
// header the page sent, which the API would neither store nor replay.
|
|
1891
|
+
request: cardMethod === 'GET' ? { url: input.request.url, method: 'GET', headers: {}, body: '' } : {
|
|
1277
1892
|
url: input.request.url,
|
|
1278
1893
|
method: input.request.method,
|
|
1279
1894
|
headers: { ...pickHeaders(input.request.headers, rec.passthroughHeaders),
|
|
@@ -1285,6 +1900,8 @@ export class VaultClient {
|
|
|
1285
1900
|
microform: { version: input.request.microform.version, fields: [...input.request.microform.fields],
|
|
1286
1901
|
...(input.request.microform.trigger ? { trigger: input.request.microform.trigger } : {}) },
|
|
1287
1902
|
} : {}),
|
|
1903
|
+
// Only the key: the token already rides in the passthrough authorization header.
|
|
1904
|
+
...(mode === 'device_encrypt' && sidecar ? { payrails: { encryption_public_key: sidecar.encryption_public_key } } : {}),
|
|
1288
1905
|
},
|
|
1289
1906
|
};
|
|
1290
1907
|
try {
|
|
@@ -1324,7 +1941,18 @@ export class VaultClient {
|
|
|
1324
1941
|
// 10 seconds has at most 20 reads instead of 5 (15 additional reads).
|
|
1325
1942
|
// Then restore the caller's interval. Human approval waiting, cancellation,
|
|
1326
1943
|
// merchant deadlines, and the single processor request remain unchanged.
|
|
1327
|
-
|
|
1944
|
+
// A prepared Worldpay eProtect registration too, for 15 seconds: its
|
|
1945
|
+
// merchants give up on the paused script 10 to 15 seconds after Pay, so a
|
|
1946
|
+
// registration is read every 500 ms instead of waiting out 2 seconds.
|
|
1947
|
+
const preparedFastPollUntil = preparation?.psp === 'mercado_pago' ? Date.now() + 10_000
|
|
1948
|
+
: preparation?.psp === EPROTECT_PSP ? Date.now() + 15_000 : 0;
|
|
1949
|
+
// A preparation that tokenized before Pay: the cardholder's device already holds CyberSource's
|
|
1950
|
+
// token and approves as soon as this bind reaches it, while the page may give createToken only
|
|
1951
|
+
// seconds (Alaska Airlines' flight checkout gives it three). Poll every 100 ms for 10 seconds,
|
|
1952
|
+
// then at the caller's interval; at the default 2 seconds the first read alone would miss it.
|
|
1953
|
+
// The API's bind names 100 ms as such a row's poll interval, so slow_down admits these reads
|
|
1954
|
+
// (as an eProtect row names the 500 ms read above).
|
|
1955
|
+
const preparedFlexPollUntil = preparation?.cybersourceFlex ? Date.now() + 10_000 : 0;
|
|
1328
1956
|
let execution = {};
|
|
1329
1957
|
let approvalDelivered = false;
|
|
1330
1958
|
const deliverApproval = (state) => {
|
|
@@ -1340,6 +1968,8 @@ export class VaultClient {
|
|
|
1340
1968
|
catch { /* observer only */ }
|
|
1341
1969
|
};
|
|
1342
1970
|
let failed = false;
|
|
1971
|
+
// What this attempt throws, once it fails: a confirmed retirement below is written onto it.
|
|
1972
|
+
let failure = null;
|
|
1343
1973
|
let actionRequired = false;
|
|
1344
1974
|
const checkAutopilotAction = (state) => {
|
|
1345
1975
|
if (state.autopilot_status !== 'action_required')
|
|
@@ -1370,7 +2000,8 @@ export class VaultClient {
|
|
|
1370
2000
|
while (Date.now() < deadline) {
|
|
1371
2001
|
if (stopSignal?.aborted)
|
|
1372
2002
|
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
1373
|
-
const pollInterval = Date.now() <
|
|
2003
|
+
const pollInterval = Date.now() < preparedFastPollUntil ? Math.min(this.pollIntervalMs, 500)
|
|
2004
|
+
: Date.now() < preparedFlexPollUntil ? Math.min(this.pollIntervalMs, 100) : this.pollIntervalMs;
|
|
1374
2005
|
await interruptibleSleep(Math.min(pollInterval, Math.max(0, deadline - Date.now())), stopSignal);
|
|
1375
2006
|
if (stopSignal?.aborted)
|
|
1376
2007
|
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
@@ -1405,7 +2036,7 @@ export class VaultClient {
|
|
|
1405
2036
|
// database forces these rows to cse. A finished read in any other mode
|
|
1406
2037
|
// (token, a missing mode, a hosted form) is not one to act on, and never
|
|
1407
2038
|
// reaches the code that would answer the merchant's paused request with it.
|
|
1408
|
-
if (merchantProfile && (s.status === 'approved' || s.status === 'submitted_on_device') && s.mode !== 'cse') {
|
|
2039
|
+
if ((merchantProfile || verifoneProfile) && (s.status === 'approved' || s.status === 'submitted_on_device') && s.mode !== 'cse') {
|
|
1409
2040
|
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_hosted_mode_invalid');
|
|
1410
2041
|
}
|
|
1411
2042
|
if (s.status === 'submitted_on_device') {
|
|
@@ -1426,6 +2057,13 @@ export class VaultClient {
|
|
|
1426
2057
|
}
|
|
1427
2058
|
if (s.status === 'approved') {
|
|
1428
2059
|
const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
2060
|
+
if (cardEndpoint || approvedMode === CARD_ENDPOINT_MODE) {
|
|
2061
|
+
// Only a card endpoint request is answered from a merchant's own answer, and
|
|
2062
|
+
// it is answered from nothing else.
|
|
2063
|
+
if (!cardEndpoint || approvedMode !== CARD_ENDPOINT_MODE)
|
|
2064
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
|
|
2065
|
+
return cardEndpointReplay(s, authorizationId, cardEndpoint.profile, { ...amountAuthority, ...execution });
|
|
2066
|
+
}
|
|
1429
2067
|
if (approvedMode === 'hosted_form') {
|
|
1430
2068
|
// Never: the API finishes a hosted form as submitted_on_device, and
|
|
1431
2069
|
// its database refuses `approved` on that mode. An answer that says
|
|
@@ -1437,19 +2075,30 @@ export class VaultClient {
|
|
|
1437
2075
|
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
|
|
1438
2076
|
// A merchant-hosted create reads back only a merchant-hosted approval,
|
|
1439
2077
|
// and a processor-hosted one never does.
|
|
2078
|
+
// A Verifone create reads back only a Verifone approval, and no other create ever does.
|
|
2079
|
+
if (verifoneProfile || (s.substitutions && typeof s.substitutions === 'object' && s.substitutions.kind === 'verifone')) {
|
|
2080
|
+
return { ...verifoneApproval(s, authorizationId, verifoneProfile), ...amountAuthority, ...execution };
|
|
2081
|
+
}
|
|
1440
2082
|
if (merchantProfile || (s.substitutions && typeof s.substitutions === 'object' && 'kind' in s.substitutions)) {
|
|
1441
2083
|
return { ...merchantHostedApproval(s, authorizationId, merchantProfile), ...(priced ? { merchantTotal: priced.approved } : {}),
|
|
1442
2084
|
...amountAuthority, ...execution };
|
|
1443
2085
|
}
|
|
1444
|
-
// The API serves a Fiserv envelope only briefly after the
|
|
1445
|
-
//
|
|
1446
|
-
// window says so instead of serving it; the
|
|
2086
|
+
// The API serves a Fiserv envelope, or a Shiji STS set, only briefly after the
|
|
2087
|
+
// approval (neither is bound to a session, so whoever holds it could resubmit
|
|
2088
|
+
// it). A read after that window says so instead of serving it; the request was
|
|
2089
|
+
// never continued here, and nothing here can pay with it.
|
|
1447
2090
|
if (s.substitutions_expired === true)
|
|
1448
2091
|
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_expired');
|
|
1449
2092
|
const sub = s.substitutions;
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
2093
|
+
// Shiji STS carries its fields at the body root (`at` is ''), and
|
|
2094
|
+
// exactly pan, expires and cvv, each a 128-byte ciphertext, beside
|
|
2095
|
+
// the page's reviewed getCvvId keys (no keys, no continuation);
|
|
2096
|
+
// every other processor names a holder path, as before.
|
|
2097
|
+
const shiji = rec?.psp === SHIJI_STS_PSP;
|
|
2098
|
+
const fieldsOk = shiji ? isShijiStsServedSubstitution(sub) && isShijiStsServedCompanion(s.shiji_sts_companion)
|
|
2099
|
+
: sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
|
|
2100
|
+
&& sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
|
|
2101
|
+
&& Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
|
|
1453
2102
|
if (!fieldsOk)
|
|
1454
2103
|
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
|
|
1455
2104
|
// A Fiserv approval carries exactly its envelope's four members at
|
|
@@ -1473,12 +2122,39 @@ export class VaultClient {
|
|
|
1473
2122
|
fields: { ...sub.fields },
|
|
1474
2123
|
...(removeRaw ? { remove: [...removeRaw] } : {}),
|
|
1475
2124
|
},
|
|
2125
|
+
...(shiji ? { shijiStsCompanion: Object.freeze({ keySequences: Object.freeze([...s.shiji_sts_companion.key_sequences]) }) } : {}),
|
|
1476
2126
|
...amountAuthority,
|
|
1477
2127
|
...execution,
|
|
1478
2128
|
};
|
|
1479
2129
|
}
|
|
1480
|
-
if (approvedMode
|
|
2130
|
+
if (approvedMode === 'device_encrypt') {
|
|
2131
|
+
// The device sent the card to Payrails itself; this is Payrails' answer to it. It
|
|
2132
|
+
// enters the page only as a 201 for a card instrument, rebuilt from payment-core's
|
|
2133
|
+
// allow-list under a JSON content type (payrails.ts payrailsSaveAnswer). Anything else
|
|
2134
|
+
// is never handed over: the card may be saved, so the outcome is unknown, not declined.
|
|
2135
|
+
// Autopilot never runs this mode, so an autopilot answer is not one to act on either.
|
|
2136
|
+
if (execution.executionMode === 'autopilot')
|
|
2137
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
|
|
2138
|
+
if (mode !== 'device_encrypt')
|
|
2139
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
|
|
2140
|
+
const saved = s.response;
|
|
2141
|
+
const answer = saved && typeof saved === 'object' && Number.isInteger(saved.status) && typeof saved.body === 'string'
|
|
2142
|
+
? payrailsSaveAnswer(saved.status, saved.body) : null;
|
|
2143
|
+
if (!answer)
|
|
2144
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'payrails_answer_unconfirmed');
|
|
2145
|
+
if (s.processor_context !== undefined)
|
|
2146
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'unexpected_processor_context');
|
|
2147
|
+
return { mode: 'device_encrypt', authorizationId, ...answer, ...amountAuthority, ...execution };
|
|
2148
|
+
}
|
|
2149
|
+
// A save created as device_encrypt is answered only as one: Payrails' raw answer never
|
|
2150
|
+
// passes through the token path below.
|
|
2151
|
+
if (approvedMode !== 'token' || mode === 'device_encrypt')
|
|
1481
2152
|
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
|
|
2153
|
+
// The API serves an approved answer whose landing url carries a processor's card token
|
|
2154
|
+
// (CreditGuard's) only for a window after the approval, this SDK's default timeoutMs; one it
|
|
2155
|
+
// no longer serves is an unknown outcome, never a page to continue.
|
|
2156
|
+
if (s.response_expired === true)
|
|
2157
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'response_expired');
|
|
1482
2158
|
const response = s.response;
|
|
1483
2159
|
if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
|
|
1484
2160
|
|| typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
|
|
@@ -1490,6 +2166,41 @@ export class VaultClient {
|
|
|
1490
2166
|
if (flex && (response.status < 200 || response.status > 299 || !isCybersourceFlexTokenShape(response.body))) {
|
|
1491
2167
|
throw new PaymentOutcomeUnknownError(authorizationId, 'cybersource_flex_token_unconfirmed');
|
|
1492
2168
|
}
|
|
2169
|
+
if (rec?.psp === JUSPAY_PSP) {
|
|
2170
|
+
// The page receives only the allowlisted /txns answer for the paused
|
|
2171
|
+
// order, re-serialized here from the stored one (juspayPageAnswer),
|
|
2172
|
+
// with a JSON content type as its one header. Anything else is not an
|
|
2173
|
+
// answer to hand the page: the device may already have sent the payment.
|
|
2174
|
+
const answer = juspayPageAnswer(response.status, response.body, input.request);
|
|
2175
|
+
if (answer === null)
|
|
2176
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'juspay_response_unconfirmed');
|
|
2177
|
+
response.body = answer;
|
|
2178
|
+
response.headers = { 'content-type': 'application/json' };
|
|
2179
|
+
}
|
|
2180
|
+
if (rec?.psp === ACI_PSP) {
|
|
2181
|
+
// ACI: the paused card-number request, and the page's CVC and expiry requests the
|
|
2182
|
+
// adapters answer with the same bytes, get exactly ACI's rebuilt success answer under
|
|
2183
|
+
// JSON. Anything else is not an answer to hand the page, and the device may already have
|
|
2184
|
+
// put the real card on the checkout.
|
|
2185
|
+
const answer = aciCardLegAnswer(response);
|
|
2186
|
+
if (!answer)
|
|
2187
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'aci_answer_unconfirmed');
|
|
2188
|
+
response.status = answer.status;
|
|
2189
|
+
response.headers = answer.headers;
|
|
2190
|
+
response.body = answer.body;
|
|
2191
|
+
}
|
|
2192
|
+
if (eprotect) {
|
|
2193
|
+
// Worldpay eProtect: the paused <script> is answered the way Worldpay answers it,
|
|
2194
|
+
// `<callback>(<registration>)` under its script type, with the callback the paused URL
|
|
2195
|
+
// named and only a registration payment-core's judge accepts for that URL. Anything else
|
|
2196
|
+
// is not an answer to hand the page, and the device may already have registered the card.
|
|
2197
|
+
const answer = eprotectScriptAnswer(input.request.url, response);
|
|
2198
|
+
if (!answer)
|
|
2199
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'eprotect_registration_unconfirmed');
|
|
2200
|
+
response.status = answer.status;
|
|
2201
|
+
response.headers = answer.headers;
|
|
2202
|
+
response.body = answer.body;
|
|
2203
|
+
}
|
|
1493
2204
|
if (shopOrder) {
|
|
1494
2205
|
let receipt;
|
|
1495
2206
|
try {
|
|
@@ -1502,6 +2213,26 @@ export class VaultClient {
|
|
|
1502
2213
|
response.body = JSON.stringify(receipt);
|
|
1503
2214
|
response.headers = { 'content-type': 'application/json' };
|
|
1504
2215
|
}
|
|
2216
|
+
if (creditguardTxId !== null) {
|
|
2217
|
+
// A CreditGuard payment is always prepared and approved by the cardholder, and no
|
|
2218
|
+
// autopilot run sends one (payment-core and the autopilot authority refuse it), so an
|
|
2219
|
+
// approval the API reports as unattended is not handed to the page.
|
|
2220
|
+
if (execution.executionMode === 'autopilot')
|
|
2221
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
|
|
2222
|
+
// CreditGuard's answer to the device's submit reaches the page only as payment-core
|
|
2223
|
+
// rebuilds it from its allow-list, for the paused transaction, as JSON: an answer that
|
|
2224
|
+
// is not an approval of this txId is an unknown outcome, never handed to the page.
|
|
2225
|
+
let rebuilt;
|
|
2226
|
+
try {
|
|
2227
|
+
if (response.status === 200)
|
|
2228
|
+
rebuilt = sanitizeCreditguardResponse(response.body, { txId: creditguardTxId });
|
|
2229
|
+
}
|
|
2230
|
+
catch { /* Never return an unvalidated processor body to the page. */ }
|
|
2231
|
+
if (rebuilt === undefined)
|
|
2232
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'creditguard_response_unconfirmed');
|
|
2233
|
+
response.body = rebuilt;
|
|
2234
|
+
response.headers = { 'content-type': 'application/json' };
|
|
2235
|
+
}
|
|
1505
2236
|
if (checkoutContext) {
|
|
1506
2237
|
let receipt;
|
|
1507
2238
|
try {
|
|
@@ -1514,7 +2245,7 @@ export class VaultClient {
|
|
|
1514
2245
|
response.body = JSON.stringify(receipt);
|
|
1515
2246
|
response.headers = { 'content-type': 'application/json' };
|
|
1516
2247
|
}
|
|
1517
|
-
if (rec
|
|
2248
|
+
if (rec?.psp === GMO_PSP) {
|
|
1518
2249
|
// GMO's answer enters the page only as one clean single-use token under a JSON
|
|
1519
2250
|
// content type (gmo.ts gmoTokenAnswer). Anything else is never handed over; the
|
|
1520
2251
|
// card may have left the device, so the outcome is unknown rather than declined.
|
|
@@ -1525,6 +2256,18 @@ export class VaultClient {
|
|
|
1525
2256
|
response.headers = answer.headers;
|
|
1526
2257
|
response.body = answer.body;
|
|
1527
2258
|
}
|
|
2259
|
+
if (rec?.psp === PRIMER_PSP) {
|
|
2260
|
+
// Primer's answer enters the page only as one single-use card token rebuilt from
|
|
2261
|
+
// payment-core's allow-list, under a JSON content type (primer.ts primerTokenAnswer).
|
|
2262
|
+
// Anything else, a multi-use token or vaultData included, is never handed over; the card
|
|
2263
|
+
// may have left the device, so the outcome is unknown rather than declined.
|
|
2264
|
+
const answer = primerTokenAnswer(response.status, response.body);
|
|
2265
|
+
if (!answer)
|
|
2266
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'primer_token_unconfirmed');
|
|
2267
|
+
response.status = answer.status;
|
|
2268
|
+
response.headers = answer.headers;
|
|
2269
|
+
response.body = answer.body;
|
|
2270
|
+
}
|
|
1528
2271
|
let processorContext;
|
|
1529
2272
|
if (mercadoContext) {
|
|
1530
2273
|
try {
|
|
@@ -1588,19 +2331,25 @@ export class VaultClient {
|
|
|
1588
2331
|
}
|
|
1589
2332
|
catch (error) {
|
|
1590
2333
|
failed = true;
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
throw error;
|
|
2334
|
+
failure = input.merchantSignal?.aborted ? new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted') : error;
|
|
2335
|
+
throw failure;
|
|
1594
2336
|
}
|
|
1595
2337
|
finally {
|
|
1596
2338
|
// An attested action-required result retains its claim and reservation.
|
|
1597
2339
|
// Reporting that state must not request cancellation of the same attempt.
|
|
1598
|
-
|
|
2340
|
+
// A card endpoint request that failed here has no page request left to answer, so
|
|
2341
|
+
// it is retired too: the device must not send the card for an answer nobody reads.
|
|
2342
|
+
if (!actionRequired && (input.merchantSignal?.aborted || ((preparation || nativeCheckout || cardEndpoint) && failed))) {
|
|
1599
2343
|
// Drain a create acknowledgement even after the merchant aborts so its
|
|
1600
2344
|
// known ID can be retired. An unacknowledged create remains unknown.
|
|
1601
2345
|
// A started/finalized replay or failed cleanup never becomes a claimed
|
|
1602
2346
|
// cancellation, and this best-effort cleanup never retries a payment.
|
|
1603
|
-
await this.cancelAuthorization(authorizationId).catch(() =>
|
|
2347
|
+
const retired = await this.cancelAuthorization(authorizationId).catch(() => null);
|
|
2348
|
+
// A retirement whose acknowledgement says no processor request started: the thrown error
|
|
2349
|
+
// says so. The API echoes the reason the row carries, so a row retired earlier as
|
|
2350
|
+
// merchant_never_retried can answer that a processor request did start; then nothing changes.
|
|
2351
|
+
if (retired?.processor_request_started === false && failure instanceof PaymentOutcomeUnknownError)
|
|
2352
|
+
failure.processorRequestStarted = false;
|
|
1604
2353
|
}
|
|
1605
2354
|
}
|
|
1606
2355
|
}
|
|
@@ -1688,6 +2437,65 @@ export class VaultClient {
|
|
|
1688
2437
|
}
|
|
1689
2438
|
return { paymentIntentId: pi.id, amount: pi.amount, currency: pi.currency };
|
|
1690
2439
|
}
|
|
2440
|
+
/**
|
|
2441
|
+
* Ask whether the page may save the card with the Stripe payment method an
|
|
2442
|
+
* approval produced: a SetupIntent confirm that carries no card and pays with
|
|
2443
|
+
* exactly the approved payment method (the page's own, or written by the
|
|
2444
|
+
* adapters in place of a card the confirm carries again).
|
|
2445
|
+
* The API allows a card save only for a company Agentcard turned it on for,
|
|
2446
|
+
* for an approval given on the Vault page, not by voice, with no amount, and
|
|
2447
|
+
* a SetupIntent Stripe reports still confirmable on the same Stripe account;
|
|
2448
|
+
* any other answer rejects with a CheckoutApiError whose code says why (for
|
|
2449
|
+
* example `continuation_invalid` where it is off, `amount_mismatch`). Nothing
|
|
2450
|
+
* is charged: the adapters continue the page's request once this resolves.
|
|
2451
|
+
*/
|
|
2452
|
+
async checkStripeSetupContinuation(authorizationId, request) {
|
|
2453
|
+
if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
|
|
2454
|
+
throw new Error('Invalid authorization ID.');
|
|
2455
|
+
const rec = findRecognizer(request.url, this.registry);
|
|
2456
|
+
if (rec?.psp !== 'stripe')
|
|
2457
|
+
throw new Error('Only a Stripe request can continue an approved Stripe payment method.');
|
|
2458
|
+
let confirmed = null;
|
|
2459
|
+
try {
|
|
2460
|
+
confirmed = /^\/v1\/setup_intents\/(seti_[A-Za-z0-9]+)\/confirm$/.exec(new URL(request.url).pathname)?.[1] ?? null;
|
|
2461
|
+
}
|
|
2462
|
+
catch {
|
|
2463
|
+
confirmed = null;
|
|
2464
|
+
}
|
|
2465
|
+
if (!confirmed)
|
|
2466
|
+
throw new Error('Only a Stripe SetupIntent confirmation can continue into a card save.');
|
|
2467
|
+
const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/continuations`, {
|
|
2468
|
+
request: { url: request.url, method: request.method, headers: pickHeaders(request.headers, rec.passthroughHeaders), body: request.body },
|
|
2469
|
+
}, AbortSignal.timeout(15_000));
|
|
2470
|
+
const si = result?.setup_intent;
|
|
2471
|
+
// The answer names the SetupIntent this page confirms, and no payment.
|
|
2472
|
+
if (result?.object !== 'checkout_continuation' || result.continuation !== 'allowed' || result.authorization !== authorizationId
|
|
2473
|
+
|| result.payment_intent !== undefined || !si || si.id !== confirmed) {
|
|
2474
|
+
throw new Error('The continuation answer was not recognized.');
|
|
2475
|
+
}
|
|
2476
|
+
return { setupIntentId: confirmed };
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Create a merchant-session probe, the phase-0 read of the deterministic merchant total:
|
|
2480
|
+
* `user` is the org's connected user, who opens the returned Vault link and reads the
|
|
2481
|
+
* merchant's own total once through Agentcard's merchant-session relay. The capture is
|
|
2482
|
+
* the merchant's cookie jar as captureMerchantSession read it; the capture rules run
|
|
2483
|
+
* again here, so no bot-manager cookie and no signed-in session ever leaves. No card is
|
|
2484
|
+
* involved. The API answers 404 for every org not on its probe list.
|
|
2485
|
+
*/
|
|
2486
|
+
async createMerchantSessionProbe(input) {
|
|
2487
|
+
const capture = merchantSessionCapture(input.capture.profile, input.capture.cookies);
|
|
2488
|
+
const created = await this.post('/v2/checkout/merchant-session-probes', { user: input.user, profile: capture.profile, session: { cookies: capture.cookies } }, AbortSignal.timeout(15_000));
|
|
2489
|
+
return { id: created.id, profile: created.profile, approvalUrl: created.approval_url, expiresAt: created.expires_at };
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Record the same read made from the autopilot egress by the rustls runner (the API's
|
|
2493
|
+
* egressResultSchema: statuses, header names, sizes, times, totals, hashes and the
|
|
2494
|
+
* certificate chain's SPKI hashes). Once per probe.
|
|
2495
|
+
*/
|
|
2496
|
+
async recordMerchantSessionEgress(probeId, result) {
|
|
2497
|
+
await this.post(`/v2/checkout/merchant-session-probes/${encodeURIComponent(probeId)}/egress-result`, { result }, AbortSignal.timeout(15_000));
|
|
2498
|
+
}
|
|
1691
2499
|
/**
|
|
1692
2500
|
* After a merchant-hosted payment priced by the merchant's own total: what the merchant's
|
|
1693
2501
|
* confirmation says it charged (merchant-total.ts merchantChargeReport builds `report`
|
|
@@ -1896,6 +2704,112 @@ export class VaultClient {
|
|
|
1896
2704
|
return this.call(`/v2/checkout/preparations/${id}`, { signal }, false, undefined, 0);
|
|
1897
2705
|
}
|
|
1898
2706
|
}
|
|
2707
|
+
/**
|
|
2708
|
+
* A card endpoint request, checked whole before any network call: a profile in this
|
|
2709
|
+
* build's table; no other checkout context, Autopilot or preparation (the cardholder
|
|
2710
|
+
* approves every such payment); the agent's amount, the only authority here; a
|
|
2711
|
+
* session the API and the device would take (payment-core
|
|
2712
|
+
* merchantCardEndpointSessionRefusal: the profile's URL, headers, cookies, Referer and
|
|
2713
|
+
* body, paused on its checkout page); for a merchant that keeps its own record of the
|
|
2714
|
+
* order, that record, held to the agent's amount and currency, and refused for an order
|
|
2715
|
+
* that renews or a checkout in test mode (admitCardEndpointFacts); and the profile's
|
|
2716
|
+
* status last, so that refusal means every other check passed. Nothing it throws names
|
|
2717
|
+
* the URL, the body or a session value.
|
|
2718
|
+
*/
|
|
2719
|
+
function admitCardEndpoint(input) {
|
|
2720
|
+
const named = input.request.merchant_card_endpoint;
|
|
2721
|
+
const profile = isRecord(named) && typeof named.profile === 'string' ? cardEndpointById(named.profile) : null;
|
|
2722
|
+
if (!profile || !isRecord(named))
|
|
2723
|
+
throw new Error('merchant_card_endpoint names no card endpoint Agentcard reviewed.');
|
|
2724
|
+
if (input.request.mercado_checkout !== undefined || hasStripeCheckoutMarker(input.request) || hasOwnedShopMarker(input.request)) {
|
|
2725
|
+
throw new CardEndpointRefusedError(profile.id, 'request_unsupported', 'the request carries another checkout\'s context');
|
|
2726
|
+
}
|
|
2727
|
+
if (input.executionMode === 'autopilot' || input.grantId !== undefined)
|
|
2728
|
+
throw new CardEndpointRefusedError(profile.id, 'approval_required');
|
|
2729
|
+
if (input.preparation || input.stripeCheckoutEnvironment !== undefined)
|
|
2730
|
+
throw new CardEndpointRefusedError(profile.id, 'preparation_unsupported');
|
|
2731
|
+
if (input.amount == null || typeof input.currency !== 'string' || !input.currency)
|
|
2732
|
+
throw new CardEndpointRefusedError(profile.id, 'amount_required');
|
|
2733
|
+
const session = named.session;
|
|
2734
|
+
if (!isRecord(session) || (input.request.method ?? 'POST').toUpperCase() !== 'POST' || input.request.url !== session.url) {
|
|
2735
|
+
throw new CardEndpointRefusedError(profile.id, 'request_unsupported', 'the request is not the session\'s card request');
|
|
2736
|
+
}
|
|
2737
|
+
const refusal = cardEndpointSessionRefusal(profile, session);
|
|
2738
|
+
if (refusal)
|
|
2739
|
+
throw new CardEndpointRefusedError(profile.id, 'request_unsupported', refusal);
|
|
2740
|
+
const facts = admitCardEndpointFacts(profile, session, named.facts, input);
|
|
2741
|
+
if (!cardEndpointLive(profile))
|
|
2742
|
+
throw new CardEndpointRefusedError(profile.id, 'not_enabled');
|
|
2743
|
+
return { profile: profile.id, session, ...(facts ? { facts } : {}) };
|
|
2744
|
+
}
|
|
2745
|
+
/**
|
|
2746
|
+
* The merchant's own record of the order, for a profile whose merchant keeps one
|
|
2747
|
+
* (profile.facts), read with the rules the API reads it with (payment-core
|
|
2748
|
+
* readMerchantCardEndpointFacts and merchantCardEndpointFactsRefusal): required, bound
|
|
2749
|
+
* to this checkout's session, in the reviewed layout; a total or currency that differs
|
|
2750
|
+
* from the agent's is an AmountMismatchError against the merchant's total, and a
|
|
2751
|
+
* renewing order or a checkout in test mode is refused. Card endpoint payments are live
|
|
2752
|
+
* only, so the record must say the checkout is live. Undefined for any other profile,
|
|
2753
|
+
* which sends none.
|
|
2754
|
+
*/
|
|
2755
|
+
function admitCardEndpointFacts(profile, session, sent, input) {
|
|
2756
|
+
if (!profile.facts) {
|
|
2757
|
+
if (sent !== undefined)
|
|
2758
|
+
throw new CardEndpointRefusedError(profile.id, 'request_unsupported', 'the request carries an order record this merchant has none of');
|
|
2759
|
+
return undefined;
|
|
2760
|
+
}
|
|
2761
|
+
if (sent === undefined)
|
|
2762
|
+
throw new CardEndpointRefusedError(profile.id, 'facts_unavailable', `${profile.merchant}'s own record of this order was not read`);
|
|
2763
|
+
const read = sharedReadFacts(profile.id, session.url, sent);
|
|
2764
|
+
if (!read.ok)
|
|
2765
|
+
throw new CardEndpointRefusedError(profile.id, 'facts_unavailable', read.refusal);
|
|
2766
|
+
const currency = String(input.currency).toLowerCase();
|
|
2767
|
+
const refusal = sharedFactsRefusal(profile.id, read.facts, { amountCents: typeof input.amount === 'number' ? input.amount : Number.NaN, currency, live: true });
|
|
2768
|
+
if (refusal?.code === 'amount' || refusal?.code === 'currency') {
|
|
2769
|
+
// An integer amount is compared here; a decimal string is left to the API, which
|
|
2770
|
+
// reads it in the currency's units and holds it to the same total.
|
|
2771
|
+
if (typeof input.amount === 'number') {
|
|
2772
|
+
throw new AmountMismatchError(null, input.amount, read.facts.amount.amountCents, currency, read.facts.amount.currency, 'create', 'merchant_total');
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
else if (refusal) {
|
|
2776
|
+
throw new CardEndpointRefusedError(profile.id, 'facts_refused', refusal.reason);
|
|
2777
|
+
}
|
|
2778
|
+
return sent;
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* The merchant's answer to the device's card request, as the API serves it once
|
|
2782
|
+
* (merchant_card_endpoint.answer), checked before any of it reaches the page: the
|
|
2783
|
+
* profile the request named, an HTTP status, string headers, Set-Cookie strings and a
|
|
2784
|
+
* body, or `withheld` with none of them. An answer the API no longer holds
|
|
2785
|
+
* (answer_expired) or cannot vouch for leaves the outcome unknown: the card was sent.
|
|
2786
|
+
*/
|
|
2787
|
+
function cardEndpointReplay(s, authorizationId, profile, metadata) {
|
|
2788
|
+
const unknown = (reason) => new PaymentOutcomeUnknownError(authorizationId, reason);
|
|
2789
|
+
if (metadata.executionMode === 'autopilot')
|
|
2790
|
+
throw unknown('autopilot_submission_mode_invalid');
|
|
2791
|
+
const named = s.merchant_card_endpoint;
|
|
2792
|
+
if (!isRecord(named) || named.profile !== profile)
|
|
2793
|
+
throw unknown('merchant_answer_malformed');
|
|
2794
|
+
if (named.answer_expired === true)
|
|
2795
|
+
throw unknown('merchant_answer_expired');
|
|
2796
|
+
const answer = named.answer;
|
|
2797
|
+
if (!isRecord(answer) || !Number.isInteger(answer.status) || answer.status < 100 || answer.status > 599)
|
|
2798
|
+
throw unknown('merchant_answer_malformed');
|
|
2799
|
+
const base = { mode: CARD_ENDPOINT_MODE, authorizationId, profile, status: answer.status, ...metadata };
|
|
2800
|
+
if (answer.withheld !== undefined) {
|
|
2801
|
+
if (answer.withheld !== 'card_in_response' && answer.withheld !== 'unreadable_in_response' && answer.withheld !== 'refused_step_in_response') {
|
|
2802
|
+
throw unknown('merchant_answer_malformed');
|
|
2803
|
+
}
|
|
2804
|
+
return { ...base, headers: {}, setCookies: [], body: '', withheld: answer.withheld };
|
|
2805
|
+
}
|
|
2806
|
+
const headers = answer.headers;
|
|
2807
|
+
const setCookies = answer.set_cookies;
|
|
2808
|
+
if (answer.status < 200 || typeof answer.bodyText !== 'string' || !isRecord(headers) || !Object.values(headers).every((value) => typeof value === 'string')
|
|
2809
|
+
|| !Array.isArray(setCookies) || !setCookies.every((value) => typeof value === 'string'))
|
|
2810
|
+
throw unknown('merchant_answer_malformed');
|
|
2811
|
+
return { ...base, headers: { ...headers }, setCookies: [...setCookies], body: answer.bodyText, withheld: null };
|
|
2812
|
+
}
|
|
1899
2813
|
function executionMetadata(state, authorizationId, previous = {}) {
|
|
1900
2814
|
if (state.execution_mode === undefined)
|
|
1901
2815
|
return previous;
|
|
@@ -1909,6 +2823,15 @@ function executionMetadata(state, authorizationId, previous = {}) {
|
|
|
1909
2823
|
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_changed');
|
|
1910
2824
|
return { executionMode: 'autopilot', grantId: state.grant_id };
|
|
1911
2825
|
}
|
|
2826
|
+
/**
|
|
2827
|
+
* What authorize() creates a Verifone authorization as: a cse payment that only a
|
|
2828
|
+
* prepared checkout may make, forwarding no header but content-type. Never matched
|
|
2829
|
+
* against a URL: authorize() uses it only for an armed Verifone profile's request.
|
|
2830
|
+
*/
|
|
2831
|
+
const VERIFONE_RECOGNIZER = Object.freeze({
|
|
2832
|
+
psp: 'verifone', match: /(?!)/, encoding: 'json', passthroughHeaders: [],
|
|
2833
|
+
clientSideEncrypted: true, mode: 'cse', preparationRequired: true,
|
|
2834
|
+
});
|
|
1912
2835
|
/**
|
|
1913
2836
|
* What authorize() creates a merchant-hosted authorization as: an Adyen cse
|
|
1914
2837
|
* payment that only a prepared checkout may make (so an unprepared request is
|
|
@@ -1939,6 +2862,51 @@ function assertMerchantHostedRequest(input, profile) {
|
|
|
1939
2862
|
if (verdict.verdict === 'pass')
|
|
1940
2863
|
throw new Error(`not a card request Agentcard reviewed for ${profile.merchant}.`);
|
|
1941
2864
|
}
|
|
2865
|
+
/**
|
|
2866
|
+
* Refuse, before any create or prompt, a Verifone request this client cannot pay: a profile
|
|
2867
|
+
* Agentcard has not turned on (observe), Autopilot (the cardholder approves every one), or a
|
|
2868
|
+
* request its rules do not pause (anything but its reviewed card request).
|
|
2869
|
+
*/
|
|
2870
|
+
function assertVerifoneRequest(input, profile) {
|
|
2871
|
+
if (profile.status !== 'enabled') {
|
|
2872
|
+
throw new Error(`Agentcard has not turned on payments to ${profile.merchant} yet. Nothing was charged.`);
|
|
2873
|
+
}
|
|
2874
|
+
if (input.executionMode === 'autopilot' || input.grantId !== undefined) {
|
|
2875
|
+
throw new Error(`A payment to ${profile.merchant} needs the cardholder's approval every time; Autopilot cannot pay it.`);
|
|
2876
|
+
}
|
|
2877
|
+
const verdict = classifyVerifoneProfileRequest(profile, input.request.url, input.request.method ?? 'POST', input.request.body);
|
|
2878
|
+
if (verdict.verdict === 'abort')
|
|
2879
|
+
throw new Error(`Refusing to pay ${profile.merchant}: ${verdict.reason}. Nothing was charged.`);
|
|
2880
|
+
if (verdict.verdict === 'pass')
|
|
2881
|
+
throw new Error(`not a card request Agentcard reviewed for ${profile.merchant}.`);
|
|
2882
|
+
}
|
|
2883
|
+
/**
|
|
2884
|
+
* The approved read of a Verifone authorization, as the replay the adapters continue: the
|
|
2885
|
+
* device's blob for the profile (`kind: 'verifone'`), beside the profile and the SHA-256 of the
|
|
2886
|
+
* body the API checked at create. Anything else, a create that was not Verifone's, another
|
|
2887
|
+
* profile, or a blob the API no longer serves (`substitutions_expired`) is an unknown outcome:
|
|
2888
|
+
* the device may already have encrypted the card, and nothing here can pay with it.
|
|
2889
|
+
*/
|
|
2890
|
+
function verifoneApproval(s, authorizationId, profile) {
|
|
2891
|
+
const malformed = () => new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
|
|
2892
|
+
if (!profile)
|
|
2893
|
+
throw malformed();
|
|
2894
|
+
if (s.substitutions_expired === true)
|
|
2895
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'substitutions_expired');
|
|
2896
|
+
const served = s.verifone;
|
|
2897
|
+
if (!isVerifoneSubstitution(s.substitutions, profile.id) || !isRecord(served) || served.profile !== profile.id
|
|
2898
|
+
|| typeof served.body_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(served.body_sha256))
|
|
2899
|
+
throw malformed();
|
|
2900
|
+
return {
|
|
2901
|
+
mode: 'cse',
|
|
2902
|
+
kind: 'verifone',
|
|
2903
|
+
authorizationId,
|
|
2904
|
+
profile: profile.id,
|
|
2905
|
+
substitutions: { encoding: 'json', kind: 'verifone', profile: profile.id, blob: s.substitutions.blob },
|
|
2906
|
+
bodySha256: served.body_sha256,
|
|
2907
|
+
substitutionsExpiresAt: typeof s.substitutions_expires_at === 'string' ? s.substitutions_expires_at : null,
|
|
2908
|
+
};
|
|
2909
|
+
}
|
|
1942
2910
|
const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
1943
2911
|
const FORBIDDEN_STEPS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
1944
2912
|
/**
|