@agent-cards/checkout 0.17.0 → 0.19.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 +16 -0
- package/PREFLIGHT.md +4 -0
- package/README.md +106 -14
- package/dist/adyen-merchant-hosted.generated.d.ts +277 -0
- package/dist/adyen-merchant-hosted.generated.js +1902 -0
- package/dist/builtin-registry.generated.js +1 -1
- package/dist/card-fields.generated.d.ts +3 -0
- package/dist/card-fields.generated.js +46 -0
- package/dist/cdp.d.ts +6 -1
- package/dist/cdp.js +574 -220
- package/dist/client.d.ts +285 -5
- package/dist/client.js +590 -12
- package/dist/cse-body.d.ts +25 -0
- package/dist/cse-body.js +41 -0
- package/dist/fiserv.d.ts +65 -0
- package/dist/fiserv.generated.d.ts +73 -0
- package/dist/fiserv.generated.js +830 -0
- package/dist/fiserv.js +104 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +5 -1
- package/dist/lifecycle.d.ts +38 -1
- package/dist/lifecycle.js +77 -5
- package/dist/merchant-handoff.d.ts +54 -0
- package/dist/merchant-handoff.js +100 -0
- package/dist/merchant-hosted.d.ts +140 -0
- package/dist/merchant-hosted.js +170 -0
- package/dist/merchant-total-watch.d.ts +115 -0
- package/dist/merchant-total-watch.js +268 -0
- package/dist/merchant-total.d.ts +257 -0
- package/dist/merchant-total.js +383 -0
- package/dist/pre-claim.d.ts +123 -0
- package/dist/pre-claim.js +386 -0
- package/dist/preflight-capabilities.generated.d.ts +1 -1
- package/dist/preflight-capabilities.generated.js +1 -1
- package/dist/preflight-catalog.json +133 -1
- package/dist/preflight-schemas.json +14 -2
- package/dist/preflight.generated.d.ts +1 -1
- package/dist/preflight.generated.js +15 -1
- package/dist/preparation.d.ts +13 -0
- package/dist/preparation.js +46 -9
- package/dist/prepared-processor.d.ts +36 -3
- package/dist/prepared-processor.js +53 -3
- package/dist/registry.d.ts +60 -0
- package/dist/registry.js +14 -0
- package/dist/stripe-checkout.generated.js +140 -20
- package/dist/substitutions.generated.d.ts +2 -1
- package/dist/substitutions.generated.js +758 -6
- package/examples/preflight/kernel-native/inventory.json +1 -1
- package/package.json +3 -3
package/dist/client.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, } from './registry.js';
|
|
1
|
+
import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, recognizerRequiresPreparation, } from './registry.js';
|
|
2
|
+
import { armServedProfiles, classifyProfileRequest, declareSandboxMerchant, merchantProfileEnvironment, merchantProfileFor, merchantProfileUrlPatterns, } from './merchant-hosted.js';
|
|
3
|
+
import { merchantAmountExchanges, merchantAmountFromResponses, merchantProfilePricedBySource, merchantTotalWire, MerchantTotalWait, } from './merchant-total.js';
|
|
2
4
|
import { isMercadoTokenRequest, parseMercadoCheckoutContext, validateMercadoProcessorContext } from './mercado-checkout.generated.js';
|
|
3
|
-
import {
|
|
5
|
+
import { matchesPreparation, preparationMode, validPreparationEnvironment } from './prepared-processor.js';
|
|
6
|
+
import { fiservKeyPinById, isFiservSubstitution, requiresFiservPreparation } from './fiserv.js';
|
|
4
7
|
import { hasOwnedShopMarker, parseOwnedShopOrder, parseOwnedShopReceipt } from './owned-shop.generated.js';
|
|
8
|
+
import { requestWithoutCard } from './card-fields.generated.js';
|
|
5
9
|
import { classifyStripeCheckoutRequest, encodeStripeCheckoutContext, hasStripeCheckoutMarker, parseStripeCheckoutContext, parseStripeCheckoutResponse, STRIPE_CHECKOUT_CONTEXT_HEADER, } from './stripe-checkout.generated.js';
|
|
6
10
|
/**
|
|
7
11
|
* The modes this SDK can finish. Asked for on syncRegistry (the API serves
|
|
@@ -9,6 +13,27 @@ import { classifyStripeCheckoutRequest, encodeStripeCheckoutContext, hasStripeCh
|
|
|
9
13
|
* is never paused) and sent on every create.
|
|
10
14
|
*/
|
|
11
15
|
export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
|
|
16
|
+
/**
|
|
17
|
+
* Registry features this SDK honours, asked for on syncRegistry next to the
|
|
18
|
+
* modes. `card_fields`: it reads a recognizer's cardFields and claims only a
|
|
19
|
+
* request whose body carries the card, so the API may serve it recognizers
|
|
20
|
+
* whose endpoints also run without a card. `checkout_sessions`: it lets a
|
|
21
|
+
* request the recognizer marks as routine without a card (passWithoutCard)
|
|
22
|
+
* through ahead of its holds, so the API may serve Stripe's Checkout Session
|
|
23
|
+
* confirm, which hosted Checkout sends after an approval.
|
|
24
|
+
*/
|
|
25
|
+
export const SUPPORTED_REGISTRY_FEATURES = ['card_fields', 'checkout_sessions'];
|
|
26
|
+
/**
|
|
27
|
+
* Dark-launch capabilities this build can finish. Sent on syncRegistry as
|
|
28
|
+
* ?capabilities= so the API also serves the recognizers gated behind one of
|
|
29
|
+
* these. `fiserv_card_capture`: Fiserv Commerce Hub's Secure Data Capture card
|
|
30
|
+
* capture, which this build pauses and pays only through a prepared checkout
|
|
31
|
+
* (pre-claim.ts, fiserv.ts). The API serves Fiserv's recognizer only to a build
|
|
32
|
+
* that lists it and only for a company Agentcard turned Fiserv on for, so an older
|
|
33
|
+
* SDK never pauses a capture it cannot finish and no SDK pauses one for a company
|
|
34
|
+
* Fiserv is off for; the API also refuses a Fiserv payment for every such company.
|
|
35
|
+
*/
|
|
36
|
+
export const SUPPORTED_CAPABILITIES = ['fiserv_card_capture'];
|
|
12
37
|
/** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
|
|
13
38
|
export function validAmountInput(amount) {
|
|
14
39
|
if (typeof amount === 'number')
|
|
@@ -48,6 +73,22 @@ export class UnsupportedModeError extends Error {
|
|
|
48
73
|
this.name = 'UnsupportedModeError';
|
|
49
74
|
}
|
|
50
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The recognizer says this processor is preparation-required, and no preparation
|
|
78
|
+
* was passed. Its card may be sent only after the cardholder approved a
|
|
79
|
+
* prepare(): call prepareCheckout() (or the adapters' preparation gate) before
|
|
80
|
+
* this request is intercepted. Thrown locally before any create or prompt, and
|
|
81
|
+
* terminal (retrying the same paused request without preparing fails the same
|
|
82
|
+
* way). The API answers 409 preparation_required for the same case.
|
|
83
|
+
*/
|
|
84
|
+
export class PreparationRequiredError extends Error {
|
|
85
|
+
psp;
|
|
86
|
+
constructor(psp) {
|
|
87
|
+
super(`${psp} requires a prepared checkout: run prepareCheckout() and have the cardholder approve it before this request is taken over. Nothing was charged.`);
|
|
88
|
+
this.psp = psp;
|
|
89
|
+
this.name = 'PreparationRequiredError';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
51
92
|
export class ApprovalTimeoutError extends Error {
|
|
52
93
|
constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
|
|
53
94
|
}
|
|
@@ -85,6 +126,13 @@ export class ApprovalDeclinedError extends Error {
|
|
|
85
126
|
* and quiet the page's retry exactly as for a person's "no"), so it extends
|
|
86
127
|
* ApprovalDeclinedError: code that already handles declines keeps working,
|
|
87
128
|
* and code that wants the numbers reads them here or branches on `code`.
|
|
129
|
+
*
|
|
130
|
+
* A merchant-hosted checkout (an Adyen merchant whose own server charges the card)
|
|
131
|
+
* has no processor read-back: its agent amount is held to the merchant's own
|
|
132
|
+
* checkout total, as the agent's browser read it, at stage 'create'. The SDK refuses
|
|
133
|
+
* that before any create, and the API refuses the same with 409 `amount_mismatch`, so a
|
|
134
|
+
* caller catches this one class either way; `amountSource` says whose number
|
|
135
|
+
* `actualCents` is, and the message names it.
|
|
88
136
|
*/
|
|
89
137
|
export class AmountMismatchError extends ApprovalDeclinedError {
|
|
90
138
|
authorizationId;
|
|
@@ -93,20 +141,27 @@ export class AmountMismatchError extends ApprovalDeclinedError {
|
|
|
93
141
|
currency;
|
|
94
142
|
actualCurrency;
|
|
95
143
|
stage;
|
|
144
|
+
amountSource;
|
|
96
145
|
code = 'amount_mismatch';
|
|
97
146
|
constructor(
|
|
98
147
|
/** The declined authorization, or null for a create-time refusal (no row exists). */
|
|
99
148
|
authorizationId,
|
|
100
149
|
/** What the user was asked to approve, smallest currency unit. */
|
|
101
150
|
expectedCents,
|
|
102
|
-
/** What the processor reported at the last check. */
|
|
151
|
+
/** What the processor (or the merchant, see `amountSource`) reported at the last check. */
|
|
103
152
|
actualCents,
|
|
104
153
|
/** ISO 4217 of the approved amount. */
|
|
105
154
|
currency,
|
|
106
155
|
/** ISO 4217 the processor reported (differs only on a currency change). */
|
|
107
156
|
actualCurrency = currency,
|
|
108
157
|
/** Which check refused it. */
|
|
109
|
-
stage = 'pre_replay'
|
|
158
|
+
stage = 'pre_replay',
|
|
159
|
+
/**
|
|
160
|
+
* Whose number `actualCents` is: the processor's ('processor'), a merchant-hosted
|
|
161
|
+
* merchant's own checkout total ('merchant_total'), or the amount its card request
|
|
162
|
+
* names ('merchant_request').
|
|
163
|
+
*/
|
|
164
|
+
amountSource = 'processor') {
|
|
110
165
|
super('amount_mismatch');
|
|
111
166
|
this.authorizationId = authorizationId;
|
|
112
167
|
this.expectedCents = expectedCents;
|
|
@@ -114,9 +169,12 @@ export class AmountMismatchError extends ApprovalDeclinedError {
|
|
|
114
169
|
this.currency = currency;
|
|
115
170
|
this.actualCurrency = actualCurrency;
|
|
116
171
|
this.stage = stage;
|
|
172
|
+
this.amountSource = amountSource;
|
|
117
173
|
this.name = 'AmountMismatchError';
|
|
174
|
+
const reported = amountSource === 'merchant_total' ? 'the merchant\'s own checkout total is'
|
|
175
|
+
: amountSource === 'merchant_request' ? 'the merchant\'s card request names' : 'the processor reports';
|
|
118
176
|
this.message = `amount mismatch${authorizationId ? ` on ${authorizationId}` : ' at create'}: `
|
|
119
|
-
+ `the user was asked to approve ${expectedCents} ${currency},
|
|
177
|
+
+ `the user was asked to approve ${expectedCents} ${currency}, ${reported} ${actualCents} ${actualCurrency}. Nothing was charged.`;
|
|
120
178
|
}
|
|
121
179
|
}
|
|
122
180
|
/**
|
|
@@ -184,6 +242,69 @@ export class ProcessorRefusedError extends ApprovalDeclinedError {
|
|
|
184
242
|
this.message = `the processor rejected the payment request on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Check the merchant payment status before retrying.`;
|
|
185
243
|
}
|
|
186
244
|
}
|
|
245
|
+
const ADYEN_TEST_PLATFORM_REFUSALS = ['adyen_test_environment_refused', 'adyen_test_platform_requires_documented_test_card'];
|
|
246
|
+
/**
|
|
247
|
+
* Agentcard refused a payment on Adyen's TEST platform, where a test account can
|
|
248
|
+
* read whatever is encrypted under its key. Nothing was encrypted and nothing was
|
|
249
|
+
* charged. `code` says which rule:
|
|
250
|
+
* - 'adyen_test_environment_refused': a live checkout names Adyen's test host or
|
|
251
|
+
* a `test_` client key. Stage 'create': the API refused it before anyone was
|
|
252
|
+
* asked (`authorizationId` is null) and the page's next request is refused the
|
|
253
|
+
* same way, so the adapters stop intercepting. Stage 'pre_replay': the
|
|
254
|
+
* authorization was declined right before the card would have been encrypted.
|
|
255
|
+
* - 'adyen_test_platform_requires_documented_test_card': a test-mode checkout on
|
|
256
|
+
* Adyen's test platform, where the approval page encrypts only Adyen's
|
|
257
|
+
* documented test cards and the cardholder's card is not one.
|
|
258
|
+
* A decline in every structural sense, so it extends ApprovalDeclinedError.
|
|
259
|
+
*/
|
|
260
|
+
export class AdyenTestPlatformRefusedError extends ApprovalDeclinedError {
|
|
261
|
+
authorizationId;
|
|
262
|
+
code;
|
|
263
|
+
stage;
|
|
264
|
+
constructor(authorizationId, code, stage) {
|
|
265
|
+
super(code);
|
|
266
|
+
this.authorizationId = authorizationId;
|
|
267
|
+
this.code = code;
|
|
268
|
+
this.stage = stage;
|
|
269
|
+
this.name = 'AdyenTestPlatformRefusedError';
|
|
270
|
+
this.message = code === 'adyen_test_environment_refused'
|
|
271
|
+
? `Adyen's test platform cannot take this live payment${authorizationId ? ` (${authorizationId} was declined)` : '; no authorization was created'}: the checkout names Adyen's test host or a test_ client key. Nothing was encrypted or charged.`
|
|
272
|
+
: `this checkout is on Adyen's test platform, which takes only Adyen's documented test cards${authorizationId ? ` (${authorizationId} was declined)` : ''}. Nothing was encrypted or charged.`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* A reviewed merchant's own checkout total could not stand behind this payment, so the
|
|
277
|
+
* card request was held and nothing was charged. The merchant's server picks what it
|
|
278
|
+
* charges, so a merchant-hosted payment is priced by the total the merchant's own
|
|
279
|
+
* checkout responses named in this browser (the profile's amount source), never by the
|
|
280
|
+
* agent's number alone. `code`:
|
|
281
|
+
* - 'merchant_total_required': no total was sent (stage 'sdk': this runtime recorded no
|
|
282
|
+
* merchant responses; stage 'create': the API got none);
|
|
283
|
+
* - 'merchant_total_refused': the responses do not confirm one total for this order
|
|
284
|
+
* (`reasonCode`: no_source when none was seen, stale, refused, unreadable,
|
|
285
|
+
* mismatch, unbound, invalid); stage 'sdk' before any create, 'create' by the API;
|
|
286
|
+
* - 'merchant_total_changed': stage 'release': between the approval and the moment the
|
|
287
|
+
* card would have gone out, the merchant's total moved, or a request that could move
|
|
288
|
+
* it had not answered. The approval is retired; the card never left this browser;
|
|
289
|
+
* - 'merchant_total_stale': stage 'runtime': the approval came after the total was too
|
|
290
|
+
* old for its profile, so the API withheld the card.
|
|
291
|
+
* A decline in every structural sense, so it extends ApprovalDeclinedError.
|
|
292
|
+
*/
|
|
293
|
+
export class MerchantTotalError extends ApprovalDeclinedError {
|
|
294
|
+
authorizationId;
|
|
295
|
+
code;
|
|
296
|
+
stage;
|
|
297
|
+
reasonCode;
|
|
298
|
+
constructor(authorizationId, code, stage, reasonCode, reason) {
|
|
299
|
+
super(code);
|
|
300
|
+
this.authorizationId = authorizationId;
|
|
301
|
+
this.code = code;
|
|
302
|
+
this.stage = stage;
|
|
303
|
+
this.reasonCode = reasonCode;
|
|
304
|
+
this.name = 'MerchantTotalError';
|
|
305
|
+
this.message = `${reason}${authorizationId ? ` (${authorizationId})` : ''}. Nothing was charged.`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
187
308
|
/**
|
|
188
309
|
* A non-2xx from the Agentcard API, carrying the status so callers can tell a
|
|
189
310
|
* misconfiguration from a blip. The adapters use this to decide whether
|
|
@@ -353,6 +474,35 @@ export function redactUrl(raw) {
|
|
|
353
474
|
return raw.split(/[?#]/)[0];
|
|
354
475
|
}
|
|
355
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Refusals a preparation create answers before any preparation exists, surfaced
|
|
479
|
+
* as CheckoutPreparationError reasons: Adyen's test platform on a live client, and
|
|
480
|
+
* a merchant profile Agentcard has not turned on, does not offer this account, or
|
|
481
|
+
* whose key or checkout page is not the one it reviewed.
|
|
482
|
+
*/
|
|
483
|
+
const PREPARATION_REFUSALS = [
|
|
484
|
+
'adyen_test_environment_refused', 'unsupported_checkout', 'merchant_profile_unavailable',
|
|
485
|
+
'merchant_profile_key_changed', 'unknown_merchant_profile', 'unsupported_payment_endpoint',
|
|
486
|
+
// A sandbox declaration from a live client or an org without declarations turned on,
|
|
487
|
+
// or one the API cannot pay (sandboxMerchants).
|
|
488
|
+
'sandbox_declaration_refused', 'sandbox_declaration_invalid',
|
|
489
|
+
];
|
|
490
|
+
/**
|
|
491
|
+
* Refusals a Fiserv preparation create answers (4xx) before any preparation exists, surfaced as
|
|
492
|
+
* the CheckoutPreparationError reason so the caller can tell them apart: the processor is
|
|
493
|
+
* not turned on for this company (`processor_unavailable`), Agentcard has not turned on
|
|
494
|
+
* payments to the merchant the key belongs to (`unsupported_checkout`), the merchant's
|
|
495
|
+
* published key is no longer the one Agentcard reviewed (`merchant_profile_key_changed`),
|
|
496
|
+
* or the key or environment named does not fit the request or the client's test or live
|
|
497
|
+
* mode. A 502 `cse_key_unavailable` (the merchant's published key could not be read just
|
|
498
|
+
* now; a new prepare() may pass) is surfaced the same way. Only for a Fiserv checkout;
|
|
499
|
+
* any other failure, and every other processor's refusal that PREPARATION_REFUSALS does
|
|
500
|
+
* not name, stays `preparation_unconfirmed`.
|
|
501
|
+
*/
|
|
502
|
+
const FISERV_PREPARATION_REFUSALS = [
|
|
503
|
+
'processor_unavailable', 'unsupported_checkout', 'merchant_profile_required', 'unknown_merchant_profile',
|
|
504
|
+
'merchant_profile_key_changed', 'fiserv_key_refused', 'sandbox_host_on_live_row', 'live_host_on_sandbox_row',
|
|
505
|
+
];
|
|
356
506
|
/** Default backoff for a 502 amount_unverifiable at create: two retries, then give up. */
|
|
357
507
|
const UNVERIFIABLE_RETRY_DELAYS_MS = [500, 1500];
|
|
358
508
|
const PREPARATION_READ_RETRY_DELAYS_MS = [250, 500];
|
|
@@ -373,6 +523,13 @@ export class VaultClient {
|
|
|
373
523
|
pollIntervalMs;
|
|
374
524
|
unverifiableRetryDelaysMs;
|
|
375
525
|
registry;
|
|
526
|
+
/**
|
|
527
|
+
* The reviewed merchant profiles this client arms: every one this build reviewed,
|
|
528
|
+
* in 'observe' until a sync reads that the API enabled it (armServedProfiles).
|
|
529
|
+
*/
|
|
530
|
+
merchantProfiles = armServedProfiles();
|
|
531
|
+
/** This client's own sandbox declarations (sandboxMerchants), armed at construction, one per profile. */
|
|
532
|
+
declaredProfiles;
|
|
376
533
|
preparations = new WeakSet();
|
|
377
534
|
usedPreparations = new WeakSet();
|
|
378
535
|
constructor(opts) {
|
|
@@ -382,6 +539,11 @@ export class VaultClient {
|
|
|
382
539
|
this.registry = opts.registry ?? BUILTIN_REGISTRY;
|
|
383
540
|
this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
|
|
384
541
|
this.unverifiableRetryDelaysMs = opts.unverifiableRetryDelaysMs ?? UNVERIFIABLE_RETRY_DELAYS_MS;
|
|
542
|
+
const declared = (opts.sandboxMerchants ?? []).map(declareSandboxMerchant);
|
|
543
|
+
if (new Set(declared.map((profile) => profile.id)).size !== declared.length) {
|
|
544
|
+
throw new TypeError('sandboxMerchants: declare each profile at most once.');
|
|
545
|
+
}
|
|
546
|
+
this.declaredProfiles = Object.freeze(declared);
|
|
385
547
|
}
|
|
386
548
|
/** Refresh recognizers from the API so new PSPs work without a redeploy. */
|
|
387
549
|
async syncRegistry() {
|
|
@@ -394,24 +556,79 @@ export class VaultClient {
|
|
|
394
556
|
// abort, which would dead-end the checkout). `mode` rides through the
|
|
395
557
|
// spread verbatim.
|
|
396
558
|
let raw;
|
|
559
|
+
// ?capabilities= is the dark-launch half of the same negotiation: a
|
|
560
|
+
// processor gated behind a capability is served only when this build lists
|
|
561
|
+
// it. This build lists fiserv_card_capture, so it arms Fiserv's card capture
|
|
562
|
+
// for a company Agentcard turned Fiserv on for; an older build, and every
|
|
563
|
+
// other company's, never sees that recognizer.
|
|
564
|
+
const capabilities = SUPPORTED_CAPABILITIES.length ? `&capabilities=${SUPPORTED_CAPABILITIES.join(',')}` : '';
|
|
565
|
+
// merchant_profiles=1 asks for the reviewed Adyen merchant profiles too, each
|
|
566
|
+
// as a `{ merchant_profile }` entry after the recognizers; an API that
|
|
567
|
+
// predates them serves none. Every profile this build reviewed stays armed in
|
|
568
|
+
// 'observe' through a failed sync or an answer without profiles, and turns
|
|
569
|
+
// 'enabled' only when the API serves it enabled with this build's own rules
|
|
570
|
+
// (merchant-hosted.ts armServedProfiles).
|
|
397
571
|
try {
|
|
398
|
-
raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}`);
|
|
572
|
+
raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}&features=${SUPPORTED_REGISTRY_FEATURES.join(',')}${capabilities}&merchant_profiles=1`);
|
|
399
573
|
}
|
|
400
574
|
catch {
|
|
401
575
|
return;
|
|
402
576
|
}
|
|
403
577
|
if (!Array.isArray(raw))
|
|
404
578
|
return;
|
|
405
|
-
|
|
579
|
+
const isProfile = (e) => !!e && typeof e === 'object' && 'merchant_profile' in e;
|
|
580
|
+
this.registry = raw.filter((e) => !isProfile(e)).map((e) => ({
|
|
406
581
|
...e,
|
|
407
582
|
match: new RegExp(e.match, 'i'),
|
|
408
583
|
passthroughHeaders: e.passthroughHeaders.map((h) => new RegExp(h, 'i')),
|
|
409
584
|
}));
|
|
585
|
+
this.merchantProfiles = armServedProfiles(raw.filter(isProfile));
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* The reviewed Adyen merchant profile whose card endpoint (or a sibling of it,
|
|
589
|
+
* the same path under another query) this URL is, with its status on this
|
|
590
|
+
* client; null for any other URL. Every profile this build reviewed is armed,
|
|
591
|
+
* in 'observe' until a sync reads that the API enabled it. The adapters pause
|
|
592
|
+
* these requests and judge them with classifyMerchantRequest. A raw runtime
|
|
593
|
+
* that pauses one must never continue a card body there unless authorize()
|
|
594
|
+
* paid it.
|
|
595
|
+
*/
|
|
596
|
+
merchantProfileOf(url) {
|
|
597
|
+
return merchantProfileFor([...this.declaredProfiles, ...this.merchantProfiles], url);
|
|
598
|
+
}
|
|
599
|
+
/** The armed profile with this id, or null. A profile id this client declared (sandboxMerchants) names its declaration, in place of the reviewed profile. */
|
|
600
|
+
merchantProfile(id) {
|
|
601
|
+
return this.declaredProfiles.find((profile) => profile.id === id)
|
|
602
|
+
?? this.merchantProfiles.find((profile) => profile.id === id) ?? null;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Fetch.enable globs for every armed merchant profile's endpoint and its
|
|
606
|
+
* siblings, for a raw CDP runtime to arm beside cardUrlPatterns(). Every profile
|
|
607
|
+
* this build reviewed is armed from the start, so these are the same before and
|
|
608
|
+
* after syncRegistry; a sync changes only a profile's status.
|
|
609
|
+
*/
|
|
610
|
+
merchantProfileUrlPatterns() {
|
|
611
|
+
return merchantProfileUrlPatterns([...this.declaredProfiles, ...this.merchantProfiles]);
|
|
410
612
|
}
|
|
411
613
|
/** True when this request is a card tokenization we can take over. */
|
|
412
614
|
isCardRequest(url, method = 'POST') {
|
|
413
615
|
return method.toUpperCase() === 'POST' && findRecognizer(url, this.registry) !== null;
|
|
414
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* What happens to a card request (by URL) whose body carries no card: null
|
|
619
|
+
* when it carries one, so it is a card request as usual; 'continue' when the
|
|
620
|
+
* recognizer marks the endpoint as one where such requests are routine and
|
|
621
|
+
* never ours; 'refuse' otherwise, such as a Stripe confirmation paying with
|
|
622
|
+
* a method this checkout never approved. The adapters ask only after their
|
|
623
|
+
* holds, so a request that would reuse an approved token is refused there
|
|
624
|
+
* first. A body that could not be read is not judged here.
|
|
625
|
+
*/
|
|
626
|
+
withoutCard(url, body) {
|
|
627
|
+
if (body == null)
|
|
628
|
+
return null;
|
|
629
|
+
const rec = findRecognizer(url, this.registry);
|
|
630
|
+
return rec ? requestWithoutCard(rec, url, body) : null;
|
|
631
|
+
}
|
|
415
632
|
/**
|
|
416
633
|
* How the card would reach the processor on this request (`token`, `cse`
|
|
417
634
|
* or `hosted_form`; absent on the entry means `token`), or null when the
|
|
@@ -445,6 +662,35 @@ export class VaultClient {
|
|
|
445
662
|
const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
|
|
446
663
|
if (!validPreparationEnvironment(input.psp, input.environment))
|
|
447
664
|
throw fail('unsupported_processor');
|
|
665
|
+
// A merchant-hosted preparation pays one reviewed profile's card request: the
|
|
666
|
+
// profile must be armed here, enabled, and paid in its own environment.
|
|
667
|
+
// A Fiserv checkout names the key Agentcard pinned for its merchant, in that key's own
|
|
668
|
+
// environment. The API then names that key's merchant on the approval, and the capture
|
|
669
|
+
// this preparation pays must carry an envelope under that key (matchesPreparation).
|
|
670
|
+
let profile = null;
|
|
671
|
+
let fiservPin = null;
|
|
672
|
+
if (input.psp === 'fiserv') {
|
|
673
|
+
if (input.merchantProfile === undefined)
|
|
674
|
+
throw fail('merchant_profile_required');
|
|
675
|
+
fiservPin = fiservKeyPinById(input.merchantProfile);
|
|
676
|
+
if (!fiservPin)
|
|
677
|
+
throw fail('unknown_merchant_profile');
|
|
678
|
+
if (fiservPin.environment !== input.environment)
|
|
679
|
+
throw fail('unsupported_processor');
|
|
680
|
+
}
|
|
681
|
+
else if (input.merchantProfile !== undefined) {
|
|
682
|
+
profile = typeof input.merchantProfile === 'string' ? this.merchantProfile(input.merchantProfile) : null;
|
|
683
|
+
if (input.psp !== 'adyen')
|
|
684
|
+
throw fail('unsupported_processor');
|
|
685
|
+
if (!profile)
|
|
686
|
+
throw fail('processor_interception_unavailable');
|
|
687
|
+
if (profile.status !== 'enabled')
|
|
688
|
+
throw fail('unsupported_checkout');
|
|
689
|
+
// A declared test endpoint pays on Adyen's TEST platform: a sandbox preparation.
|
|
690
|
+
if ((profile.sandboxDeclaration ? 'sandbox' : merchantProfileEnvironment(profile.id)) !== input.environment)
|
|
691
|
+
throw fail('unsupported_processor');
|
|
692
|
+
}
|
|
693
|
+
const declaration = profile?.sandboxDeclaration;
|
|
448
694
|
if (!validAmountInput(input.amount) || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
|
|
449
695
|
throw fail('amount_required');
|
|
450
696
|
const origin = new URL(input.merchantOrigin);
|
|
@@ -452,6 +698,11 @@ export class VaultClient {
|
|
|
452
698
|
throw fail('merchant_origin_invalid');
|
|
453
699
|
if (!input.checkoutKey || !input.user || !input.merchant)
|
|
454
700
|
throw fail('checkout_context_required');
|
|
701
|
+
// The cardholder approves a payment to the merchant the key pin belongs to, so a Fiserv
|
|
702
|
+
// checkout names that merchant as well: the approval, the handle this returns and every
|
|
703
|
+
// authorization it pays then name one merchant, never a label the caller chose.
|
|
704
|
+
if (fiservPin && input.merchant !== fiservPin.merchant)
|
|
705
|
+
throw fail('merchant_profile_mismatch');
|
|
455
706
|
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
456
707
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
457
708
|
throw fail('timeout_invalid');
|
|
@@ -467,6 +718,9 @@ export class VaultClient {
|
|
|
467
718
|
user: input.user, merchant: input.merchant, amount: input.amount, currency: input.currency.toLowerCase(),
|
|
468
719
|
...(input.cardId ? { card_id: input.cardId } : {}), psp: input.psp, mode: preparationMode(input.psp),
|
|
469
720
|
environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
|
|
721
|
+
...(profile ? { merchant_profile: profile.id } : {}),
|
|
722
|
+
...(fiservPin ? { merchant_profile: fiservPin.id } : {}),
|
|
723
|
+
...(declaration ? { sandbox_declaration: { endpoint: declaration.endpoint, client_key: declaration.clientKey, environment: 'test' } } : {}),
|
|
470
724
|
}, AbortSignal.timeout(30_000), signal);
|
|
471
725
|
if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
|
|
472
726
|
throw fail('create_unconfirmed');
|
|
@@ -494,7 +748,12 @@ export class VaultClient {
|
|
|
494
748
|
const expiry = Date.parse(state.ready_expires_at);
|
|
495
749
|
if (!Number.isFinite(expiry) || expiry <= Date.now() || typeof state.card_id !== 'string' || !state.card_id
|
|
496
750
|
|| state.payment_status !== 'not_started' || state.amount_authority !== 'agent'
|
|
497
|
-
|
|
751
|
+
// A merchant-hosted preparation names the merchant its reviewed profile names, and
|
|
752
|
+
// a declared test endpoint names its host (declareSandboxMerchant), never the caller's label.
|
|
753
|
+
// A Fiserv preparation names the merchant its key pin belongs to (input.merchant, checked above), and that pin.
|
|
754
|
+
|| state.user !== input.user || state.merchant !== (profile ? profile.merchant : input.merchant) || state.merchant_origin !== input.merchantOrigin
|
|
755
|
+
|| state.merchant_profile !== (profile ? profile.id : fiservPin ? fiservPin.id : undefined)
|
|
756
|
+
|| !servesDeclaration(state.sandbox_declaration, declaration)
|
|
498
757
|
|| !Number.isSafeInteger(state.amount) || (typeof input.amount === 'number' && state.amount !== input.amount) || state.currency !== input.currency.toLowerCase()
|
|
499
758
|
|| state.psp !== input.psp || state.mode !== preparationMode(input.psp) || state.environment !== input.environment
|
|
500
759
|
|| state.checkout_key !== input.checkoutKey)
|
|
@@ -505,6 +764,9 @@ export class VaultClient {
|
|
|
505
764
|
amountDisplay: typeof state.amount_display === 'string' ? state.amount_display : null,
|
|
506
765
|
currency: input.currency.toLowerCase(), merchantOrigin: input.merchantOrigin, checkoutKey: input.checkoutKey,
|
|
507
766
|
paymentStatus: 'not_started', amountAuthority: 'agent',
|
|
767
|
+
...(profile ? { merchantProfile: profile.id } : {}),
|
|
768
|
+
...(fiservPin ? { merchantProfile: fiservPin.id } : {}),
|
|
769
|
+
...(declaration ? { sandboxDeclaration: declaration } : {}),
|
|
508
770
|
});
|
|
509
771
|
this.preparations.add(prepared);
|
|
510
772
|
ready = true;
|
|
@@ -519,6 +781,15 @@ export class VaultClient {
|
|
|
519
781
|
catch (error) {
|
|
520
782
|
if (error instanceof CheckoutPreparationError)
|
|
521
783
|
throw error;
|
|
784
|
+
// A refusal the API answered before any preparation existed names its rule: one
|
|
785
|
+
// PREPARATION_REFUSALS names for any checkout, and, for a Fiserv checkout only, one
|
|
786
|
+
// FISERV_PREPARATION_REFUSALS names or a 502 cse_key_unavailable. Every other
|
|
787
|
+
// processor's other refusals stay preparation_unconfirmed, as before.
|
|
788
|
+
if (!id && !signal.aborted && error instanceof CheckoutApiError && error.code
|
|
789
|
+
&& ((error.status >= 400 && error.status < 500 && (PREPARATION_REFUSALS.includes(error.code)
|
|
790
|
+
|| (input.psp === 'fiserv' && FISERV_PREPARATION_REFUSALS.includes(error.code))))
|
|
791
|
+
|| (input.psp === 'fiserv' && error.status === 502 && error.code === 'cse_key_unavailable')))
|
|
792
|
+
throw fail(error.code);
|
|
522
793
|
throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
|
|
523
794
|
}
|
|
524
795
|
finally {
|
|
@@ -534,6 +805,26 @@ export class VaultClient {
|
|
|
534
805
|
if (state?.id !== id || !['cancelled', 'expired'].includes(state.status))
|
|
535
806
|
throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
|
|
536
807
|
}
|
|
808
|
+
async observePreparation(id, guidance, reason) {
|
|
809
|
+
try {
|
|
810
|
+
await this.post(`/v2/checkout/preparations/${id}/observe`, {
|
|
811
|
+
form_guidance: guidance, ...(reason ? { reason } : {}),
|
|
812
|
+
}, AbortSignal.timeout(5_000));
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
this.opts.onEvent?.({ type: 'telemetry_skipped', detail: 'form_guidance' });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
async reportDuplicateGuard(authorizationId) {
|
|
819
|
+
try {
|
|
820
|
+
await this.post(`/v2/checkout/authorizations/${authorizationId}/duplicate-guard`, {
|
|
821
|
+
guard: 'hosted_form_repeat',
|
|
822
|
+
}, AbortSignal.timeout(5_000));
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
this.opts.onEvent?.({ type: 'telemetry_skipped', detail: 'duplicate_guard' });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
537
828
|
/**
|
|
538
829
|
* Hand us a paused tokenization request. We ask the cardholder to approve,
|
|
539
830
|
* their device supplies the card and calls the merchant, and you get back the
|
|
@@ -591,14 +882,33 @@ export class VaultClient {
|
|
|
591
882
|
throw new CheckoutPreparationError(preparation.id, 'expired');
|
|
592
883
|
if (input.user !== preparation.user || input.merchant !== preparation.merchant || (typeof input.amount === 'number' && input.amount !== preparation.amount) || (typeof input.amount === 'string' && !validAmountInput(input.amount))
|
|
593
884
|
|| input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
|
|
594
|
-
|| !
|
|
885
|
+
|| !matchesPreparation(preparation, input.request.url, input.request.method ?? 'POST', input.request.body, input.request.headers))
|
|
595
886
|
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
596
887
|
}
|
|
597
888
|
if (input.signal?.aborted)
|
|
598
889
|
throw new CheckoutCancelledError();
|
|
599
890
|
if (input.merchantSignal?.aborted)
|
|
600
891
|
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
601
|
-
|
|
892
|
+
// An armed merchant profile's endpoint (merchant-hosted.ts): the reviewed
|
|
893
|
+
// merchant's own server, never a processor host, so no recognizer matches it.
|
|
894
|
+
const merchantProfile = nativeCheckout || ownedShop || mercadoContext ? null : this.merchantProfileOf(input.request.url);
|
|
895
|
+
// A preparation pays the profile it was made for and nothing else: a
|
|
896
|
+
// merchant-hosted one never a processor's request or another profile's (two
|
|
897
|
+
// profiles could share an endpoint), and a processor-hosted one never a
|
|
898
|
+
// merchant's own endpoint. Checked here, not left to the matcher above, so the
|
|
899
|
+
// create below can only ever name the profile the cardholder prepared. A Fiserv
|
|
900
|
+
// preparation names a key pin, not a reviewed profile (matchesPreparation binds it).
|
|
901
|
+
if (preparation && (((preparation.psp === 'fiserv' ? null : preparation.merchantProfile) ?? null) !== (merchantProfile?.id ?? null)
|
|
902
|
+
|| (preparation.sandboxDeclaration?.endpoint ?? null) !== (merchantProfile?.sandboxDeclaration?.endpoint ?? null))) {
|
|
903
|
+
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
904
|
+
}
|
|
905
|
+
// A profile a later sync holds in 'observe' no longer pays the preparation made for it.
|
|
906
|
+
if (preparation && merchantProfile && merchantProfile.status !== 'enabled') {
|
|
907
|
+
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
908
|
+
}
|
|
909
|
+
if (merchantProfile)
|
|
910
|
+
assertMerchantHostedRequest(input, merchantProfile);
|
|
911
|
+
const rec = merchantProfile ? MERCHANT_HOSTED_RECOGNIZER : nativeCheckout ? this.registry.find(entry => entry.psp === 'stripe' && (entry.mode ?? 'token') === 'token')
|
|
602
912
|
: findRecognizer(input.request.url, this.registry);
|
|
603
913
|
if (!rec)
|
|
604
914
|
throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
|
|
@@ -609,6 +919,22 @@ export class VaultClient {
|
|
|
609
919
|
const mode = rec.mode ?? 'token';
|
|
610
920
|
if (preparation && (rec.psp !== preparation.psp || mode !== preparationMode(preparation.psp)))
|
|
611
921
|
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
922
|
+
// A preparation-required processor may not be taken over on an ordinary
|
|
923
|
+
// authorization: the cardholder must have approved a prepare() first. Refuse
|
|
924
|
+
// locally, before any create or prompt, when none is active, through the
|
|
925
|
+
// same strict rule the API applies (recognizerRequiresPreparation). Fiserv
|
|
926
|
+
// sets it; the API serves Fiserv only on a sync that declares its capability,
|
|
927
|
+
// and only to a company Agentcard turned Fiserv on for. This build carries
|
|
928
|
+
// Fiserv's own rules, so it also requires the preparation for a Fiserv
|
|
929
|
+
// request whatever the served entry says (requiresFiservPreparation): a
|
|
930
|
+
// registry that dropped the flag never opens an ordinary Fiserv authorization.
|
|
931
|
+
if ((recognizerRequiresPreparation(rec) || requiresFiservPreparation(rec.psp, input.request.url)) && !preparation)
|
|
932
|
+
throw new PreparationRequiredError(rec.psp);
|
|
933
|
+
// The merchant's server picks what it charges Adyen, so the agent's amount is
|
|
934
|
+
// what the API holds the body's own amount to: a merchant-hosted payment names one.
|
|
935
|
+
if (merchantProfile && input.amount == null) {
|
|
936
|
+
throw new Error(`A payment to ${merchantProfile.merchant} names its amount: pass amount and currency, the amount the cardholder approves.`);
|
|
937
|
+
}
|
|
612
938
|
if (rec.clientSideEncrypted && mode !== 'cse')
|
|
613
939
|
throw new CardEncryptedError(rec.psp);
|
|
614
940
|
if (!SUPPORTED_MODES.includes(mode))
|
|
@@ -640,6 +966,10 @@ export class VaultClient {
|
|
|
640
966
|
const deadline = Date.now() + timeoutMs;
|
|
641
967
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
642
968
|
const operationSignal = input.signal ? AbortSignal.any([input.signal, timeoutSignal]) : timeoutSignal;
|
|
969
|
+
// A reviewed profile priced by the merchant's own total: read it from what this browser
|
|
970
|
+
// recorded, and refuse before any create when it cannot stand behind this payment.
|
|
971
|
+
const priced = merchantProfile && merchantProfilePricedBySource(merchantProfile.id)
|
|
972
|
+
? await this.readMerchantTotal(input, merchantProfile, preparation, operationSignal) : null;
|
|
643
973
|
let created;
|
|
644
974
|
const payload = {
|
|
645
975
|
user: input.user,
|
|
@@ -647,6 +977,9 @@ export class VaultClient {
|
|
|
647
977
|
// snake_case on the wire; camelCase is this SDK's convention.
|
|
648
978
|
...(hasAmount ? { amount: input.amount, currency: input.currency } : {}),
|
|
649
979
|
...(input.pageAmount ? { page_amount: input.pageAmount.amount, page_currency: input.pageAmount.currency } : {}),
|
|
980
|
+
...(Number.isInteger(input.payToInterceptMs) && input.payToInterceptMs >= 0
|
|
981
|
+
? { pay_to_intercept_ms: input.payToInterceptMs }
|
|
982
|
+
: {}),
|
|
650
983
|
psp: rec.psp,
|
|
651
984
|
// The mode this request will be finished in. The API checks it against
|
|
652
985
|
// the recognizer and refuses a disagreement before a row exists.
|
|
@@ -655,9 +988,15 @@ export class VaultClient {
|
|
|
655
988
|
...(input.executionMode ? { execution_mode: input.executionMode } : {}),
|
|
656
989
|
...(input.grantId ? { grant_id: input.grantId } : {}),
|
|
657
990
|
...(input.merchantOrigin && !preparation ? { merchant_origin: input.merchantOrigin } : {}),
|
|
991
|
+
// Only the profile's id: the API takes the key, its environment and where
|
|
992
|
+
// the card goes from the profile Agentcard reviewed, never from the caller.
|
|
993
|
+
...(merchantProfile ? { merchant_hosted: { profile: merchantProfile.id, ...(merchantProfile.sandboxDeclaration ? { sandbox_declaration: {
|
|
994
|
+
endpoint: merchantProfile.sandboxDeclaration.endpoint, client_key: merchantProfile.sandboxDeclaration.clientKey, environment: 'test'
|
|
995
|
+
} } : {}),
|
|
996
|
+
...(priced ? { merchant_total: priced.wire } : {}) } } : {}),
|
|
658
997
|
// A prepared checkout already carries the page origin as merchant_origin.
|
|
659
998
|
...(preparation
|
|
660
|
-
? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin }
|
|
999
|
+
? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin, form_guidance: 'filled' }
|
|
661
1000
|
: input.pageOrigin ? { checkout_origin: input.pageOrigin } : {}),
|
|
662
1001
|
request: {
|
|
663
1002
|
url: input.request.url,
|
|
@@ -782,6 +1121,13 @@ export class VaultClient {
|
|
|
782
1121
|
const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
|
|
783
1122
|
? { amountAuthority: s.amount_authority }
|
|
784
1123
|
: {};
|
|
1124
|
+
// A merchant-hosted create finishes only as a cse approval: the API's
|
|
1125
|
+
// database forces these rows to cse. A finished read in any other mode
|
|
1126
|
+
// (token, a missing mode, a hosted form) is not one to act on, and never
|
|
1127
|
+
// reaches the code that would answer the merchant's paused request with it.
|
|
1128
|
+
if (merchantProfile && (s.status === 'approved' || s.status === 'submitted_on_device') && s.mode !== 'cse') {
|
|
1129
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_hosted_mode_invalid');
|
|
1130
|
+
}
|
|
785
1131
|
if (s.status === 'submitted_on_device') {
|
|
786
1132
|
// The device attested that the processor's form left it; the stamp
|
|
787
1133
|
// is the whole fact and it is NOT an approval (see HostedFormReplay).
|
|
@@ -809,12 +1155,27 @@ export class VaultClient {
|
|
|
809
1155
|
if (approvedMode === 'cse') {
|
|
810
1156
|
if (execution.executionMode === 'autopilot')
|
|
811
1157
|
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
|
|
1158
|
+
// A merchant-hosted create reads back only a merchant-hosted approval,
|
|
1159
|
+
// and a processor-hosted one never does.
|
|
1160
|
+
if (merchantProfile || (s.substitutions && typeof s.substitutions === 'object' && 'kind' in s.substitutions)) {
|
|
1161
|
+
return { ...merchantHostedApproval(s, authorizationId, merchantProfile), ...(priced ? { merchantTotal: priced.approved } : {}),
|
|
1162
|
+
...amountAuthority, ...execution };
|
|
1163
|
+
}
|
|
1164
|
+
// The API serves a Fiserv envelope only briefly after the approval (it carries
|
|
1165
|
+
// no timestamp, so whoever holds it could resubmit it). A read after that
|
|
1166
|
+
// window says so instead of serving it; the capture was never continued here.
|
|
1167
|
+
if (s.substitutions_expired === true)
|
|
1168
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_expired');
|
|
812
1169
|
const sub = s.substitutions;
|
|
813
1170
|
const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
|
|
814
1171
|
&& sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
|
|
815
1172
|
&& Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
|
|
816
1173
|
if (!fieldsOk)
|
|
817
1174
|
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
|
|
1175
|
+
// A Fiserv approval carries exactly its envelope's four members at
|
|
1176
|
+
// source.encryptionData and removes nothing (fiserv.ts); any other shape is not one to act on.
|
|
1177
|
+
if (rec.psp === 'fiserv' && !isFiservSubstitution(sub))
|
|
1178
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
|
|
818
1179
|
// `remove`: sibling keys the API says to drop with the swap (Adyen's
|
|
819
1180
|
// `brand`, stamped by adyen-web from the agent's dummy digits). Absent
|
|
820
1181
|
// on an older API; anything but a list of names is refused, since a
|
|
@@ -901,6 +1262,9 @@ export class VaultClient {
|
|
|
901
1262
|
}
|
|
902
1263
|
if (s.reason === 'intent_not_confirmable')
|
|
903
1264
|
throw new IntentNotConfirmableError(String(created.id));
|
|
1265
|
+
if (typeof s.reason === 'string' && ADYEN_TEST_PLATFORM_REFUSALS.includes(s.reason)) {
|
|
1266
|
+
throw new AdyenTestPlatformRefusedError(String(created.id), s.reason, 'pre_replay');
|
|
1267
|
+
}
|
|
904
1268
|
if (typeof s.reason === 'string' && PRESET_REFUSAL_REASONS.has(s.reason)) {
|
|
905
1269
|
throw new PresetRefusedError(String(created.id), s.reason, typeof s.message === 'string' ? s.message : null, presetOf(s.preset), typeof s.rule === 'string' ? s.rule : null, 'pre_replay', attachmentOf(s.attachment), refusalsOf(s.refusals));
|
|
906
1270
|
}
|
|
@@ -996,6 +1360,93 @@ export class VaultClient {
|
|
|
996
1360
|
return { id: authorizationId, status: 'declined', reason: recorded,
|
|
997
1361
|
cancelled: true, processor_request_started: result.processor_request_started };
|
|
998
1362
|
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Ask whether the page may pay with the Stripe card token an approval
|
|
1365
|
+
* produced: a PaymentIntent confirm that carries no card and pays with
|
|
1366
|
+
* exactly the approved payment method, card token, confirmation token or
|
|
1367
|
+
* source. The API reads the payment from Stripe and answers only for the
|
|
1368
|
+
* approved amount and currency on the same Stripe account; any other answer
|
|
1369
|
+
* rejects with a CheckoutApiError whose code says why (for example
|
|
1370
|
+
* `continuation_not_bound`, `amount_mismatch`). Nothing is charged here:
|
|
1371
|
+
* the adapters continue the page's own request once this resolves.
|
|
1372
|
+
*/
|
|
1373
|
+
async checkStripeContinuation(authorizationId, request) {
|
|
1374
|
+
if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
|
|
1375
|
+
throw new Error('Invalid authorization ID.');
|
|
1376
|
+
const rec = findRecognizer(request.url, this.registry);
|
|
1377
|
+
if (rec?.psp !== 'stripe')
|
|
1378
|
+
throw new Error('Only a Stripe request can continue an approved Stripe token.');
|
|
1379
|
+
const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/continuations`, {
|
|
1380
|
+
request: { url: request.url, method: request.method, headers: pickHeaders(request.headers, rec.passthroughHeaders), body: request.body },
|
|
1381
|
+
}, AbortSignal.timeout(15_000));
|
|
1382
|
+
const pi = result?.payment_intent;
|
|
1383
|
+
if (result?.object !== 'checkout_continuation' || result.continuation !== 'allowed' || result.authorization !== authorizationId
|
|
1384
|
+
|| !pi || typeof pi.id !== 'string' || !Number.isSafeInteger(pi.amount) || typeof pi.currency !== 'string') {
|
|
1385
|
+
throw new Error('The continuation answer was not recognized.');
|
|
1386
|
+
}
|
|
1387
|
+
return { paymentIntentId: pi.id, amount: pi.amount, currency: pi.currency };
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* After a merchant-hosted payment priced by the merchant's own total: what the merchant's
|
|
1391
|
+
* confirmation says it charged (merchant-total.ts merchantChargeReport builds `report`
|
|
1392
|
+
* from the responses this browser recorded after the card request). The API compares it
|
|
1393
|
+
* with the approved amount and alerts Agentcard once when the merchant charged more, or
|
|
1394
|
+
* in another currency. The adapters send it on their own; the verdict is 'equal',
|
|
1395
|
+
* 'lower', 'higher', 'currency_mismatch', or 'unread' when no response read.
|
|
1396
|
+
*/
|
|
1397
|
+
async reportMerchantCharge(authorizationId, report) {
|
|
1398
|
+
if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
|
|
1399
|
+
throw new Error('Invalid authorization ID.');
|
|
1400
|
+
const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/merchant_charge`, report, AbortSignal.timeout(10_000));
|
|
1401
|
+
const verdict = result?.verdict;
|
|
1402
|
+
if (result?.authorization_id !== authorizationId || !['equal', 'lower', 'higher', 'currency_mismatch', 'unread'].includes(verdict)) {
|
|
1403
|
+
throw new Error('The merchant charge report was not acknowledged.');
|
|
1404
|
+
}
|
|
1405
|
+
return { verdict, alerted: result.alerted === true };
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* The merchant's own total for a paused card request whose reviewed profile is priced by
|
|
1409
|
+
* one: the exchanges the adapter recorded (once every request that can move the total has
|
|
1410
|
+
* answered), cut to what the profile's rules read, and the same reading the API makes.
|
|
1411
|
+
* Refused with a MerchantTotalError before any create when nothing recorded them, one is
|
|
1412
|
+
* still unanswered, or they do not confirm one total for this order.
|
|
1413
|
+
*/
|
|
1414
|
+
async readMerchantTotal(input, profile, preparation, signal) {
|
|
1415
|
+
const refuse = (code, reasonCode, reason) => new MerchantTotalError(null, code, 'sdk', reasonCode, reason);
|
|
1416
|
+
const capture = input.merchantTotal;
|
|
1417
|
+
if (!capture || typeof capture.exchanges !== 'function' || typeof capture.pausedAt !== 'number' || !Number.isFinite(capture.pausedAt)) {
|
|
1418
|
+
throw refuse('merchant_total_required', null, `${profile.merchant}'s own total is read from its checkout's responses, and this runtime recorded none`);
|
|
1419
|
+
}
|
|
1420
|
+
const checkoutOrigin = preparation?.merchantOrigin ?? input.pageOrigin;
|
|
1421
|
+
const card = {
|
|
1422
|
+
url: input.request.url, method: (input.request.method ?? 'POST').toUpperCase(), body: input.request.body, pausedAt: capture.pausedAt,
|
|
1423
|
+
...(checkoutOrigin ? { checkoutOrigin } : {}), ...(capture.page ? { page: capture.page } : {}),
|
|
1424
|
+
...(profile.sandboxDeclaration ? { declaredEndpoint: profile.sandboxDeclaration.endpoint } : {}),
|
|
1425
|
+
};
|
|
1426
|
+
let seen;
|
|
1427
|
+
try {
|
|
1428
|
+
seen = await capture.exchanges(profile.id, 'source', card, signal);
|
|
1429
|
+
}
|
|
1430
|
+
catch (error) {
|
|
1431
|
+
if (error instanceof MerchantTotalWait)
|
|
1432
|
+
throw refuse('merchant_total_refused', 'stale', `${profile.merchant}'s total is not known: ${error.message}`);
|
|
1433
|
+
throw error;
|
|
1434
|
+
}
|
|
1435
|
+
const sent = merchantAmountExchanges(profile.id, 'source', { card, responses: seen });
|
|
1436
|
+
if (!sent.ok)
|
|
1437
|
+
throw refuse('merchant_total_refused', sent.code, `Refusing to pay ${profile.merchant}: ${sent.reason}`);
|
|
1438
|
+
const read = merchantAmountFromResponses(profile.id, { card, responses: sent.responses });
|
|
1439
|
+
if (!read.ok)
|
|
1440
|
+
throw refuse('merchant_total_refused', read.code, `Refusing to pay ${profile.merchant}: ${read.reason}`);
|
|
1441
|
+
// The agent's number is held to the merchant's before anyone is asked. An integer amount is
|
|
1442
|
+
// compared here; a decimal string is left to the API, which reads it in the currency's units.
|
|
1443
|
+
if (typeof input.amount === 'number' && typeof input.currency === 'string'
|
|
1444
|
+
&& (input.amount !== read.amount.amount || input.currency.toLowerCase() !== read.amount.currency.toLowerCase())) {
|
|
1445
|
+
// The same typed refusal the API's 409 amount_mismatch becomes, naming the merchant's total.
|
|
1446
|
+
throw new AmountMismatchError(null, input.amount, read.amount.amount, input.currency.toLowerCase(), read.amount.currency, 'create', 'merchant_total');
|
|
1447
|
+
}
|
|
1448
|
+
return { wire: merchantTotalWire(capture.pausedAt, capture.page, sent.responses), approved: { card, amount: read.amount } };
|
|
1449
|
+
}
|
|
999
1450
|
/**
|
|
1000
1451
|
* POST the create, with two typed twists: a 502 `amount_unverifiable`
|
|
1001
1452
|
* (Stripe did not answer the read-back) is retried on a short backoff
|
|
@@ -1016,9 +1467,21 @@ export class VaultClient {
|
|
|
1016
1467
|
const d = err.details;
|
|
1017
1468
|
throw new PresetRefusedError(null, err.code, typeof d.message === 'string' ? d.message : null, presetOf(d.preset), typeof d.rule === 'string' ? d.rule : null, 'create', attachmentOf(d.attachment), refusalsOf(d.refusals));
|
|
1018
1469
|
}
|
|
1470
|
+
if (err instanceof CheckoutApiError && err.status === 400 && err.code === 'adyen_test_environment_refused') {
|
|
1471
|
+
// A live checkout named Adyen's test host or a test_ key: refused before anyone was asked.
|
|
1472
|
+
throw new AdyenTestPlatformRefusedError(null, 'adyen_test_environment_refused', 'create');
|
|
1473
|
+
}
|
|
1019
1474
|
if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
|
|
1020
1475
|
const d = err.details;
|
|
1021
|
-
throw new AmountMismatchError(null, Number(d.expected_cents), Number(d.actual_cents), String(d.currency ?? currency ?? ''), d.actual_currency != null ? String(d.actual_currency) : undefined, 'create'
|
|
1476
|
+
throw new AmountMismatchError(null, Number(d.expected_cents), Number(d.actual_cents), String(d.currency ?? currency ?? ''), d.actual_currency != null ? String(d.actual_currency) : undefined, 'create',
|
|
1477
|
+
// A merchant-hosted refusal names the merchant amount it held the agent's to:
|
|
1478
|
+
// its card request's own ('body') or its checkout total (an amount source's id).
|
|
1479
|
+
typeof d.amount_source === 'string' ? (d.amount_source === 'body' ? 'merchant_request' : 'merchant_total') : 'processor');
|
|
1480
|
+
}
|
|
1481
|
+
if (err instanceof CheckoutApiError && err.status === 409 && (err.code === 'merchant_total_required' || err.code === 'merchant_total_refused')) {
|
|
1482
|
+
// The API could not confirm the merchant's own total for this order: no row exists.
|
|
1483
|
+
const d = err.details;
|
|
1484
|
+
throw new MerchantTotalError(null, err.code, 'create', typeof d.reason_code === 'string' ? d.reason_code : null, typeof d.reason === 'string' ? `Agentcard could not confirm the merchant's own total: ${d.reason}` : err.message);
|
|
1022
1485
|
}
|
|
1023
1486
|
// Two 502s the API asks to be retried: Stripe did not answer the
|
|
1024
1487
|
// amount read-back, or Adyen did not answer the public-key fetch.
|
|
@@ -1137,6 +1600,121 @@ function executionMetadata(state, authorizationId, previous = {}) {
|
|
|
1137
1600
|
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_changed');
|
|
1138
1601
|
return { executionMode: 'autopilot', grantId: state.grant_id };
|
|
1139
1602
|
}
|
|
1603
|
+
/**
|
|
1604
|
+
* What authorize() creates a merchant-hosted authorization as: an Adyen cse
|
|
1605
|
+
* payment that only a prepared checkout may make (so an unprepared request is
|
|
1606
|
+
* refused locally with PreparationRequiredError, before any create), forwarding
|
|
1607
|
+
* no header but content-type. Never matched against a URL: authorize() uses it
|
|
1608
|
+
* only for an armed profile's endpoint.
|
|
1609
|
+
*/
|
|
1610
|
+
const MERCHANT_HOSTED_RECOGNIZER = Object.freeze({
|
|
1611
|
+
psp: 'adyen', match: /(?!)/, encoding: 'json', passthroughHeaders: [],
|
|
1612
|
+
clientSideEncrypted: true, mode: 'cse', preparationRequired: true,
|
|
1613
|
+
});
|
|
1614
|
+
/**
|
|
1615
|
+
* Refuse, before any create or prompt, a merchant-hosted request this client
|
|
1616
|
+
* cannot pay: a profile Agentcard has not turned on (observe), Autopilot (the
|
|
1617
|
+
* cardholder approves every one), or a request the profile's rules do not pause
|
|
1618
|
+
* (anything but its reviewed card request).
|
|
1619
|
+
*/
|
|
1620
|
+
function assertMerchantHostedRequest(input, profile) {
|
|
1621
|
+
if (profile.status !== 'enabled') {
|
|
1622
|
+
throw new Error(`Agentcard has not turned on payments to ${profile.merchant} yet. Nothing was charged.`);
|
|
1623
|
+
}
|
|
1624
|
+
if (input.executionMode === 'autopilot' || input.grantId !== undefined) {
|
|
1625
|
+
throw new Error(`A payment to ${profile.merchant} needs the cardholder's approval every time; Autopilot cannot pay it.`);
|
|
1626
|
+
}
|
|
1627
|
+
const verdict = classifyProfileRequest(profile, input.request.url, input.request.method ?? 'POST', input.request.body);
|
|
1628
|
+
if (verdict.verdict === 'abort')
|
|
1629
|
+
throw new Error(`Refusing to pay ${profile.merchant}: ${verdict.reason}. Nothing was charged.`);
|
|
1630
|
+
if (verdict.verdict === 'pass')
|
|
1631
|
+
throw new Error(`not a card request Agentcard reviewed for ${profile.merchant}.`);
|
|
1632
|
+
}
|
|
1633
|
+
const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
1634
|
+
const FORBIDDEN_STEPS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
1635
|
+
/**
|
|
1636
|
+
* Whether a served preparation names the sandbox declaration this client sent:
|
|
1637
|
+
* its endpoint, client key and the test environment, with the public key's
|
|
1638
|
+
* SHA-256 the API read. Neither side naming one also matches.
|
|
1639
|
+
*/
|
|
1640
|
+
function servesDeclaration(served, declaration) {
|
|
1641
|
+
if (!declaration)
|
|
1642
|
+
return served === undefined;
|
|
1643
|
+
return isRecord(served) && served.endpoint === declaration.endpoint && served.client_key === declaration.clientKey
|
|
1644
|
+
&& served.environment === 'test' && typeof served.public_key_sha256 === 'string' && /^[0-9a-f]{64}$/.test(served.public_key_sha256);
|
|
1645
|
+
}
|
|
1646
|
+
/** A served JSON path: object keys and array indices, `[]` for the body root; null for anything else. */
|
|
1647
|
+
function jsonPathOf(value) {
|
|
1648
|
+
if (!Array.isArray(value) || value.length > 16)
|
|
1649
|
+
return null;
|
|
1650
|
+
const ok = value.every((step) => (typeof step === 'string' && step.length > 0 && !FORBIDDEN_STEPS.has(step))
|
|
1651
|
+
|| (typeof step === 'number' && Number.isSafeInteger(step) && step >= 0));
|
|
1652
|
+
return ok ? [...value] : null;
|
|
1653
|
+
}
|
|
1654
|
+
/** Served card facts: a list of {path, value}; null when anything in it is not one. */
|
|
1655
|
+
function factsOf(value) {
|
|
1656
|
+
if (!Array.isArray(value))
|
|
1657
|
+
return null;
|
|
1658
|
+
const facts = [];
|
|
1659
|
+
for (const fact of value) {
|
|
1660
|
+
if (!isRecord(fact) || Object.keys(fact).some((key) => key !== 'path' && key !== 'value'))
|
|
1661
|
+
return null;
|
|
1662
|
+
const path = jsonPathOf(fact.path);
|
|
1663
|
+
if (!path || typeof fact.value !== 'string')
|
|
1664
|
+
return null;
|
|
1665
|
+
facts.push({ path, value: fact.value });
|
|
1666
|
+
}
|
|
1667
|
+
return facts;
|
|
1668
|
+
}
|
|
1669
|
+
const MERCHANT_HOSTED_SUBSTITUTION_MEMBERS = new Set(['encoding', 'kind', 'profile', 'path', 'fields', 'remove', 'set']);
|
|
1670
|
+
/**
|
|
1671
|
+
* The approved read of a merchant-hosted authorization, as the replay the
|
|
1672
|
+
* adapters continue: the profile's substitution (`kind: 'merchant_hosted'`, a
|
|
1673
|
+
* holder path that may be the root `[]`, the merchant's four field names with
|
|
1674
|
+
* their ciphertext, the keys to drop and any card facts to `set`), and beside it
|
|
1675
|
+
* the profile and the SHA-256 of the body the API checked at create. Anything
|
|
1676
|
+
* else, a create that was not merchant-hosted, another profile, or a set the API
|
|
1677
|
+
* no longer serves (`substitutions_expired`) is an unknown outcome: the device may
|
|
1678
|
+
* already have encrypted the card, and nothing here can pay with it.
|
|
1679
|
+
*/
|
|
1680
|
+
function merchantHostedApproval(s, authorizationId, profile) {
|
|
1681
|
+
const malformed = () => new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
|
|
1682
|
+
if (!profile)
|
|
1683
|
+
throw malformed();
|
|
1684
|
+
// The API withheld the card because the merchant's total was too old when the cardholder
|
|
1685
|
+
// approved: nothing was encrypted into any request, so this is a refusal, not an unknown.
|
|
1686
|
+
if (s.merchant_total_stale === true) {
|
|
1687
|
+
throw new MerchantTotalError(authorizationId, 'merchant_total_stale', 'runtime', 'stale', `The approval came after ${profile.merchant}'s total was too old for its profile, so Agentcard withheld the card`);
|
|
1688
|
+
}
|
|
1689
|
+
if (s.substitutions_expired === true)
|
|
1690
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'substitutions_expired');
|
|
1691
|
+
const sub = s.substitutions;
|
|
1692
|
+
const served = s.merchant_hosted;
|
|
1693
|
+
if (!isRecord(sub) || sub.encoding !== 'json' || sub.kind !== 'merchant_hosted' || sub.profile !== profile.id
|
|
1694
|
+
|| Object.keys(sub).some((member) => !MERCHANT_HOSTED_SUBSTITUTION_MEMBERS.has(member)))
|
|
1695
|
+
throw malformed();
|
|
1696
|
+
if (!isRecord(served) || served.profile !== profile.id || typeof served.body_sha256 !== 'string'
|
|
1697
|
+
|| !/^[0-9a-f]{64}$/.test(served.body_sha256))
|
|
1698
|
+
throw malformed();
|
|
1699
|
+
const path = jsonPathOf(sub.path);
|
|
1700
|
+
const fields = isRecord(sub.fields) && Object.keys(sub.fields).length > 0
|
|
1701
|
+
&& Object.values(sub.fields).every((value) => typeof value === 'string' && value.length > 0) ? { ...sub.fields } : null;
|
|
1702
|
+
const remove = sub.remove === undefined ? [] : Array.isArray(sub.remove) && sub.remove.every((key) => typeof key === 'string' && key.length > 0)
|
|
1703
|
+
? [...sub.remove] : null;
|
|
1704
|
+
const set = sub.set === undefined ? [] : factsOf(sub.set);
|
|
1705
|
+
if (!path || !fields || !remove || !set)
|
|
1706
|
+
throw malformed();
|
|
1707
|
+
return {
|
|
1708
|
+
mode: 'cse',
|
|
1709
|
+
kind: 'merchant_hosted',
|
|
1710
|
+
authorizationId,
|
|
1711
|
+
profile: profile.id,
|
|
1712
|
+
substitutions: { encoding: 'json', kind: 'merchant_hosted', profile: profile.id, path, fields, remove, ...(set.length ? { set } : {}) },
|
|
1713
|
+
bodySha256: served.body_sha256,
|
|
1714
|
+
substitutionsExpiresAt: typeof s.substitutions_expires_at === 'string' ? s.substitutions_expires_at : null,
|
|
1715
|
+
...(profile.sandboxDeclaration ? { sandboxDeclaration: profile.sandboxDeclaration } : {}),
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1140
1718
|
/**
|
|
1141
1719
|
* Forward only the headers the merchant needs to accept the replay. Everything
|
|
1142
1720
|
* else (cookies, UA, tracing) is dropped so we transmit as little as possible.
|