@agent-cards/checkout 0.18.0 → 0.21.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/README.md +6 -669
- package/cdp.d.ts +1 -0
- package/cdp.js +2 -0
- package/index.d.ts +1 -0
- package/index.js +2 -0
- package/package.json +33 -33
- package/playwright.d.ts +1 -0
- package/playwright.js +2 -0
- package/preflight.d.ts +1 -0
- package/preflight.js +2 -0
- package/CHANGELOG.md +0 -124
- package/PREFLIGHT.md +0 -308
- package/dist/adyen.generated.d.ts +0 -24
- package/dist/adyen.generated.js +0 -64
- package/dist/attachment.d.ts +0 -11
- package/dist/attachment.js +0 -50
- package/dist/braintree.d.ts +0 -2
- package/dist/braintree.generated.d.ts +0 -10
- package/dist/braintree.generated.js +0 -302
- package/dist/braintree.js +0 -2
- package/dist/builtin-registry.generated.d.ts +0 -2
- package/dist/builtin-registry.generated.js +0 -1
- package/dist/card-fields.generated.d.ts +0 -3
- package/dist/card-fields.generated.js +0 -46
- package/dist/cdp.d.ts +0 -189
- package/dist/cdp.js +0 -2194
- package/dist/checkout-com.generated.d.ts +0 -4
- package/dist/checkout-com.generated.js +0 -183
- package/dist/client.d.ts +0 -618
- package/dist/client.js +0 -1251
- package/dist/hosted-form.d.ts +0 -44
- package/dist/hosted-form.js +0 -78
- package/dist/index.d.ts +0 -13
- package/dist/index.js +0 -6
- package/dist/lifecycle.d.ts +0 -165
- package/dist/lifecycle.js +0 -370
- package/dist/mercado-checkout.d.ts +0 -20
- package/dist/mercado-checkout.generated.d.ts +0 -52
- package/dist/mercado-checkout.generated.js +0 -198
- package/dist/mercado-checkout.js +0 -108
- package/dist/owned-shop.generated.d.ts +0 -24
- package/dist/owned-shop.generated.js +0 -108
- package/dist/paysafe.generated.d.ts +0 -12
- package/dist/paysafe.generated.js +0 -87
- package/dist/playwright.d.ts +0 -3
- package/dist/playwright.js +0 -3
- package/dist/preflight-capabilities.generated.d.ts +0 -1253
- package/dist/preflight-capabilities.generated.js +0 -1929
- package/dist/preflight-catalog.json +0 -4595
- package/dist/preflight-playwright.d.ts +0 -34
- package/dist/preflight-playwright.js +0 -355
- package/dist/preflight-schemas.json +0 -1110
- package/dist/preflight.d.ts +0 -1
- package/dist/preflight.generated.d.ts +0 -1965
- package/dist/preflight.generated.js +0 -556
- package/dist/preflight.js +0 -2
- package/dist/preparation.d.ts +0 -31
- package/dist/preparation.js +0 -164
- package/dist/prepared-processor.d.ts +0 -10
- package/dist/prepared-processor.js +0 -122
- package/dist/recurly.generated.d.ts +0 -1
- package/dist/recurly.generated.js +0 -87
- package/dist/registry.d.ts +0 -76
- package/dist/registry.js +0 -296
- package/dist/spreedly.generated.d.ts +0 -10
- package/dist/spreedly.generated.js +0 -332
- package/dist/stripe-checkout.d.ts +0 -81
- package/dist/stripe-checkout.generated.d.ts +0 -82
- package/dist/stripe-checkout.generated.js +0 -1004
- package/dist/stripe-checkout.js +0 -140
- package/dist/substitute.d.ts +0 -38
- package/dist/substitute.js +0 -23
- package/dist/substitutions.generated.d.ts +0 -10
- package/dist/substitutions.generated.js +0 -66
- package/examples/existing-browser.mjs +0 -63
- package/examples/preflight/classify-direct.mjs +0 -21
- package/examples/preflight/classify-kernel.mjs +0 -30
- package/examples/preflight/inspect-browser.mjs +0 -44
- package/examples/preflight/kernel-native/README.md +0 -112
- package/examples/preflight/kernel-native/documented-adapters.json +0 -113
- package/examples/preflight/kernel-native/inventory.json +0 -233
- package/examples/preflight/kernel-native/qualification.mjs +0 -182
- package/examples/preflight/kernel-profile.empty.json +0 -11
- package/examples/preflight/mollie-hosted.observations.json +0 -23
- package/examples/preflight/mollie-hosted.result.json +0 -103
- package/examples/preflight/stripe-script.direct.result.json +0 -92
- package/examples/preflight/stripe-script.observations.json +0 -16
- package/examples/preflight/stripe-script.result.json +0 -87
package/dist/client.js
DELETED
|
@@ -1,1251 +0,0 @@
|
|
|
1
|
-
import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, } from './registry.js';
|
|
2
|
-
import { isMercadoTokenRequest, parseMercadoCheckoutContext, validateMercadoProcessorContext } from './mercado-checkout.generated.js';
|
|
3
|
-
import { matchesPreparedRequest, preparationMode, validPreparationEnvironment } from './prepared-processor.js';
|
|
4
|
-
import { hasOwnedShopMarker, parseOwnedShopOrder, parseOwnedShopReceipt } from './owned-shop.generated.js';
|
|
5
|
-
import { requestWithoutCard } from './card-fields.generated.js';
|
|
6
|
-
import { classifyStripeCheckoutRequest, encodeStripeCheckoutContext, hasStripeCheckoutMarker, parseStripeCheckoutContext, parseStripeCheckoutResponse, STRIPE_CHECKOUT_CONTEXT_HEADER, } from './stripe-checkout.generated.js';
|
|
7
|
-
/**
|
|
8
|
-
* The modes this SDK can finish. Asked for on syncRegistry (the API serves
|
|
9
|
-
* only recognizers in these modes, so a request this build cannot complete
|
|
10
|
-
* is never paused) and sent on every create.
|
|
11
|
-
*/
|
|
12
|
-
export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
|
|
13
|
-
/**
|
|
14
|
-
* Registry features this SDK honours, asked for on syncRegistry next to the
|
|
15
|
-
* modes. `card_fields`: it reads a recognizer's cardFields and claims only a
|
|
16
|
-
* request whose body carries the card, so the API may serve it recognizers
|
|
17
|
-
* whose endpoints also run without a card. `checkout_sessions`: it lets a
|
|
18
|
-
* request the recognizer marks as routine without a card (passWithoutCard)
|
|
19
|
-
* through ahead of its holds, so the API may serve Stripe's Checkout Session
|
|
20
|
-
* confirm, which hosted Checkout sends after an approval.
|
|
21
|
-
*/
|
|
22
|
-
export const SUPPORTED_REGISTRY_FEATURES = ['card_fields', 'checkout_sessions'];
|
|
23
|
-
/** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
|
|
24
|
-
export function validAmountInput(amount) {
|
|
25
|
-
if (typeof amount === 'number')
|
|
26
|
-
return Number.isSafeInteger(amount) && amount >= 0;
|
|
27
|
-
return typeof amount === 'string' && /^\d{1,15}\.\d{1,3}$/.test(amount.trim());
|
|
28
|
-
}
|
|
29
|
-
const AMOUNT_AUTHORITIES = ['processor', 'agent', 'page', 'none'];
|
|
30
|
-
export class CheckoutPreparationError extends Error {
|
|
31
|
-
preparationId;
|
|
32
|
-
reason;
|
|
33
|
-
constructor(preparationId, reason) {
|
|
34
|
-
super(`Checkout preparation unavailable: ${reason}`);
|
|
35
|
-
this.preparationId = preparationId;
|
|
36
|
-
this.reason = reason;
|
|
37
|
-
this.name = 'CheckoutPreparationError';
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
export class CardEncryptedError extends Error {
|
|
41
|
-
psp;
|
|
42
|
-
constructor(psp) {
|
|
43
|
-
super(`${psp} encrypts the card in-page; a paused request carries a blob, not a PAN. ` +
|
|
44
|
-
`Agentcard must run this PSP's client-side crypto in the vault.`);
|
|
45
|
-
this.psp = psp;
|
|
46
|
-
this.name = 'CardEncryptedError';
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* The registry requests a mode this SDK build cannot finish, before creation.
|
|
51
|
-
* A response in an unexpected mode after creation has an unknown payment
|
|
52
|
-
* outcome instead and raises PaymentOutcomeUnknownError.
|
|
53
|
-
*/
|
|
54
|
-
export class UnsupportedModeError extends Error {
|
|
55
|
-
mode;
|
|
56
|
-
constructor(mode) {
|
|
57
|
-
super(`the checkout requests mode "${mode}", which this version of @agent-cards/checkout cannot complete; upgrade the SDK.`);
|
|
58
|
-
this.mode = mode;
|
|
59
|
-
this.name = 'UnsupportedModeError';
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
export class ApprovalTimeoutError extends Error {
|
|
63
|
-
constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
|
|
64
|
-
}
|
|
65
|
-
export class CheckoutCancelledError extends Error {
|
|
66
|
-
constructor() { super('checkout cancelled locally before authorization creation'); this.name = 'CheckoutCancelledError'; }
|
|
67
|
-
}
|
|
68
|
-
export const RUNTIME_CANCEL_REASONS = ['merchant_request_aborted', 'merchant_never_retried'];
|
|
69
|
-
/** The payment may have reached the processor. Reconcile the merchant order before any new attempt. */
|
|
70
|
-
export class PaymentOutcomeUnknownError extends Error {
|
|
71
|
-
authorizationId;
|
|
72
|
-
reason;
|
|
73
|
-
constructor(authorizationId, reason) {
|
|
74
|
-
super(`payment outcome unknown${authorizationId ? ` for ${authorizationId}` : ''}: ${reason}; check the merchant order before retrying`);
|
|
75
|
-
this.authorizationId = authorizationId;
|
|
76
|
-
this.reason = reason;
|
|
77
|
-
this.name = 'PaymentOutcomeUnknownError';
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
export class ApprovalDeclinedError extends Error {
|
|
81
|
-
constructor(reason) { super(`user declined: ${reason}`); this.name = 'ApprovalDeclinedError'; }
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Agentcard refused the payment because the processor's amount did not match
|
|
85
|
-
* the amount the user was (or would have been) asked to approve. Nothing was
|
|
86
|
-
* charged. Two stages:
|
|
87
|
-
* - 'create': the intent already disagreed when the request was parked. No
|
|
88
|
-
* authorization exists (`authorizationId` is null). Per request, not per
|
|
89
|
-
* page: the merchant can still update the intent before confirmation, so
|
|
90
|
-
* the adapters keep intercepting and the next attempt is judged afresh.
|
|
91
|
-
* - 'pre_replay': the intent moved between create and the moment the
|
|
92
|
-
* cardholder's device would have sent the card. The authorization is
|
|
93
|
-
* `declined` with reason `amount_mismatch`.
|
|
94
|
-
*
|
|
95
|
-
* A decline in every structural sense (the adapters abort the paused request
|
|
96
|
-
* and quiet the page's retry exactly as for a person's "no"), so it extends
|
|
97
|
-
* ApprovalDeclinedError: code that already handles declines keeps working,
|
|
98
|
-
* and code that wants the numbers reads them here or branches on `code`.
|
|
99
|
-
*/
|
|
100
|
-
export class AmountMismatchError extends ApprovalDeclinedError {
|
|
101
|
-
authorizationId;
|
|
102
|
-
expectedCents;
|
|
103
|
-
actualCents;
|
|
104
|
-
currency;
|
|
105
|
-
actualCurrency;
|
|
106
|
-
stage;
|
|
107
|
-
code = 'amount_mismatch';
|
|
108
|
-
constructor(
|
|
109
|
-
/** The declined authorization, or null for a create-time refusal (no row exists). */
|
|
110
|
-
authorizationId,
|
|
111
|
-
/** What the user was asked to approve, smallest currency unit. */
|
|
112
|
-
expectedCents,
|
|
113
|
-
/** What the processor reported at the last check. */
|
|
114
|
-
actualCents,
|
|
115
|
-
/** ISO 4217 of the approved amount. */
|
|
116
|
-
currency,
|
|
117
|
-
/** ISO 4217 the processor reported (differs only on a currency change). */
|
|
118
|
-
actualCurrency = currency,
|
|
119
|
-
/** Which check refused it. */
|
|
120
|
-
stage = 'pre_replay') {
|
|
121
|
-
super('amount_mismatch');
|
|
122
|
-
this.authorizationId = authorizationId;
|
|
123
|
-
this.expectedCents = expectedCents;
|
|
124
|
-
this.actualCents = actualCents;
|
|
125
|
-
this.currency = currency;
|
|
126
|
-
this.actualCurrency = actualCurrency;
|
|
127
|
-
this.stage = stage;
|
|
128
|
-
this.name = 'AmountMismatchError';
|
|
129
|
-
this.message = `amount mismatch${authorizationId ? ` on ${authorizationId}` : ' at create'}: `
|
|
130
|
-
+ `the user was asked to approve ${expectedCents} ${currency}, the processor reports ${actualCents} ${actualCurrency}. Nothing was charged.`;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* The PaymentIntent behind this checkout can no longer be confirmed: it was
|
|
135
|
-
* already charged (succeeded), is being charged (processing), is authorized
|
|
136
|
-
* and on hold for the merchant to capture (requires_capture), or was
|
|
137
|
-
* canceled. Agentcard refused rather than replay a confirm at it; the
|
|
138
|
-
* authorization is `declined` with reason `intent_not_confirmable`.
|
|
139
|
-
* Deliberately NOT "nothing was charged": for three of those four, money has
|
|
140
|
-
* moved or is moving. Check the intent at Stripe before retrying.
|
|
141
|
-
*/
|
|
142
|
-
export class IntentNotConfirmableError extends ApprovalDeclinedError {
|
|
143
|
-
authorizationId;
|
|
144
|
-
code = 'intent_not_confirmable';
|
|
145
|
-
constructor(authorizationId) {
|
|
146
|
-
super('intent_not_confirmable');
|
|
147
|
-
this.authorizationId = authorizationId;
|
|
148
|
-
this.name = 'IntentNotConfirmableError';
|
|
149
|
-
this.message = `the PaymentIntent behind ${authorizationId} was already charged, is processing, or is on hold; `
|
|
150
|
-
+ 'it cannot be confirmed again. Check the intent at Stripe before retrying.';
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
function parseProcessorError(value) {
|
|
154
|
-
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
155
|
-
return null;
|
|
156
|
-
const fields = value;
|
|
157
|
-
const result = {};
|
|
158
|
-
const exact = (pattern, field) => pattern.exec(field)?.[0] === field;
|
|
159
|
-
for (const [key, field] of Object.entries(fields)) {
|
|
160
|
-
if (typeof field !== 'string')
|
|
161
|
-
return null;
|
|
162
|
-
if (key === 'reason' || key === 'source' || key === 'step') {
|
|
163
|
-
if (!exact(/^[a-z][a-z_]{0,63}$/, field))
|
|
164
|
-
return null;
|
|
165
|
-
result[key] = field;
|
|
166
|
-
}
|
|
167
|
-
else if (key === 'payment_id' || key === 'order_id') {
|
|
168
|
-
if (!exact(key === 'payment_id' ? /^pay_[A-Za-z0-9]{1,128}$/ : /^order_[A-Za-z0-9]{1,128}$/, field))
|
|
169
|
-
return null;
|
|
170
|
-
result[key] = field;
|
|
171
|
-
}
|
|
172
|
-
else
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
return Object.keys(result).length ? result : null;
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* The device reported a processor request rejection. A generic processor
|
|
179
|
-
* code does not establish an issuer decline or prove no money moved. Read
|
|
180
|
-
* the bounded processor evidence and reconcile the merchant before retrying.
|
|
181
|
-
* The authorization remains `declined` with reason `processor_refused` for
|
|
182
|
-
* compatibility; no successful processor response is handed to the merchant.
|
|
183
|
-
*/
|
|
184
|
-
export class ProcessorRefusedError extends ApprovalDeclinedError {
|
|
185
|
-
authorizationId;
|
|
186
|
-
pspErrorCode;
|
|
187
|
-
code = 'processor_refused';
|
|
188
|
-
processorError;
|
|
189
|
-
constructor(authorizationId, pspErrorCode, processorError = null) {
|
|
190
|
-
super('processor_refused');
|
|
191
|
-
this.authorizationId = authorizationId;
|
|
192
|
-
this.pspErrorCode = pspErrorCode;
|
|
193
|
-
this.name = 'ProcessorRefusedError';
|
|
194
|
-
this.processorError = parseProcessorError(processorError);
|
|
195
|
-
this.message = `the processor rejected the payment request on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Check the merchant payment status before retrying.`;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* A non-2xx from the Agentcard API, carrying the status so callers can tell a
|
|
200
|
-
* misconfiguration from a blip. The adapters use this to decide whether
|
|
201
|
-
* retrying is worth anything: a 404 `connection_not_found` will answer the same
|
|
202
|
-
* way forever, while a 429 or a 502 will not.
|
|
203
|
-
*/
|
|
204
|
-
/**
|
|
205
|
-
* The company's preset refused the purchase. The company that runs this
|
|
206
|
-
* integration put rules on its users' Vault purchases (a merchant list, a
|
|
207
|
-
* currency, a spend cap, a time window); this purchase is outside them.
|
|
208
|
-
* Nothing was charged. Two stages:
|
|
209
|
-
* - 'create': refused before any authorization existed (`authorizationId`
|
|
210
|
-
* is null); nobody was asked to approve. Per purchase, not per page: the
|
|
211
|
-
* next request on the same page is judged afresh.
|
|
212
|
-
* - 'pre_replay': refused right before the cardholder's device would have
|
|
213
|
-
* sent the card; the authorization is `declined` with `code` as reason.
|
|
214
|
-
* `code` is the rule's reason (merchant_denied, currency_denied,
|
|
215
|
-
* spend_rate_exceeded, time_window_denied, ...), `message` the rule's own
|
|
216
|
-
* statement with the company's next step, `preset` the version that judged
|
|
217
|
-
* it. When several presets refused the same purchase, `code`, `preset`,
|
|
218
|
-
* `rule` and `attachment` are the first, and `refusals` names every one. A
|
|
219
|
-
* decline in every structural sense (the adapters quiet the page's retry as
|
|
220
|
-
* for a person's "no"), so it extends ApprovalDeclinedError.
|
|
221
|
-
*/
|
|
222
|
-
export class PresetRefusedError extends ApprovalDeclinedError {
|
|
223
|
-
authorizationId;
|
|
224
|
-
code;
|
|
225
|
-
detail;
|
|
226
|
-
preset;
|
|
227
|
-
rule;
|
|
228
|
-
stage;
|
|
229
|
-
attachment;
|
|
230
|
-
/** The preset's name. */
|
|
231
|
-
presetName;
|
|
232
|
-
/** Every preset that refused, each with its attachment, rule, code and statement; one entry when one refused. */
|
|
233
|
-
refusals;
|
|
234
|
-
constructor(authorizationId, code, detail, preset, rule, stage,
|
|
235
|
-
/** The stored card the preset that refused is attached to, with its last four digits. */
|
|
236
|
-
attachment = null, refusals = []) {
|
|
237
|
-
super(code);
|
|
238
|
-
this.authorizationId = authorizationId;
|
|
239
|
-
this.code = code;
|
|
240
|
-
this.detail = detail;
|
|
241
|
-
this.preset = preset;
|
|
242
|
-
this.rule = rule;
|
|
243
|
-
this.stage = stage;
|
|
244
|
-
this.attachment = attachment;
|
|
245
|
-
this.name = 'PresetRefusedError';
|
|
246
|
-
this.presetName = preset?.name ?? null;
|
|
247
|
-
this.refusals = refusals.length ? refusals : preset ? [{ preset, attachment, rule, code, detail }] : [];
|
|
248
|
-
const others = this.refusals.slice(1).map((r) => `"${r.preset.name}"`);
|
|
249
|
-
const also = others.length ? ` Also refused by ${others.length === 1 ? 'preset' : 'presets'} ${others.join(', ')}.` : '';
|
|
250
|
-
this.message = `refused by the company preset${preset ? ` "${preset.name}"` : ''} (${code}${stage === 'create' ? ', before any authorization was created' : ''}): ${detail ?? 'this purchase is outside the rules the company set'}${also}`;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
/** A decline reason only the company presets stamp (see PresetRefusedError). */
|
|
254
|
-
export const PRESET_REFUSAL_REASONS = new Set([
|
|
255
|
-
'spend_total_exceeded', 'spend_rate_exceeded', 'spend_rate_unknown', 'amount_unknown', 'rate_unavailable',
|
|
256
|
-
'category_denied', 'category_unknown', 'merchant_denied', 'merchant_unknown',
|
|
257
|
-
'geo_denied', 'geo_unknown', 'currency_denied', 'currency_unknown',
|
|
258
|
-
'time_window_denied', 'surface_denied', 'surface_unknown',
|
|
259
|
-
]);
|
|
260
|
-
function presetOf(v) {
|
|
261
|
-
if (!v || typeof v !== 'object')
|
|
262
|
-
return null;
|
|
263
|
-
const p = v;
|
|
264
|
-
if (typeof p.id !== 'string' || typeof p.version !== 'number' || typeof p.name !== 'string')
|
|
265
|
-
return null;
|
|
266
|
-
return { id: p.id, version: p.version, name: p.name };
|
|
267
|
-
}
|
|
268
|
-
function attachmentOf(v) {
|
|
269
|
-
if (!v || typeof v !== 'object')
|
|
270
|
-
return null;
|
|
271
|
-
const a = v;
|
|
272
|
-
if (a.kind !== 'card')
|
|
273
|
-
return null;
|
|
274
|
-
return { kind: a.kind, targetId: typeof a.target_id === 'string' ? a.target_id : null, last4: typeof a.last4 === 'string' ? a.last4 : null };
|
|
275
|
-
}
|
|
276
|
-
/** The `refusals` list of a refusal envelope: every entry that names a preset. */
|
|
277
|
-
function refusalsOf(v) {
|
|
278
|
-
if (!Array.isArray(v))
|
|
279
|
-
return [];
|
|
280
|
-
const out = [];
|
|
281
|
-
for (const item of v) {
|
|
282
|
-
if (!item || typeof item !== 'object')
|
|
283
|
-
continue;
|
|
284
|
-
const r = item;
|
|
285
|
-
const preset = presetOf(r.preset);
|
|
286
|
-
if (!preset || typeof r.reason !== 'string')
|
|
287
|
-
continue;
|
|
288
|
-
out.push({ preset, attachment: attachmentOf(r.attachment), rule: typeof r.rule === 'string' ? r.rule : null, code: r.reason, detail: typeof r.message === 'string' ? r.message : null });
|
|
289
|
-
}
|
|
290
|
-
return out;
|
|
291
|
-
}
|
|
292
|
-
export class CheckoutApiError extends Error {
|
|
293
|
-
status;
|
|
294
|
-
path;
|
|
295
|
-
bodyText;
|
|
296
|
-
/** The API's stable error code (`{ error: { code } }`), or null when the body carried none. */
|
|
297
|
-
code;
|
|
298
|
-
/**
|
|
299
|
-
* The rest of the error envelope. A 409 `amount_mismatch` from create
|
|
300
|
-
* carries `expected_cents`, `actual_cents`, `currency`, `actual_currency`;
|
|
301
|
-
* an `amount_unverifiable` carries `reason`; an `intent_not_confirmable`
|
|
302
|
-
* carries `intent_status`.
|
|
303
|
-
*/
|
|
304
|
-
details;
|
|
305
|
-
constructor(status, path, bodyText) {
|
|
306
|
-
super(`agentcard ${path} -> ${status} ${bodyText}`);
|
|
307
|
-
this.status = status;
|
|
308
|
-
this.path = path;
|
|
309
|
-
this.bodyText = bodyText;
|
|
310
|
-
this.name = 'CheckoutApiError';
|
|
311
|
-
const envelope = parseErrorEnvelope(bodyText);
|
|
312
|
-
this.code = envelope.code;
|
|
313
|
-
this.details = envelope.details;
|
|
314
|
-
}
|
|
315
|
-
/**
|
|
316
|
-
* True when repeating this exact call cannot succeed: a misconfiguration
|
|
317
|
-
* (4xx other than 429). NOT a 409 `amount_mismatch`: Stripe lets a merchant
|
|
318
|
-
* update an intent's amount until it is confirmed, so the next request on
|
|
319
|
-
* the same page may well agree. That one is a per-request failure, and
|
|
320
|
-
* authorize() surfaces it as AmountMismatchError before an adapter ever
|
|
321
|
-
* sees it here. NOT a 409 `duplicate_submission` either: the household
|
|
322
|
-
* already has, or already answered, the prompt for this submission, and
|
|
323
|
-
* once that prior authorization is declined or expired the same form is a
|
|
324
|
-
* new question. The adapters quiet the page's re-post the way they quiet a
|
|
325
|
-
* decline instead of latching the attachment.
|
|
326
|
-
*/
|
|
327
|
-
get permanent() {
|
|
328
|
-
if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission')
|
|
329
|
-
return false;
|
|
330
|
-
// Nor a refusal by the company preset (a 403 carrying `preset` in the
|
|
331
|
-
// envelope): the company's rules refused THIS purchase, at this merchant,
|
|
332
|
-
// for this amount, at this hour. The next one on the same page may pass,
|
|
333
|
-
// so the page is quieted like a decline, never latched.
|
|
334
|
-
if (this.details.preset && typeof this.details.preset === 'object')
|
|
335
|
-
return false;
|
|
336
|
-
return this.status >= 400 && this.status < 500 && this.status !== 429;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
function parseErrorEnvelope(bodyText) {
|
|
340
|
-
try {
|
|
341
|
-
const parsed = JSON.parse(bodyText);
|
|
342
|
-
const err = parsed && typeof parsed === 'object' ? parsed.error : null;
|
|
343
|
-
if (err && typeof err === 'object') {
|
|
344
|
-
const { code, ...details } = err;
|
|
345
|
-
return { code: typeof code === 'string' ? code : null, details };
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
catch {
|
|
349
|
-
// Not a JSON envelope (a proxy page, an empty body): no code to carry.
|
|
350
|
-
}
|
|
351
|
-
return { code: null, details: {} };
|
|
352
|
-
}
|
|
353
|
-
/**
|
|
354
|
-
* A URL as it may appear in an error message: origin + path only. A paused
|
|
355
|
-
* request's URL can carry a client secret in its query string, and error
|
|
356
|
-
* messages travel further than anyone intends (onEvent, logs, crash reports).
|
|
357
|
-
*/
|
|
358
|
-
export function redactUrl(raw) {
|
|
359
|
-
try {
|
|
360
|
-
const u = new URL(raw);
|
|
361
|
-
return `${u.origin}${u.pathname}`;
|
|
362
|
-
}
|
|
363
|
-
catch {
|
|
364
|
-
return raw.split(/[?#]/)[0];
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
/** Default backoff for a 502 amount_unverifiable at create: two retries, then give up. */
|
|
368
|
-
const UNVERIFIABLE_RETRY_DELAYS_MS = [500, 1500];
|
|
369
|
-
const PREPARATION_READ_RETRY_DELAYS_MS = [250, 500];
|
|
370
|
-
const TRANSIENT_READ_NETWORK_CODES = new Set([
|
|
371
|
-
'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN',
|
|
372
|
-
'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT',
|
|
373
|
-
]);
|
|
374
|
-
function transientReadNetworkError(error) {
|
|
375
|
-
if (!(error instanceof Error) || error.name === 'AbortError' || error.name === 'TimeoutError' || error instanceof SyntaxError)
|
|
376
|
-
return false;
|
|
377
|
-
const code = error.cause?.code ?? error.code;
|
|
378
|
-
return typeof code === 'string' && TRANSIENT_READ_NETWORK_CODES.has(code);
|
|
379
|
-
}
|
|
380
|
-
export class VaultClient {
|
|
381
|
-
opts;
|
|
382
|
-
baseUrl;
|
|
383
|
-
fetch;
|
|
384
|
-
pollIntervalMs;
|
|
385
|
-
unverifiableRetryDelaysMs;
|
|
386
|
-
registry;
|
|
387
|
-
preparations = new WeakSet();
|
|
388
|
-
usedPreparations = new WeakSet();
|
|
389
|
-
constructor(opts) {
|
|
390
|
-
this.opts = opts;
|
|
391
|
-
this.baseUrl = (opts.baseUrl ?? 'https://api.agentcard.sh').replace(/\/$/, '');
|
|
392
|
-
this.fetch = opts.fetchImpl ?? globalThis.fetch;
|
|
393
|
-
this.registry = opts.registry ?? BUILTIN_REGISTRY;
|
|
394
|
-
this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
|
|
395
|
-
this.unverifiableRetryDelaysMs = opts.unverifiableRetryDelaysMs ?? UNVERIFIABLE_RETRY_DELAYS_MS;
|
|
396
|
-
}
|
|
397
|
-
/** Refresh recognizers from the API so new PSPs work without a redeploy. */
|
|
398
|
-
async syncRegistry() {
|
|
399
|
-
// Never break checkout over a registry fetch — an auth blip or a bad
|
|
400
|
-
// response leaves the built-in recognizers in place.
|
|
401
|
-
//
|
|
402
|
-
// ?modes= is capability negotiation: the API serves only recognizers in
|
|
403
|
-
// the modes this build can finish, so a processor whose flow this SDK
|
|
404
|
-
// does not speak is never armed (a pause it cannot complete becomes an
|
|
405
|
-
// abort, which would dead-end the checkout). `mode` rides through the
|
|
406
|
-
// spread verbatim.
|
|
407
|
-
let raw;
|
|
408
|
-
try {
|
|
409
|
-
raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}&features=${SUPPORTED_REGISTRY_FEATURES.join(',')}`);
|
|
410
|
-
}
|
|
411
|
-
catch {
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
if (!Array.isArray(raw))
|
|
415
|
-
return;
|
|
416
|
-
this.registry = raw.map((e) => ({
|
|
417
|
-
...e,
|
|
418
|
-
match: new RegExp(e.match, 'i'),
|
|
419
|
-
passthroughHeaders: e.passthroughHeaders.map((h) => new RegExp(h, 'i')),
|
|
420
|
-
}));
|
|
421
|
-
}
|
|
422
|
-
/** True when this request is a card tokenization we can take over. */
|
|
423
|
-
isCardRequest(url, method = 'POST') {
|
|
424
|
-
return method.toUpperCase() === 'POST' && findRecognizer(url, this.registry) !== null;
|
|
425
|
-
}
|
|
426
|
-
/**
|
|
427
|
-
* What happens to a card request (by URL) whose body carries no card: null
|
|
428
|
-
* when it carries one, so it is a card request as usual; 'continue' when the
|
|
429
|
-
* recognizer marks the endpoint as one where such requests are routine and
|
|
430
|
-
* never ours; 'refuse' otherwise, such as a Stripe confirmation paying with
|
|
431
|
-
* a method this checkout never approved. The adapters ask only after their
|
|
432
|
-
* holds, so a request that would reuse an approved token is refused there
|
|
433
|
-
* first. A body that could not be read is not judged here.
|
|
434
|
-
*/
|
|
435
|
-
withoutCard(url, body) {
|
|
436
|
-
if (body == null)
|
|
437
|
-
return null;
|
|
438
|
-
const rec = findRecognizer(url, this.registry);
|
|
439
|
-
return rec ? requestWithoutCard(rec, url, body) : null;
|
|
440
|
-
}
|
|
441
|
-
/**
|
|
442
|
-
* How the card would reach the processor on this request (`token`, `cse`
|
|
443
|
-
* or `hosted_form`; absent on the entry means `token`), or null when the
|
|
444
|
-
* registry does not recognize it. The adapters read it to decide whether an
|
|
445
|
-
* approval outlives the page's own request: a hosted form is a navigation
|
|
446
|
-
* and cannot.
|
|
447
|
-
*/
|
|
448
|
-
checkoutModeOf(url, method = 'POST') {
|
|
449
|
-
if (method.toUpperCase() !== 'POST')
|
|
450
|
-
return null;
|
|
451
|
-
const rec = findRecognizer(url, this.registry);
|
|
452
|
-
return rec ? rec.mode ?? 'token' : null;
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
|
-
* Glob url patterns covering every host the CURRENT registry can send a card
|
|
456
|
-
* to — what a raw CDP connection has to hand `Fetch.enable` before any card
|
|
457
|
-
* request can be paused. attachToCdp calls this for you.
|
|
458
|
-
*
|
|
459
|
-
* Call syncRegistry() FIRST: without it these cover only the built-in PSPs,
|
|
460
|
-
* and a request the API knows about is never paused at all. Deliberately
|
|
461
|
-
* WIDER than the recognizers themselves (a glob cannot express an anchored
|
|
462
|
-
* host regex); isCardRequest is the exact check and runs on every request
|
|
463
|
-
* these patterns pause.
|
|
464
|
-
*/
|
|
465
|
-
cardUrlPatterns() {
|
|
466
|
-
return deriveCardUrlPatterns(this.registry);
|
|
467
|
-
}
|
|
468
|
-
/** Wait for real device approval before the caller starts native tokenization. */
|
|
469
|
-
async prepareCheckout(input) {
|
|
470
|
-
input = { ...input };
|
|
471
|
-
const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
|
|
472
|
-
if (!validPreparationEnvironment(input.psp, input.environment))
|
|
473
|
-
throw fail('unsupported_processor');
|
|
474
|
-
if (!validAmountInput(input.amount) || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
|
|
475
|
-
throw fail('amount_required');
|
|
476
|
-
const origin = new URL(input.merchantOrigin);
|
|
477
|
-
if (!(origin.protocol === 'https:' || (origin.protocol === 'http:' && origin.hostname === 'localhost')) || origin.origin !== input.merchantOrigin)
|
|
478
|
-
throw fail('merchant_origin_invalid');
|
|
479
|
-
if (!input.checkoutKey || !input.user || !input.merchant)
|
|
480
|
-
throw fail('checkout_context_required');
|
|
481
|
-
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
482
|
-
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
483
|
-
throw fail('timeout_invalid');
|
|
484
|
-
const signal = input.signal ? AbortSignal.any([input.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
|
|
485
|
-
if (signal.aborted)
|
|
486
|
-
throw fail('cancelled');
|
|
487
|
-
let id = null;
|
|
488
|
-
let ready = false;
|
|
489
|
-
try {
|
|
490
|
-
// Drain a sent creation even after caller cancellation to retire its ID.
|
|
491
|
-
// The separate stop signal prevents dispatch after a slow OAuth exchange.
|
|
492
|
-
const created = await this.post('/v2/checkout/preparations', {
|
|
493
|
-
user: input.user, merchant: input.merchant, amount: input.amount, currency: input.currency.toLowerCase(),
|
|
494
|
-
...(input.cardId ? { card_id: input.cardId } : {}), psp: input.psp, mode: preparationMode(input.psp),
|
|
495
|
-
environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
|
|
496
|
-
}, AbortSignal.timeout(30_000), signal);
|
|
497
|
-
if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
|
|
498
|
-
throw fail('create_unconfirmed');
|
|
499
|
-
const preparationId = created.id;
|
|
500
|
-
id = preparationId;
|
|
501
|
-
try {
|
|
502
|
-
Promise.resolve(input.onPreparationCreated?.(preparationId)).catch(() => { });
|
|
503
|
-
}
|
|
504
|
-
catch { /* observer only */ }
|
|
505
|
-
if (signal.aborted)
|
|
506
|
-
throw fail('cancelled', id);
|
|
507
|
-
if (typeof created.approvalUrl !== 'string')
|
|
508
|
-
throw fail('approval_url_missing', id);
|
|
509
|
-
try {
|
|
510
|
-
Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
|
|
511
|
-
}
|
|
512
|
-
catch { /* observer only */ }
|
|
513
|
-
while (!signal.aborted) {
|
|
514
|
-
const state = await this.readPreparation(id, signal);
|
|
515
|
-
if (signal.aborted)
|
|
516
|
-
throw fail('cancelled', id);
|
|
517
|
-
if (state?.id !== id)
|
|
518
|
-
throw fail('status_unconfirmed', id);
|
|
519
|
-
if (state.status === 'ready') {
|
|
520
|
-
const expiry = Date.parse(state.ready_expires_at);
|
|
521
|
-
if (!Number.isFinite(expiry) || expiry <= Date.now() || typeof state.card_id !== 'string' || !state.card_id
|
|
522
|
-
|| state.payment_status !== 'not_started' || state.amount_authority !== 'agent'
|
|
523
|
-
|| state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
|
|
524
|
-
|| !Number.isSafeInteger(state.amount) || (typeof input.amount === 'number' && state.amount !== input.amount) || state.currency !== input.currency.toLowerCase()
|
|
525
|
-
|| state.psp !== input.psp || state.mode !== preparationMode(input.psp) || state.environment !== input.environment
|
|
526
|
-
|| state.checkout_key !== input.checkoutKey)
|
|
527
|
-
throw fail('ready_unconfirmed', id);
|
|
528
|
-
const prepared = Object.freeze({
|
|
529
|
-
id: preparationId, status: 'ready', psp: input.psp, environment: input.environment, mode: preparationMode(input.psp), expiresAt: state.ready_expires_at,
|
|
530
|
-
cardId: state.card_id, user: input.user, merchant: input.merchant, amount: state.amount,
|
|
531
|
-
amountDisplay: typeof state.amount_display === 'string' ? state.amount_display : null,
|
|
532
|
-
currency: input.currency.toLowerCase(), merchantOrigin: input.merchantOrigin, checkoutKey: input.checkoutKey,
|
|
533
|
-
paymentStatus: 'not_started', amountAuthority: 'agent',
|
|
534
|
-
});
|
|
535
|
-
this.preparations.add(prepared);
|
|
536
|
-
ready = true;
|
|
537
|
-
return prepared;
|
|
538
|
-
}
|
|
539
|
-
if (state.status !== 'awaiting_approval')
|
|
540
|
-
throw fail(['cancelled', 'expired', 'bound'].includes(state.status) ? state.status : 'status_unconfirmed', id);
|
|
541
|
-
await interruptibleSleep(this.pollIntervalMs, signal);
|
|
542
|
-
}
|
|
543
|
-
throw fail('cancelled', id);
|
|
544
|
-
}
|
|
545
|
-
catch (error) {
|
|
546
|
-
if (error instanceof CheckoutPreparationError)
|
|
547
|
-
throw error;
|
|
548
|
-
throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
|
|
549
|
-
}
|
|
550
|
-
finally {
|
|
551
|
-
if (id && !ready)
|
|
552
|
-
await this.cancelPreparation(id).catch(() => { });
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
/** Cancel only an unconsumed preparation; a bound request is reconciled separately. */
|
|
556
|
-
async cancelPreparation(id) {
|
|
557
|
-
if (!/^cprep_[A-Za-z0-9_-]{1,128}$/.test(id))
|
|
558
|
-
throw new CheckoutPreparationError(null, 'id_invalid');
|
|
559
|
-
const state = await this.post(`/v2/checkout/preparations/${id}/cancel`, {}, AbortSignal.timeout(5_000));
|
|
560
|
-
if (state?.id !== id || !['cancelled', 'expired'].includes(state.status))
|
|
561
|
-
throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
|
|
562
|
-
}
|
|
563
|
-
async observePreparation(id, guidance, reason) {
|
|
564
|
-
try {
|
|
565
|
-
await this.post(`/v2/checkout/preparations/${id}/observe`, {
|
|
566
|
-
form_guidance: guidance, ...(reason ? { reason } : {}),
|
|
567
|
-
}, AbortSignal.timeout(5_000));
|
|
568
|
-
}
|
|
569
|
-
catch {
|
|
570
|
-
this.opts.onEvent?.({ type: 'telemetry_skipped', detail: 'form_guidance' });
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
async reportDuplicateGuard(authorizationId) {
|
|
574
|
-
try {
|
|
575
|
-
await this.post(`/v2/checkout/authorizations/${authorizationId}/duplicate-guard`, {
|
|
576
|
-
guard: 'hosted_form_repeat',
|
|
577
|
-
}, AbortSignal.timeout(5_000));
|
|
578
|
-
}
|
|
579
|
-
catch {
|
|
580
|
-
this.opts.onEvent?.({ type: 'telemetry_skipped', detail: 'duplicate_guard' });
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
/**
|
|
584
|
-
* Hand us a paused tokenization request. We ask the cardholder to approve,
|
|
585
|
-
* their device supplies the card and calls the merchant, and you get back the
|
|
586
|
-
* response to replay into the browser. Your process never sees a card.
|
|
587
|
-
*/
|
|
588
|
-
async authorize(input) {
|
|
589
|
-
const mercadoContext = input.request.mercado_checkout === undefined ? undefined : parseMercadoCheckoutContext(input.request.mercado_checkout);
|
|
590
|
-
if (mercadoContext && (!isMercadoTokenRequest(input.request.url, input.request.method)
|
|
591
|
-
|| input.executionMode === 'autopilot' || input.grantId))
|
|
592
|
-
throw new Error('mercado_checkout_context_invalid');
|
|
593
|
-
const nativeCheckout = hasStripeCheckoutMarker(input.request);
|
|
594
|
-
if (input.stripeCheckoutEnvironment !== undefined && !nativeCheckout)
|
|
595
|
-
throw new Error('Native Stripe Checkout environment requires its bound request.');
|
|
596
|
-
const checkoutContext = nativeCheckout ? parseStripeCheckoutContext(input.request) : null;
|
|
597
|
-
if (nativeCheckout) {
|
|
598
|
-
if (!checkoutContext || input.preparation || input.executionMode !== 'autopilot' || !input.grantId
|
|
599
|
-
|| !Number.isSafeInteger(input.amount) || Number(input.amount) <= 0 || input.currency !== 'usd'
|
|
600
|
-
|| input.merchantOrigin !== 'https://checkout.stripe.com')
|
|
601
|
-
throw new Error('Native Stripe Checkout requires an explicit mode-bound autopilot configuration.');
|
|
602
|
-
const phase = classifyStripeCheckoutRequest(input.request, {
|
|
603
|
-
environment: input.stripeCheckoutEnvironment, sessionId: checkoutContext.session_id, syntheticPaymentMethodId: checkoutContext.synthetic_payment_method_id,
|
|
604
|
-
amountCents: input.amount,
|
|
605
|
-
});
|
|
606
|
-
if (phase?.phase !== 'final')
|
|
607
|
-
throw new Error('Native Stripe Checkout request is not prepared.');
|
|
608
|
-
}
|
|
609
|
-
const ownedShop = hasOwnedShopMarker(input.request);
|
|
610
|
-
const shopOrder = ownedShop ? parseOwnedShopOrder(input.request) : null;
|
|
611
|
-
if (ownedShop) {
|
|
612
|
-
if (!shopOrder || input.preparation || input.executionMode === 'user_approval' ||
|
|
613
|
-
!input.merchantOrigin || (input.amount != null && input.amount !== shopOrder.amount &&
|
|
614
|
-
!(typeof input.amount === 'string' && Number(input.amount) * 100 === shopOrder.amount)) ||
|
|
615
|
-
(input.currency !== undefined && input.currency.toLowerCase() !== shopOrder.currency))
|
|
616
|
-
throw new Error('The shop order cannot use this checkout configuration.');
|
|
617
|
-
input = { ...input, amount: shopOrder.amount, currency: shopOrder.currency };
|
|
618
|
-
}
|
|
619
|
-
if (input.executionMode !== undefined && !['user_approval', 'autopilot'].includes(input.executionMode))
|
|
620
|
-
throw new Error('Unsupported checkout execution mode.');
|
|
621
|
-
if (input.grantId !== undefined && !/^apg_[A-Za-z0-9_-]{1,128}$/.test(input.grantId))
|
|
622
|
-
throw new Error('Invalid autopilot grant ID.');
|
|
623
|
-
if (input.preparation && (input.executionMode === 'autopilot' || input.grantId))
|
|
624
|
-
throw new Error('A device preparation cannot also select autopilot.');
|
|
625
|
-
if (input.merchantOrigin !== undefined && !input.preparation) {
|
|
626
|
-
const merchantUrl = new URL(input.merchantOrigin);
|
|
627
|
-
if (merchantUrl.protocol !== 'https:' || merchantUrl.origin !== input.merchantOrigin)
|
|
628
|
-
throw new Error('merchantOrigin must be an exact HTTPS origin.');
|
|
629
|
-
}
|
|
630
|
-
const preparation = input.preparation;
|
|
631
|
-
if (preparation) {
|
|
632
|
-
if (!this.preparations.has(preparation) || this.usedPreparations.has(preparation))
|
|
633
|
-
throw new CheckoutPreparationError(preparation.id ?? null, 'already_used_or_foreign');
|
|
634
|
-
// Consume locally before any await, including OAuth, and never recycle it.
|
|
635
|
-
this.usedPreparations.add(preparation);
|
|
636
|
-
if (Date.parse(preparation.expiresAt) <= Date.now())
|
|
637
|
-
throw new CheckoutPreparationError(preparation.id, 'expired');
|
|
638
|
-
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
|
-
|| input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
|
|
640
|
-
|| !matchesPreparedRequest(preparation.psp, preparation.environment, input.request.url, input.request.method ?? 'POST', input.request.body, input.request.headers))
|
|
641
|
-
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
642
|
-
}
|
|
643
|
-
if (input.signal?.aborted)
|
|
644
|
-
throw new CheckoutCancelledError();
|
|
645
|
-
if (input.merchantSignal?.aborted)
|
|
646
|
-
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
647
|
-
const rec = nativeCheckout ? this.registry.find(entry => entry.psp === 'stripe' && (entry.mode ?? 'token') === 'token')
|
|
648
|
-
: findRecognizer(input.request.url, this.registry);
|
|
649
|
-
if (!rec)
|
|
650
|
-
throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
|
|
651
|
-
// A client-side-encrypted processor is only takeable when its entry says
|
|
652
|
-
// the vault produces that ciphertext itself (mode cse); a bare
|
|
653
|
-
// clientSideEncrypted entry (an older registry, a hand-built one) still
|
|
654
|
-
// refuses here, exactly as before.
|
|
655
|
-
const mode = rec.mode ?? 'token';
|
|
656
|
-
if (preparation && (rec.psp !== preparation.psp || mode !== preparationMode(preparation.psp)))
|
|
657
|
-
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
658
|
-
if (rec.clientSideEncrypted && mode !== 'cse')
|
|
659
|
-
throw new CardEncryptedError(rec.psp);
|
|
660
|
-
if (!SUPPORTED_MODES.includes(mode))
|
|
661
|
-
throw new UnsupportedModeError(mode);
|
|
662
|
-
// Say no rather than coerce. isCardRequest only ever pauses a POST, and the
|
|
663
|
-
// service stores the replay template with method 'POST' hardcoded — so a
|
|
664
|
-
// caller handing in a PUT or GET would get it silently rewritten and the
|
|
665
|
-
// cardholder's device would replay something the caller never asked for.
|
|
666
|
-
// A direct caller deserves to be told, not surprised.
|
|
667
|
-
const method = (input.request.method ?? 'POST').toUpperCase();
|
|
668
|
-
if (method !== 'POST') {
|
|
669
|
-
throw new Error(`checkout authorization is POST-only; got ${method} for ${redactUrl(input.request.url)}. ` +
|
|
670
|
-
'A non-POST tokenizer needs recognizer support before it can be intercepted.');
|
|
671
|
-
}
|
|
672
|
-
// The amount is a hint: a pair or nothing. Refuse half a pair here, before
|
|
673
|
-
// a network call: the API would too, and a retry loop cannot fix a
|
|
674
|
-
// missing field. The processor's own amount is read by Agentcard.
|
|
675
|
-
const hasAmount = input.amount != null;
|
|
676
|
-
const hasCurrency = typeof input.currency === 'string' && input.currency.length > 0;
|
|
677
|
-
if (hasAmount !== hasCurrency) {
|
|
678
|
-
throw new Error('amount and currency go together: pass both or neither.');
|
|
679
|
-
}
|
|
680
|
-
if (hasAmount && !validAmountInput(input.amount)) {
|
|
681
|
-
throw new Error('amount is an integer in the currency\'s smallest unit (2306 for $23.06), or a decimal string in normal units ("23.06").');
|
|
682
|
-
}
|
|
683
|
-
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
684
|
-
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
685
|
-
throw new Error('timeoutMs must be a positive integer no larger than 2147483647.');
|
|
686
|
-
const deadline = Date.now() + timeoutMs;
|
|
687
|
-
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
688
|
-
const operationSignal = input.signal ? AbortSignal.any([input.signal, timeoutSignal]) : timeoutSignal;
|
|
689
|
-
let created;
|
|
690
|
-
const payload = {
|
|
691
|
-
user: input.user,
|
|
692
|
-
merchant: input.merchant,
|
|
693
|
-
// snake_case on the wire; camelCase is this SDK's convention.
|
|
694
|
-
...(hasAmount ? { amount: input.amount, currency: input.currency } : {}),
|
|
695
|
-
...(input.pageAmount ? { page_amount: input.pageAmount.amount, page_currency: input.pageAmount.currency } : {}),
|
|
696
|
-
...(Number.isInteger(input.payToInterceptMs) && input.payToInterceptMs >= 0
|
|
697
|
-
? { pay_to_intercept_ms: input.payToInterceptMs }
|
|
698
|
-
: {}),
|
|
699
|
-
psp: rec.psp,
|
|
700
|
-
// The mode this request will be finished in. The API checks it against
|
|
701
|
-
// the recognizer and refuses a disagreement before a row exists.
|
|
702
|
-
mode,
|
|
703
|
-
...(input.cardId ? { cardId: input.cardId } : {}),
|
|
704
|
-
...(input.executionMode ? { execution_mode: input.executionMode } : {}),
|
|
705
|
-
...(input.grantId ? { grant_id: input.grantId } : {}),
|
|
706
|
-
...(input.merchantOrigin && !preparation ? { merchant_origin: input.merchantOrigin } : {}),
|
|
707
|
-
// A prepared checkout already carries the page origin as merchant_origin.
|
|
708
|
-
...(preparation
|
|
709
|
-
? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin, form_guidance: 'filled' }
|
|
710
|
-
: input.pageOrigin ? { checkout_origin: input.pageOrigin } : {}),
|
|
711
|
-
request: {
|
|
712
|
-
url: input.request.url,
|
|
713
|
-
method: input.request.method,
|
|
714
|
-
headers: { ...pickHeaders(input.request.headers, rec.passthroughHeaders),
|
|
715
|
-
...(checkoutContext ? { [STRIPE_CHECKOUT_CONTEXT_HEADER]: encodeStripeCheckoutContext(checkoutContext) } : {}) },
|
|
716
|
-
body: input.request.body,
|
|
717
|
-
...(mercadoContext ? { mercado_checkout: mercadoContext } : {}),
|
|
718
|
-
},
|
|
719
|
-
};
|
|
720
|
-
try {
|
|
721
|
-
created = await this.createAuthorization(payload, input.currency, AbortSignal.any([operationSignal, AbortSignal.timeout(30_000)]), input.merchantSignal);
|
|
722
|
-
}
|
|
723
|
-
catch (error) {
|
|
724
|
-
if (error instanceof PaymentOutcomeUnknownError) {
|
|
725
|
-
if (preparation && !error.authorizationId)
|
|
726
|
-
throw new PaymentOutcomeUnknownError(await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated), error.reason);
|
|
727
|
-
throw error;
|
|
728
|
-
}
|
|
729
|
-
if (preparation && error instanceof CheckoutApiError && error.code === 'preparation_bound') {
|
|
730
|
-
const id = typeof error.details.authorization_id === 'string' && /^cauth_[A-Za-z0-9_-]+$/.test(error.details.authorization_id) ? error.details.authorization_id : null;
|
|
731
|
-
if (id) {
|
|
732
|
-
try {
|
|
733
|
-
Promise.resolve(input.onAuthorizationCreated?.(id)).catch(() => { });
|
|
734
|
-
}
|
|
735
|
-
catch { /* observer only */ }
|
|
736
|
-
}
|
|
737
|
-
throw new PaymentOutcomeUnknownError(id, 'preparation_already_bound');
|
|
738
|
-
}
|
|
739
|
-
// A missing answer or generic 5xx can hide a committed row and a delivered approval link.
|
|
740
|
-
// Only the documented pre-create read-back errors prove it is safe to retry.
|
|
741
|
-
const safeReadFailure = error instanceof CheckoutApiError
|
|
742
|
-
&& error.status === 502 && (error.code === 'amount_unverifiable' || error.code === 'cse_key_unavailable');
|
|
743
|
-
if ((error instanceof CheckoutApiError && error.status >= 500 && !safeReadFailure)
|
|
744
|
-
|| (!(error instanceof CheckoutApiError) && !(error instanceof ApprovalDeclinedError))) {
|
|
745
|
-
throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_unanswered');
|
|
746
|
-
}
|
|
747
|
-
throw error;
|
|
748
|
-
}
|
|
749
|
-
if (!created || typeof created.id !== 'string' || !created.id)
|
|
750
|
-
throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_malformed');
|
|
751
|
-
const authorizationId = created.id;
|
|
752
|
-
// A prepared Mercado token already has the person's approval. Poll its
|
|
753
|
-
// result promptly while the native SDK waits: at the default interval,
|
|
754
|
-
// 10 seconds has at most 20 reads instead of 5 (15 additional reads).
|
|
755
|
-
// Then restore the caller's interval. Human approval waiting, cancellation,
|
|
756
|
-
// merchant deadlines, and the single processor request remain unchanged.
|
|
757
|
-
const preparedMercadoPollUntil = preparation?.psp === 'mercado_pago' ? Date.now() + 10_000 : 0;
|
|
758
|
-
let execution = {};
|
|
759
|
-
let approvalDelivered = false;
|
|
760
|
-
const deliverApproval = (state) => {
|
|
761
|
-
if (ownedShop || nativeCheckout || preparation || approvalDelivered || execution.executionMode === 'autopilot')
|
|
762
|
-
return;
|
|
763
|
-
const url = state.approvalUrl ?? state.approval_url;
|
|
764
|
-
if (typeof url !== 'string' || !url)
|
|
765
|
-
return;
|
|
766
|
-
approvalDelivered = true;
|
|
767
|
-
try {
|
|
768
|
-
Promise.resolve(input.onApprovalUrl?.(url)).catch(() => { });
|
|
769
|
-
}
|
|
770
|
-
catch { /* observer only */ }
|
|
771
|
-
};
|
|
772
|
-
let failed = false;
|
|
773
|
-
let actionRequired = false;
|
|
774
|
-
const checkAutopilotAction = (state) => {
|
|
775
|
-
if (state.autopilot_status !== 'action_required')
|
|
776
|
-
return;
|
|
777
|
-
// The authenticated API reports only a verified completion category.
|
|
778
|
-
// Retain this authorization without returning its processor payload or
|
|
779
|
-
// inferring whether the required action is 3DS, a redirect, or another step.
|
|
780
|
-
if (state.id !== authorizationId || state.status !== 'awaiting_approval' || state.mode !== 'token'
|
|
781
|
-
|| state.execution_mode !== 'autopilot' || execution.executionMode !== 'autopilot'
|
|
782
|
-
|| state.grant_id !== execution.grantId || (input.grantId && state.grant_id !== input.grantId)) {
|
|
783
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_action_unconfirmed');
|
|
784
|
-
}
|
|
785
|
-
actionRequired = true;
|
|
786
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_action_required');
|
|
787
|
-
};
|
|
788
|
-
const stopSignal = input.merchantSignal
|
|
789
|
-
? AbortSignal.any([input.merchantSignal, ...(input.signal ? [input.signal] : [])]) : input.signal;
|
|
790
|
-
try {
|
|
791
|
-
try {
|
|
792
|
-
Promise.resolve(input.onAuthorizationCreated?.(authorizationId)).catch(() => { });
|
|
793
|
-
}
|
|
794
|
-
catch { /* observer only */ }
|
|
795
|
-
if (input.merchantSignal?.aborted)
|
|
796
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
797
|
-
execution = executionMetadata(created, authorizationId);
|
|
798
|
-
checkAutopilotAction(created);
|
|
799
|
-
deliverApproval(created);
|
|
800
|
-
while (Date.now() < deadline) {
|
|
801
|
-
if (stopSignal?.aborted)
|
|
802
|
-
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
803
|
-
const pollInterval = Date.now() < preparedMercadoPollUntil ? Math.min(this.pollIntervalMs, 500) : this.pollIntervalMs;
|
|
804
|
-
await interruptibleSleep(Math.min(pollInterval, Math.max(0, deadline - Date.now())), stopSignal);
|
|
805
|
-
if (stopSignal?.aborted)
|
|
806
|
-
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
807
|
-
let s;
|
|
808
|
-
const deadlineSignal = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
|
|
809
|
-
const pollSignal = stopSignal ? AbortSignal.any([stopSignal, deadlineSignal]) : deadlineSignal;
|
|
810
|
-
try {
|
|
811
|
-
s = await this.get(`/v2/checkout/authorizations/${authorizationId}`, pollSignal);
|
|
812
|
-
}
|
|
813
|
-
catch {
|
|
814
|
-
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'authorization_poll_failed');
|
|
815
|
-
}
|
|
816
|
-
if (input.merchantSignal?.aborted)
|
|
817
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
818
|
-
if (!s || typeof s !== 'object' || Array.isArray(s) || typeof s.status !== 'string') {
|
|
819
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_malformed');
|
|
820
|
-
}
|
|
821
|
-
execution = executionMetadata(s, authorizationId, execution);
|
|
822
|
-
checkAutopilotAction(s);
|
|
823
|
-
if (nativeCheckout && (s.status === 'submitted_on_device' ||
|
|
824
|
-
(s.status === 'approved' && (s.mode !== 'token' || execution.executionMode !== 'autopilot'
|
|
825
|
-
|| execution.grantId !== input.grantId))))
|
|
826
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'checkout_receipt_unconfirmed');
|
|
827
|
-
if (shopOrder && (s.status === 'submitted_on_device' ||
|
|
828
|
-
(s.status === 'approved' && (s.mode !== 'token' || execution.executionMode !== 'autopilot'))))
|
|
829
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'shop_receipt_unconfirmed');
|
|
830
|
-
deliverApproval(s);
|
|
831
|
-
const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
|
|
832
|
-
? { amountAuthority: s.amount_authority }
|
|
833
|
-
: {};
|
|
834
|
-
if (s.status === 'submitted_on_device') {
|
|
835
|
-
// The device attested that the processor's form left it; the stamp
|
|
836
|
-
// is the whole fact and it is NOT an approval (see HostedFormReplay).
|
|
837
|
-
// Only a hosted_form row may carry this status; a stamp without its
|
|
838
|
-
// time is not one this SDK can act on. The device may already have paid,
|
|
839
|
-
// so the adapter holds the attempt until the merchant is reconciled.
|
|
840
|
-
const mode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
841
|
-
if (mode !== 'hosted_form')
|
|
842
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'submission_mode_mismatch');
|
|
843
|
-
if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
|
|
844
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
|
|
845
|
-
}
|
|
846
|
-
if (execution.executionMode === 'autopilot')
|
|
847
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
|
|
848
|
-
return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority, ...execution };
|
|
849
|
-
}
|
|
850
|
-
if (s.status === 'approved') {
|
|
851
|
-
const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
852
|
-
if (approvedMode === 'hosted_form') {
|
|
853
|
-
// Never: the API finishes a hosted form as submitted_on_device, and
|
|
854
|
-
// its database refuses `approved` on that mode. An answer that says
|
|
855
|
-
// otherwise is not one to act on as a payment.
|
|
856
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
|
|
857
|
-
}
|
|
858
|
-
if (approvedMode === 'cse') {
|
|
859
|
-
if (execution.executionMode === 'autopilot')
|
|
860
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
|
|
861
|
-
const sub = s.substitutions;
|
|
862
|
-
const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
|
|
863
|
-
&& sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
|
|
864
|
-
&& Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
|
|
865
|
-
if (!fieldsOk)
|
|
866
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
|
|
867
|
-
// `remove`: sibling keys the API says to drop with the swap (Adyen's
|
|
868
|
-
// `brand`, stamped by adyen-web from the agent's dummy digits). Absent
|
|
869
|
-
// on an older API; anything but a list of names is refused, since a
|
|
870
|
-
// half-understood instruction would continue a body Adyen refuses.
|
|
871
|
-
const removeRaw = sub.remove;
|
|
872
|
-
if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
|
|
873
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_remove_malformed');
|
|
874
|
-
}
|
|
875
|
-
return {
|
|
876
|
-
mode: 'cse',
|
|
877
|
-
authorizationId,
|
|
878
|
-
substitutions: {
|
|
879
|
-
encoding: 'json',
|
|
880
|
-
at: sub.at,
|
|
881
|
-
fields: { ...sub.fields },
|
|
882
|
-
...(removeRaw ? { remove: [...removeRaw] } : {}),
|
|
883
|
-
},
|
|
884
|
-
...amountAuthority,
|
|
885
|
-
...execution,
|
|
886
|
-
};
|
|
887
|
-
}
|
|
888
|
-
if (approvedMode !== 'token')
|
|
889
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
|
|
890
|
-
const response = s.response;
|
|
891
|
-
if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
|
|
892
|
-
|| typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
|
|
893
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_response_malformed');
|
|
894
|
-
}
|
|
895
|
-
if (shopOrder) {
|
|
896
|
-
let receipt;
|
|
897
|
-
try {
|
|
898
|
-
receipt = parseOwnedShopReceipt(JSON.parse(response.body), shopOrder);
|
|
899
|
-
}
|
|
900
|
-
catch { /* No raw error or token replay. */ }
|
|
901
|
-
if (execution.executionMode !== 'autopilot' || response.status !== 200 || !receipt)
|
|
902
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'shop_receipt_unconfirmed');
|
|
903
|
-
// Only canonical receipt fields and our content type enter the page.
|
|
904
|
-
response.body = JSON.stringify(receipt);
|
|
905
|
-
response.headers = { 'content-type': 'application/json' };
|
|
906
|
-
}
|
|
907
|
-
if (checkoutContext) {
|
|
908
|
-
let receipt;
|
|
909
|
-
try {
|
|
910
|
-
receipt = parseStripeCheckoutResponse(JSON.parse(response.body), { environment: input.stripeCheckoutEnvironment, sessionId: checkoutContext.session_id });
|
|
911
|
-
}
|
|
912
|
-
catch { /* Never return an unvalidated processor body to the page. */ }
|
|
913
|
-
if (response.status !== 200 || !receipt || s.amount_verified !== true
|
|
914
|
-
|| s.charged_amount !== input.amount || s.charged_currency !== 'usd' || s.charged_kind !== 'captured')
|
|
915
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'checkout_receipt_unconfirmed');
|
|
916
|
-
response.body = JSON.stringify(receipt);
|
|
917
|
-
response.headers = { 'content-type': 'application/json' };
|
|
918
|
-
}
|
|
919
|
-
let processorContext;
|
|
920
|
-
if (mercadoContext) {
|
|
921
|
-
try {
|
|
922
|
-
processorContext = await validateMercadoProcessorContext(s.processor_context, mercadoContext, input.request.url, response.body);
|
|
923
|
-
}
|
|
924
|
-
catch {
|
|
925
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'mercado_checkout_metadata_invalid');
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
else if (s.processor_context !== undefined)
|
|
929
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'unexpected_processor_context');
|
|
930
|
-
return {
|
|
931
|
-
mode: 'token',
|
|
932
|
-
authorizationId,
|
|
933
|
-
...(shopOrder ? { shopOrderId: shopOrder.order_id } : {}),
|
|
934
|
-
...(checkoutContext ? { checkoutSessionId: checkoutContext.session_id } : {}),
|
|
935
|
-
...response,
|
|
936
|
-
...(processorContext ? { processorContext } : {}),
|
|
937
|
-
amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
|
|
938
|
-
chargedAmount: typeof s.charged_amount === 'number' ? s.charged_amount : null,
|
|
939
|
-
chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
|
|
940
|
-
chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
|
|
941
|
-
...amountAuthority,
|
|
942
|
-
...execution,
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
if (s.status === 'declined') {
|
|
946
|
-
// The pre-replay checks declined it: typed, with the numbers, so the
|
|
947
|
-
// caller can say what happened rather than "the user said no".
|
|
948
|
-
if (s.reason === 'amount_mismatch') {
|
|
949
|
-
throw new AmountMismatchError(String(created.id), Number(s.expected_cents), Number(s.actual_cents), String(s.currency ?? input.currency ?? ''), s.actual_currency != null ? String(s.actual_currency) : undefined, 'pre_replay');
|
|
950
|
-
}
|
|
951
|
-
if (s.reason === 'intent_not_confirmable')
|
|
952
|
-
throw new IntentNotConfirmableError(String(created.id));
|
|
953
|
-
if (typeof s.reason === 'string' && PRESET_REFUSAL_REASONS.has(s.reason)) {
|
|
954
|
-
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
|
-
}
|
|
956
|
-
if (s.reason === 'processor_refused') {
|
|
957
|
-
throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null, s.psp === 'razorpay' ? parseProcessorError(s.processor_error) : null);
|
|
958
|
-
}
|
|
959
|
-
throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
|
|
960
|
-
}
|
|
961
|
-
if (s.status === 'expired') {
|
|
962
|
-
if (s.replay_attempted === false)
|
|
963
|
-
throw new ApprovalTimeoutError(timeoutMs);
|
|
964
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'expired_after_possible_replay');
|
|
965
|
-
}
|
|
966
|
-
if (s.status !== 'awaiting_approval')
|
|
967
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
|
|
968
|
-
}
|
|
969
|
-
// A local deadline is not server-side expiry; the person may still use the approval link.
|
|
970
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'local_approval_timeout');
|
|
971
|
-
}
|
|
972
|
-
catch (error) {
|
|
973
|
-
failed = true;
|
|
974
|
-
if (input.merchantSignal?.aborted)
|
|
975
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
976
|
-
throw error;
|
|
977
|
-
}
|
|
978
|
-
finally {
|
|
979
|
-
// An attested action-required result retains its claim and reservation.
|
|
980
|
-
// Reporting that state must not request cancellation of the same attempt.
|
|
981
|
-
if (!actionRequired && (input.merchantSignal?.aborted || ((preparation || nativeCheckout) && failed))) {
|
|
982
|
-
// Drain a create acknowledgement even after the merchant aborts so its
|
|
983
|
-
// known ID can be retired. An unacknowledged create remains unknown.
|
|
984
|
-
// A started/finalized replay or failed cleanup never becomes a claimed
|
|
985
|
-
// cancellation, and this best-effort cleanup never retries a payment.
|
|
986
|
-
await this.cancelAuthorization(authorizationId).catch(() => { });
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
/** A lost bind acknowledgement must never resume the request. Recover metadata only for safe cleanup. */
|
|
991
|
-
async retireUncertainPreparation(preparation, onCreated) {
|
|
992
|
-
// Race cancellation atomically against a create still arriving at the API.
|
|
993
|
-
// A bound preparation refuses cancellation; resolve its ID exactly once below.
|
|
994
|
-
await this.cancelPreparation(preparation.id).catch(() => { });
|
|
995
|
-
let state;
|
|
996
|
-
try {
|
|
997
|
-
state = await this.get(`/v2/checkout/preparations/${preparation.id}`, AbortSignal.timeout(3_000));
|
|
998
|
-
}
|
|
999
|
-
catch {
|
|
1000
|
-
return null;
|
|
1001
|
-
}
|
|
1002
|
-
if (state?.id !== preparation.id || state.status !== 'bound' || typeof state.authorization_id !== 'string'
|
|
1003
|
-
|| !/^cauth_[A-Za-z0-9_-]{1,128}$/.test(state.authorization_id))
|
|
1004
|
-
return null;
|
|
1005
|
-
const id = state.authorization_id;
|
|
1006
|
-
try {
|
|
1007
|
-
Promise.resolve(onCreated?.(id)).catch(() => { });
|
|
1008
|
-
}
|
|
1009
|
-
catch { /* observer only */ }
|
|
1010
|
-
await this.cancelAuthorization(id).catch(() => { });
|
|
1011
|
-
return id;
|
|
1012
|
-
}
|
|
1013
|
-
/**
|
|
1014
|
-
* Retire an authorization the runtime is done with. A 409 or missing
|
|
1015
|
-
* response remains unknown.
|
|
1016
|
-
*
|
|
1017
|
-
* `merchant_request_aborted` (the default): the merchant request is gone
|
|
1018
|
-
* and the row must still be awaiting the person with no replay started; the
|
|
1019
|
-
* acknowledgement carries `processor_request_started: false`.
|
|
1020
|
-
*
|
|
1021
|
-
* `merchant_never_retried`: the page abandoned its request while the person
|
|
1022
|
-
* decided, the adapter kept the approval for the page's retry, and none
|
|
1023
|
-
* came. The row may already be `approved` (a token or ciphertext minted on
|
|
1024
|
-
* the device that this runtime handed to no request), so
|
|
1025
|
-
* `processor_request_started` says whether the processor was asked; the API
|
|
1026
|
-
* stamps this reason only where no charge can have been made and answers
|
|
1027
|
-
* 409 otherwise. The acknowledgement echoes the reason the row actually
|
|
1028
|
-
* carries: a row retired earlier under the other runtime reason answers
|
|
1029
|
-
* with that one.
|
|
1030
|
-
*/
|
|
1031
|
-
async cancelAuthorization(authorizationId, reason = 'merchant_request_aborted') {
|
|
1032
|
-
if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
|
|
1033
|
-
throw new Error('Invalid authorization ID.');
|
|
1034
|
-
if (!RUNTIME_CANCEL_REASONS.includes(reason))
|
|
1035
|
-
throw new Error('Unsupported cancellation reason.');
|
|
1036
|
-
const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/cancel`, reason === 'merchant_request_aborted' ? {} : { reason }, AbortSignal.timeout(5_000));
|
|
1037
|
-
const recorded = result?.reason;
|
|
1038
|
-
if (result?.id !== authorizationId || result.status !== 'declined' || result.cancelled !== true
|
|
1039
|
-
|| typeof recorded !== 'string' || !RUNTIME_CANCEL_REASONS.includes(recorded)
|
|
1040
|
-
|| typeof result.processor_request_started !== 'boolean'
|
|
1041
|
-
// The plain cancellation is only ever confirmed before the card moved.
|
|
1042
|
-
|| (recorded === 'merchant_request_aborted' && result.processor_request_started !== false)) {
|
|
1043
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_cancel_unconfirmed');
|
|
1044
|
-
}
|
|
1045
|
-
return { id: authorizationId, status: 'declined', reason: recorded,
|
|
1046
|
-
cancelled: true, processor_request_started: result.processor_request_started };
|
|
1047
|
-
}
|
|
1048
|
-
/**
|
|
1049
|
-
* Ask whether the page may pay with the Stripe card token an approval
|
|
1050
|
-
* produced: a PaymentIntent confirm that carries no card and pays with
|
|
1051
|
-
* exactly the approved payment method, card token, confirmation token or
|
|
1052
|
-
* source. The API reads the payment from Stripe and answers only for the
|
|
1053
|
-
* approved amount and currency on the same Stripe account; any other answer
|
|
1054
|
-
* rejects with a CheckoutApiError whose code says why (for example
|
|
1055
|
-
* `continuation_not_bound`, `amount_mismatch`). Nothing is charged here:
|
|
1056
|
-
* the adapters continue the page's own request once this resolves.
|
|
1057
|
-
*/
|
|
1058
|
-
async checkStripeContinuation(authorizationId, request) {
|
|
1059
|
-
if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
|
|
1060
|
-
throw new Error('Invalid authorization ID.');
|
|
1061
|
-
const rec = findRecognizer(request.url, this.registry);
|
|
1062
|
-
if (rec?.psp !== 'stripe')
|
|
1063
|
-
throw new Error('Only a Stripe request can continue an approved Stripe token.');
|
|
1064
|
-
const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/continuations`, {
|
|
1065
|
-
request: { url: request.url, method: request.method, headers: pickHeaders(request.headers, rec.passthroughHeaders), body: request.body },
|
|
1066
|
-
}, AbortSignal.timeout(15_000));
|
|
1067
|
-
const pi = result?.payment_intent;
|
|
1068
|
-
if (result?.object !== 'checkout_continuation' || result.continuation !== 'allowed' || result.authorization !== authorizationId
|
|
1069
|
-
|| !pi || typeof pi.id !== 'string' || !Number.isSafeInteger(pi.amount) || typeof pi.currency !== 'string') {
|
|
1070
|
-
throw new Error('The continuation answer was not recognized.');
|
|
1071
|
-
}
|
|
1072
|
-
return { paymentIntentId: pi.id, amount: pi.amount, currency: pi.currency };
|
|
1073
|
-
}
|
|
1074
|
-
/**
|
|
1075
|
-
* POST the create, with two typed twists: a 502 `amount_unverifiable`
|
|
1076
|
-
* (Stripe did not answer the read-back) is retried on a short backoff
|
|
1077
|
-
* instead of being left to the page's own retry loop, and a 409
|
|
1078
|
-
* `amount_mismatch` becomes an AmountMismatchError at stage 'create' so the
|
|
1079
|
-
* adapters treat it as an answered request, not a dead page.
|
|
1080
|
-
*/
|
|
1081
|
-
async createAuthorization(payload, currency, signal, stopRetries) {
|
|
1082
|
-
for (let attempt = 0;; attempt++) {
|
|
1083
|
-
if (stopRetries?.aborted)
|
|
1084
|
-
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
1085
|
-
try {
|
|
1086
|
-
return await this.post('/v2/checkout/authorizations', payload, signal, stopRetries);
|
|
1087
|
-
}
|
|
1088
|
-
catch (err) {
|
|
1089
|
-
if (err instanceof CheckoutApiError && err.code && err.status === 403 && presetOf(err.details.preset)) {
|
|
1090
|
-
// A company preset refused this purchase before a row existed.
|
|
1091
|
-
const d = err.details;
|
|
1092
|
-
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
|
-
}
|
|
1094
|
-
if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
|
|
1095
|
-
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');
|
|
1097
|
-
}
|
|
1098
|
-
// Two 502s the API asks to be retried: Stripe did not answer the
|
|
1099
|
-
// amount read-back, or Adyen did not answer the public-key fetch.
|
|
1100
|
-
const retryable = !payload.preparation_id && err instanceof CheckoutApiError && err.status === 502
|
|
1101
|
-
&& (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
|
|
1102
|
-
if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
|
|
1103
|
-
throw err;
|
|
1104
|
-
await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], stopRetries ? AbortSignal.any([stopRetries, ...(signal ? [signal] : [])]) : signal);
|
|
1105
|
-
if (signal?.aborted)
|
|
1106
|
-
throw signal.reason;
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
// --- auth: client_credentials, cached until just before it expires --------
|
|
1111
|
-
token = null;
|
|
1112
|
-
inflight = null;
|
|
1113
|
-
/**
|
|
1114
|
-
* Exchange client credentials for an access token, reusing the cached one
|
|
1115
|
-
* until it is nearly expired. Concurrent callers share a single in-flight
|
|
1116
|
-
* exchange rather than each minting their own token.
|
|
1117
|
-
*/
|
|
1118
|
-
async accessToken(force = false) {
|
|
1119
|
-
if (!force && this.token && Date.now() < this.token.expiresAt)
|
|
1120
|
-
return this.token.value;
|
|
1121
|
-
if (!force && this.inflight)
|
|
1122
|
-
return this.inflight;
|
|
1123
|
-
this.inflight = (async () => {
|
|
1124
|
-
const body = new URLSearchParams({
|
|
1125
|
-
grant_type: 'client_credentials',
|
|
1126
|
-
client_id: this.opts.clientId,
|
|
1127
|
-
client_secret: this.opts.clientSecret,
|
|
1128
|
-
});
|
|
1129
|
-
const r = await this.fetch(`${this.baseUrl}/api/v1/oauth/token`, {
|
|
1130
|
-
method: 'POST',
|
|
1131
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
1132
|
-
body: body.toString(),
|
|
1133
|
-
signal: AbortSignal.timeout(30_000),
|
|
1134
|
-
});
|
|
1135
|
-
if (!r.ok) {
|
|
1136
|
-
// RFC 6749 error shape, which real OAuth clients expect verbatim.
|
|
1137
|
-
const d = await r.json().catch(() => ({}));
|
|
1138
|
-
throw new Error(`agentcard auth failed: ${d.error ?? r.status} ${d.error_description ?? ''}`.trim());
|
|
1139
|
-
}
|
|
1140
|
-
const d = (await r.json());
|
|
1141
|
-
// Renew a minute early so a token never expires mid-checkout.
|
|
1142
|
-
const ttl = Math.max(60, (d.expires_in ?? 3600) - 60);
|
|
1143
|
-
this.token = { value: d.access_token, expiresAt: Date.now() + ttl * 1000 };
|
|
1144
|
-
return this.token.value;
|
|
1145
|
-
})().finally(() => { this.inflight = null; });
|
|
1146
|
-
return this.inflight;
|
|
1147
|
-
}
|
|
1148
|
-
/**
|
|
1149
|
-
* A preparation status read may retry two known connection failures, after
|
|
1150
|
-
* 250ms and 500ms, while keeping its original approval deadline and signal.
|
|
1151
|
-
* Only fetch and a successful response's body read belong to that retry:
|
|
1152
|
-
* OAuth, HTTP errors, invalid JSON and every mutation remain outside it.
|
|
1153
|
-
* The existing one-time 401 refresh carries the remaining read budget.
|
|
1154
|
-
*/
|
|
1155
|
-
async call(path, init = {}, retried = false, stopNewRequests, preparationReadAttempt) {
|
|
1156
|
-
const signal = init.signal ?? AbortSignal.timeout(30_000);
|
|
1157
|
-
const token = await withSignal(this.accessToken(), signal);
|
|
1158
|
-
// Auth can outlive the merchant request. Stop a create that has not left
|
|
1159
|
-
// yet, while preserving the response of one already sent for cleanup.
|
|
1160
|
-
if (stopNewRequests?.aborted)
|
|
1161
|
-
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
1162
|
-
for (;;) {
|
|
1163
|
-
signal.throwIfAborted();
|
|
1164
|
-
let r;
|
|
1165
|
-
try {
|
|
1166
|
-
r = await withSignal(this.fetch(`${this.baseUrl}${path}`, {
|
|
1167
|
-
...init,
|
|
1168
|
-
signal,
|
|
1169
|
-
headers: { ...(init.headers ?? {}), authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
1170
|
-
}), signal);
|
|
1171
|
-
if (r.ok)
|
|
1172
|
-
return await withSignal(r.json(), signal);
|
|
1173
|
-
}
|
|
1174
|
-
catch (error) {
|
|
1175
|
-
const delay = preparationReadAttempt === undefined ? undefined : PREPARATION_READ_RETRY_DELAYS_MS[preparationReadAttempt];
|
|
1176
|
-
if (signal.aborted || preparationReadAttempt === undefined || delay === undefined || !transientReadNetworkError(error))
|
|
1177
|
-
throw error;
|
|
1178
|
-
preparationReadAttempt++;
|
|
1179
|
-
await interruptibleSleep(delay, signal);
|
|
1180
|
-
continue;
|
|
1181
|
-
}
|
|
1182
|
-
// A token can be revoked or expire early; one forced refresh, then give up.
|
|
1183
|
-
if (r.status === 401 && !retried) {
|
|
1184
|
-
if (stopNewRequests?.aborted)
|
|
1185
|
-
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
1186
|
-
await withSignal(this.accessToken(true), signal);
|
|
1187
|
-
return this.call(path, { ...init, signal }, true, stopNewRequests, preparationReadAttempt);
|
|
1188
|
-
}
|
|
1189
|
-
throw new CheckoutApiError(r.status, path, await withSignal(r.text(), signal));
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
post(path, body, signal, stopNewRequests) {
|
|
1193
|
-
return this.call(path, { method: 'POST', body: JSON.stringify(body), signal }, false, stopNewRequests);
|
|
1194
|
-
}
|
|
1195
|
-
get(path, signal) {
|
|
1196
|
-
return this.call(path, { signal });
|
|
1197
|
-
}
|
|
1198
|
-
readPreparation(id, signal) {
|
|
1199
|
-
return this.call(`/v2/checkout/preparations/${id}`, { signal }, false, undefined, 0);
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
function executionMetadata(state, authorizationId, previous = {}) {
|
|
1203
|
-
if (state.execution_mode === undefined)
|
|
1204
|
-
return previous;
|
|
1205
|
-
if (state.execution_mode !== 'user_approval' && state.execution_mode !== 'autopilot')
|
|
1206
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'execution_mode_unrecognized');
|
|
1207
|
-
if (state.execution_mode === 'user_approval')
|
|
1208
|
-
return { executionMode: 'user_approval' };
|
|
1209
|
-
if (typeof state.grant_id !== 'string' || !/^apg_[A-Za-z0-9_-]{1,128}$/.test(state.grant_id))
|
|
1210
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_unconfirmed');
|
|
1211
|
-
if (previous.grantId && previous.grantId !== state.grant_id)
|
|
1212
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_changed');
|
|
1213
|
-
return { executionMode: 'autopilot', grantId: state.grant_id };
|
|
1214
|
-
}
|
|
1215
|
-
/**
|
|
1216
|
-
* Forward only the headers the merchant needs to accept the replay. Everything
|
|
1217
|
-
* else (cookies, UA, tracing) is dropped so we transmit as little as possible.
|
|
1218
|
-
*/
|
|
1219
|
-
function pickHeaders(headers, allow) {
|
|
1220
|
-
const out = {};
|
|
1221
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
1222
|
-
const lk = k.toLowerCase();
|
|
1223
|
-
if (lk === 'content-type' || allow.some((re) => re.test(lk)))
|
|
1224
|
-
out[lk] = v;
|
|
1225
|
-
}
|
|
1226
|
-
if (!out['content-type'])
|
|
1227
|
-
out['content-type'] = 'application/json';
|
|
1228
|
-
return out;
|
|
1229
|
-
}
|
|
1230
|
-
/** Settle local work even if a custom fetch implementation ignores AbortSignal. */
|
|
1231
|
-
function withSignal(operation, signal) {
|
|
1232
|
-
return new Promise((resolve, reject) => {
|
|
1233
|
-
const aborted = () => { signal.removeEventListener('abort', aborted); reject(signal.reason); };
|
|
1234
|
-
if (signal.aborted) {
|
|
1235
|
-
operation.catch(() => { });
|
|
1236
|
-
aborted();
|
|
1237
|
-
return;
|
|
1238
|
-
}
|
|
1239
|
-
signal.addEventListener('abort', aborted, { once: true });
|
|
1240
|
-
operation.then(value => { signal.removeEventListener('abort', aborted); resolve(value); }, error => { signal.removeEventListener('abort', aborted); reject(error); });
|
|
1241
|
-
});
|
|
1242
|
-
}
|
|
1243
|
-
function interruptibleSleep(ms, signal) {
|
|
1244
|
-
if (signal?.aborted)
|
|
1245
|
-
return Promise.resolve();
|
|
1246
|
-
return new Promise((resolve) => {
|
|
1247
|
-
const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); };
|
|
1248
|
-
const timer = setTimeout(done, ms);
|
|
1249
|
-
signal?.addEventListener('abort', done, { once: true });
|
|
1250
|
-
});
|
|
1251
|
-
}
|