@agent-cards/checkout 0.18.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 +8 -0
- package/PREFLIGHT.md +4 -0
- package/README.md +91 -7
- 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/cdp.d.ts +4 -1
- package/dist/cdp.js +416 -217
- package/dist/client.d.ts +236 -5
- package/dist/client.js +514 -11
- 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 +15 -1
- package/dist/lifecycle.js +28 -3
- 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-catalog.json +132 -0
- package/dist/preflight-schemas.json +14 -2
- package/dist/preflight.generated.js +15 -1
- package/dist/preparation.d.ts +7 -0
- package/dist/preparation.js +33 -6
- package/dist/prepared-processor.d.ts +36 -3
- package/dist/prepared-processor.js +53 -3
- package/dist/registry.d.ts +45 -0
- package/dist/registry.js +14 -0
- package/dist/stripe-checkout.generated.js +96 -7
- 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,6 +1,9 @@
|
|
|
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';
|
|
5
8
|
import { requestWithoutCard } from './card-fields.generated.js';
|
|
6
9
|
import { classifyStripeCheckoutRequest, encodeStripeCheckoutContext, hasStripeCheckoutMarker, parseStripeCheckoutContext, parseStripeCheckoutResponse, STRIPE_CHECKOUT_CONTEXT_HEADER, } from './stripe-checkout.generated.js';
|
|
@@ -20,6 +23,17 @@ export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
|
|
|
20
23
|
* confirm, which hosted Checkout sends after an approval.
|
|
21
24
|
*/
|
|
22
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'];
|
|
23
37
|
/** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
|
|
24
38
|
export function validAmountInput(amount) {
|
|
25
39
|
if (typeof amount === 'number')
|
|
@@ -59,6 +73,22 @@ export class UnsupportedModeError extends Error {
|
|
|
59
73
|
this.name = 'UnsupportedModeError';
|
|
60
74
|
}
|
|
61
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
|
+
}
|
|
62
92
|
export class ApprovalTimeoutError extends Error {
|
|
63
93
|
constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
|
|
64
94
|
}
|
|
@@ -96,6 +126,13 @@ export class ApprovalDeclinedError extends Error {
|
|
|
96
126
|
* and quiet the page's retry exactly as for a person's "no"), so it extends
|
|
97
127
|
* ApprovalDeclinedError: code that already handles declines keeps working,
|
|
98
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.
|
|
99
136
|
*/
|
|
100
137
|
export class AmountMismatchError extends ApprovalDeclinedError {
|
|
101
138
|
authorizationId;
|
|
@@ -104,20 +141,27 @@ export class AmountMismatchError extends ApprovalDeclinedError {
|
|
|
104
141
|
currency;
|
|
105
142
|
actualCurrency;
|
|
106
143
|
stage;
|
|
144
|
+
amountSource;
|
|
107
145
|
code = 'amount_mismatch';
|
|
108
146
|
constructor(
|
|
109
147
|
/** The declined authorization, or null for a create-time refusal (no row exists). */
|
|
110
148
|
authorizationId,
|
|
111
149
|
/** What the user was asked to approve, smallest currency unit. */
|
|
112
150
|
expectedCents,
|
|
113
|
-
/** What the processor reported at the last check. */
|
|
151
|
+
/** What the processor (or the merchant, see `amountSource`) reported at the last check. */
|
|
114
152
|
actualCents,
|
|
115
153
|
/** ISO 4217 of the approved amount. */
|
|
116
154
|
currency,
|
|
117
155
|
/** ISO 4217 the processor reported (differs only on a currency change). */
|
|
118
156
|
actualCurrency = currency,
|
|
119
157
|
/** Which check refused it. */
|
|
120
|
-
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') {
|
|
121
165
|
super('amount_mismatch');
|
|
122
166
|
this.authorizationId = authorizationId;
|
|
123
167
|
this.expectedCents = expectedCents;
|
|
@@ -125,9 +169,12 @@ export class AmountMismatchError extends ApprovalDeclinedError {
|
|
|
125
169
|
this.currency = currency;
|
|
126
170
|
this.actualCurrency = actualCurrency;
|
|
127
171
|
this.stage = stage;
|
|
172
|
+
this.amountSource = amountSource;
|
|
128
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';
|
|
129
176
|
this.message = `amount mismatch${authorizationId ? ` on ${authorizationId}` : ' at create'}: `
|
|
130
|
-
+ `the user was asked to approve ${expectedCents} ${currency},
|
|
177
|
+
+ `the user was asked to approve ${expectedCents} ${currency}, ${reported} ${actualCents} ${actualCurrency}. Nothing was charged.`;
|
|
131
178
|
}
|
|
132
179
|
}
|
|
133
180
|
/**
|
|
@@ -195,6 +242,69 @@ export class ProcessorRefusedError extends ApprovalDeclinedError {
|
|
|
195
242
|
this.message = `the processor rejected the payment request on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Check the merchant payment status before retrying.`;
|
|
196
243
|
}
|
|
197
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
|
+
}
|
|
198
308
|
/**
|
|
199
309
|
* A non-2xx from the Agentcard API, carrying the status so callers can tell a
|
|
200
310
|
* misconfiguration from a blip. The adapters use this to decide whether
|
|
@@ -364,6 +474,35 @@ export function redactUrl(raw) {
|
|
|
364
474
|
return raw.split(/[?#]/)[0];
|
|
365
475
|
}
|
|
366
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
|
+
];
|
|
367
506
|
/** Default backoff for a 502 amount_unverifiable at create: two retries, then give up. */
|
|
368
507
|
const UNVERIFIABLE_RETRY_DELAYS_MS = [500, 1500];
|
|
369
508
|
const PREPARATION_READ_RETRY_DELAYS_MS = [250, 500];
|
|
@@ -384,6 +523,13 @@ export class VaultClient {
|
|
|
384
523
|
pollIntervalMs;
|
|
385
524
|
unverifiableRetryDelaysMs;
|
|
386
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;
|
|
387
533
|
preparations = new WeakSet();
|
|
388
534
|
usedPreparations = new WeakSet();
|
|
389
535
|
constructor(opts) {
|
|
@@ -393,6 +539,11 @@ export class VaultClient {
|
|
|
393
539
|
this.registry = opts.registry ?? BUILTIN_REGISTRY;
|
|
394
540
|
this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
|
|
395
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);
|
|
396
547
|
}
|
|
397
548
|
/** Refresh recognizers from the API so new PSPs work without a redeploy. */
|
|
398
549
|
async syncRegistry() {
|
|
@@ -405,19 +556,59 @@ export class VaultClient {
|
|
|
405
556
|
// abort, which would dead-end the checkout). `mode` rides through the
|
|
406
557
|
// spread verbatim.
|
|
407
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).
|
|
408
571
|
try {
|
|
409
|
-
raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}&features=${SUPPORTED_REGISTRY_FEATURES.join(',')}`);
|
|
572
|
+
raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}&features=${SUPPORTED_REGISTRY_FEATURES.join(',')}${capabilities}&merchant_profiles=1`);
|
|
410
573
|
}
|
|
411
574
|
catch {
|
|
412
575
|
return;
|
|
413
576
|
}
|
|
414
577
|
if (!Array.isArray(raw))
|
|
415
578
|
return;
|
|
416
|
-
|
|
579
|
+
const isProfile = (e) => !!e && typeof e === 'object' && 'merchant_profile' in e;
|
|
580
|
+
this.registry = raw.filter((e) => !isProfile(e)).map((e) => ({
|
|
417
581
|
...e,
|
|
418
582
|
match: new RegExp(e.match, 'i'),
|
|
419
583
|
passthroughHeaders: e.passthroughHeaders.map((h) => new RegExp(h, 'i')),
|
|
420
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]);
|
|
421
612
|
}
|
|
422
613
|
/** True when this request is a card tokenization we can take over. */
|
|
423
614
|
isCardRequest(url, method = 'POST') {
|
|
@@ -471,6 +662,35 @@ export class VaultClient {
|
|
|
471
662
|
const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
|
|
472
663
|
if (!validPreparationEnvironment(input.psp, input.environment))
|
|
473
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;
|
|
474
694
|
if (!validAmountInput(input.amount) || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
|
|
475
695
|
throw fail('amount_required');
|
|
476
696
|
const origin = new URL(input.merchantOrigin);
|
|
@@ -478,6 +698,11 @@ export class VaultClient {
|
|
|
478
698
|
throw fail('merchant_origin_invalid');
|
|
479
699
|
if (!input.checkoutKey || !input.user || !input.merchant)
|
|
480
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');
|
|
481
706
|
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
482
707
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
483
708
|
throw fail('timeout_invalid');
|
|
@@ -493,6 +718,9 @@ export class VaultClient {
|
|
|
493
718
|
user: input.user, merchant: input.merchant, amount: input.amount, currency: input.currency.toLowerCase(),
|
|
494
719
|
...(input.cardId ? { card_id: input.cardId } : {}), psp: input.psp, mode: preparationMode(input.psp),
|
|
495
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' } } : {}),
|
|
496
724
|
}, AbortSignal.timeout(30_000), signal);
|
|
497
725
|
if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
|
|
498
726
|
throw fail('create_unconfirmed');
|
|
@@ -520,7 +748,12 @@ export class VaultClient {
|
|
|
520
748
|
const expiry = Date.parse(state.ready_expires_at);
|
|
521
749
|
if (!Number.isFinite(expiry) || expiry <= Date.now() || typeof state.card_id !== 'string' || !state.card_id
|
|
522
750
|
|| state.payment_status !== 'not_started' || state.amount_authority !== 'agent'
|
|
523
|
-
|
|
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)
|
|
524
757
|
|| !Number.isSafeInteger(state.amount) || (typeof input.amount === 'number' && state.amount !== input.amount) || state.currency !== input.currency.toLowerCase()
|
|
525
758
|
|| state.psp !== input.psp || state.mode !== preparationMode(input.psp) || state.environment !== input.environment
|
|
526
759
|
|| state.checkout_key !== input.checkoutKey)
|
|
@@ -531,6 +764,9 @@ export class VaultClient {
|
|
|
531
764
|
amountDisplay: typeof state.amount_display === 'string' ? state.amount_display : null,
|
|
532
765
|
currency: input.currency.toLowerCase(), merchantOrigin: input.merchantOrigin, checkoutKey: input.checkoutKey,
|
|
533
766
|
paymentStatus: 'not_started', amountAuthority: 'agent',
|
|
767
|
+
...(profile ? { merchantProfile: profile.id } : {}),
|
|
768
|
+
...(fiservPin ? { merchantProfile: fiservPin.id } : {}),
|
|
769
|
+
...(declaration ? { sandboxDeclaration: declaration } : {}),
|
|
534
770
|
});
|
|
535
771
|
this.preparations.add(prepared);
|
|
536
772
|
ready = true;
|
|
@@ -545,6 +781,15 @@ export class VaultClient {
|
|
|
545
781
|
catch (error) {
|
|
546
782
|
if (error instanceof CheckoutPreparationError)
|
|
547
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);
|
|
548
793
|
throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
|
|
549
794
|
}
|
|
550
795
|
finally {
|
|
@@ -637,14 +882,33 @@ export class VaultClient {
|
|
|
637
882
|
throw new CheckoutPreparationError(preparation.id, 'expired');
|
|
638
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))
|
|
639
884
|
|| input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
|
|
640
|
-
|| !
|
|
885
|
+
|| !matchesPreparation(preparation, input.request.url, input.request.method ?? 'POST', input.request.body, input.request.headers))
|
|
641
886
|
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
642
887
|
}
|
|
643
888
|
if (input.signal?.aborted)
|
|
644
889
|
throw new CheckoutCancelledError();
|
|
645
890
|
if (input.merchantSignal?.aborted)
|
|
646
891
|
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
647
|
-
|
|
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')
|
|
648
912
|
: findRecognizer(input.request.url, this.registry);
|
|
649
913
|
if (!rec)
|
|
650
914
|
throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
|
|
@@ -655,6 +919,22 @@ export class VaultClient {
|
|
|
655
919
|
const mode = rec.mode ?? 'token';
|
|
656
920
|
if (preparation && (rec.psp !== preparation.psp || mode !== preparationMode(preparation.psp)))
|
|
657
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
|
+
}
|
|
658
938
|
if (rec.clientSideEncrypted && mode !== 'cse')
|
|
659
939
|
throw new CardEncryptedError(rec.psp);
|
|
660
940
|
if (!SUPPORTED_MODES.includes(mode))
|
|
@@ -686,6 +966,10 @@ export class VaultClient {
|
|
|
686
966
|
const deadline = Date.now() + timeoutMs;
|
|
687
967
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
688
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;
|
|
689
973
|
let created;
|
|
690
974
|
const payload = {
|
|
691
975
|
user: input.user,
|
|
@@ -704,6 +988,12 @@ export class VaultClient {
|
|
|
704
988
|
...(input.executionMode ? { execution_mode: input.executionMode } : {}),
|
|
705
989
|
...(input.grantId ? { grant_id: input.grantId } : {}),
|
|
706
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 } : {}) } } : {}),
|
|
707
997
|
// A prepared checkout already carries the page origin as merchant_origin.
|
|
708
998
|
...(preparation
|
|
709
999
|
? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin, form_guidance: 'filled' }
|
|
@@ -831,6 +1121,13 @@ export class VaultClient {
|
|
|
831
1121
|
const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
|
|
832
1122
|
? { amountAuthority: s.amount_authority }
|
|
833
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
|
+
}
|
|
834
1131
|
if (s.status === 'submitted_on_device') {
|
|
835
1132
|
// The device attested that the processor's form left it; the stamp
|
|
836
1133
|
// is the whole fact and it is NOT an approval (see HostedFormReplay).
|
|
@@ -858,12 +1155,27 @@ export class VaultClient {
|
|
|
858
1155
|
if (approvedMode === 'cse') {
|
|
859
1156
|
if (execution.executionMode === 'autopilot')
|
|
860
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');
|
|
861
1169
|
const sub = s.substitutions;
|
|
862
1170
|
const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
|
|
863
1171
|
&& sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
|
|
864
1172
|
&& Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
|
|
865
1173
|
if (!fieldsOk)
|
|
866
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');
|
|
867
1179
|
// `remove`: sibling keys the API says to drop with the swap (Adyen's
|
|
868
1180
|
// `brand`, stamped by adyen-web from the agent's dummy digits). Absent
|
|
869
1181
|
// on an older API; anything but a list of names is refused, since a
|
|
@@ -950,6 +1262,9 @@ export class VaultClient {
|
|
|
950
1262
|
}
|
|
951
1263
|
if (s.reason === 'intent_not_confirmable')
|
|
952
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
|
+
}
|
|
953
1268
|
if (typeof s.reason === 'string' && PRESET_REFUSAL_REASONS.has(s.reason)) {
|
|
954
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));
|
|
955
1270
|
}
|
|
@@ -1071,6 +1386,67 @@ export class VaultClient {
|
|
|
1071
1386
|
}
|
|
1072
1387
|
return { paymentIntentId: pi.id, amount: pi.amount, currency: pi.currency };
|
|
1073
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
|
+
}
|
|
1074
1450
|
/**
|
|
1075
1451
|
* POST the create, with two typed twists: a 502 `amount_unverifiable`
|
|
1076
1452
|
* (Stripe did not answer the read-back) is retried on a short backoff
|
|
@@ -1091,9 +1467,21 @@ export class VaultClient {
|
|
|
1091
1467
|
const d = err.details;
|
|
1092
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));
|
|
1093
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
|
+
}
|
|
1094
1474
|
if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
|
|
1095
1475
|
const d = err.details;
|
|
1096
|
-
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);
|
|
1097
1485
|
}
|
|
1098
1486
|
// Two 502s the API asks to be retried: Stripe did not answer the
|
|
1099
1487
|
// amount read-back, or Adyen did not answer the public-key fetch.
|
|
@@ -1212,6 +1600,121 @@ function executionMetadata(state, authorizationId, previous = {}) {
|
|
|
1212
1600
|
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_changed');
|
|
1213
1601
|
return { executionMode: 'autopilot', grantId: state.grant_id };
|
|
1214
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
|
+
}
|
|
1215
1718
|
/**
|
|
1216
1719
|
* Forward only the headers the merchant needs to accept the replay. Everything
|
|
1217
1720
|
* else (cookies, UA, tracing) is dropped so we transmit as little as possible.
|