@agent-cards/checkout 0.2.1 → 0.3.1
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 +32 -0
- package/README.md +183 -10
- package/dist/cdp.d.ts +10 -5
- package/dist/cdp.js +187 -21
- package/dist/client.d.ts +26 -4
- package/dist/client.js +242 -98
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +94 -0
- package/dist/lifecycle.js +207 -0
- package/examples/existing-browser.mjs +63 -0
- package/package.json +6 -3
package/dist/client.js
CHANGED
|
@@ -16,15 +16,14 @@ export class CardEncryptedError extends Error {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* The
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* same page could only raise more prompts for the same dead end.
|
|
19
|
+
* The registry requests a mode this SDK build cannot finish, before creation.
|
|
20
|
+
* A response in an unexpected mode after creation has an unknown payment
|
|
21
|
+
* outcome instead and raises PaymentOutcomeUnknownError.
|
|
23
22
|
*/
|
|
24
23
|
export class UnsupportedModeError extends Error {
|
|
25
24
|
mode;
|
|
26
25
|
constructor(mode) {
|
|
27
|
-
super(`the
|
|
26
|
+
super(`the checkout requests mode "${mode}", which this version of @agent-cards/checkout cannot complete; upgrade the SDK.`);
|
|
28
27
|
this.mode = mode;
|
|
29
28
|
this.name = 'UnsupportedModeError';
|
|
30
29
|
}
|
|
@@ -32,6 +31,20 @@ export class UnsupportedModeError extends Error {
|
|
|
32
31
|
export class ApprovalTimeoutError extends Error {
|
|
33
32
|
constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
|
|
34
33
|
}
|
|
34
|
+
export class CheckoutCancelledError extends Error {
|
|
35
|
+
constructor() { super('checkout cancelled locally before authorization creation'); this.name = 'CheckoutCancelledError'; }
|
|
36
|
+
}
|
|
37
|
+
/** The payment may have reached the processor. Reconcile the merchant order before any new attempt. */
|
|
38
|
+
export class PaymentOutcomeUnknownError extends Error {
|
|
39
|
+
authorizationId;
|
|
40
|
+
reason;
|
|
41
|
+
constructor(authorizationId, reason) {
|
|
42
|
+
super(`payment outcome unknown${authorizationId ? ` for ${authorizationId}` : ''}: ${reason}; check the merchant order before retrying`);
|
|
43
|
+
this.authorizationId = authorizationId;
|
|
44
|
+
this.reason = reason;
|
|
45
|
+
this.name = 'PaymentOutcomeUnknownError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
35
48
|
export class ApprovalDeclinedError extends Error {
|
|
36
49
|
constructor(reason) { super(`user declined: ${reason}`); this.name = 'ApprovalDeclinedError'; }
|
|
37
50
|
}
|
|
@@ -267,6 +280,10 @@ export class VaultClient {
|
|
|
267
280
|
* response to replay into the browser. Your process never sees a card.
|
|
268
281
|
*/
|
|
269
282
|
async authorize(input) {
|
|
283
|
+
if (input.signal?.aborted)
|
|
284
|
+
throw new CheckoutCancelledError();
|
|
285
|
+
if (input.merchantSignal?.aborted)
|
|
286
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
270
287
|
const rec = findRecognizer(input.request.url, this.registry);
|
|
271
288
|
if (!rec)
|
|
272
289
|
throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
|
|
@@ -301,7 +318,13 @@ export class VaultClient {
|
|
|
301
318
|
throw new Error('authorize needs amount (a display string), or amountCents with currency.');
|
|
302
319
|
}
|
|
303
320
|
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
304
|
-
|
|
321
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
322
|
+
throw new Error('timeoutMs must be a positive integer no larger than 2147483647.');
|
|
323
|
+
const deadline = Date.now() + timeoutMs;
|
|
324
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
325
|
+
const operationSignal = input.signal ? AbortSignal.any([input.signal, timeoutSignal]) : timeoutSignal;
|
|
326
|
+
let created;
|
|
327
|
+
const payload = {
|
|
305
328
|
user: input.user,
|
|
306
329
|
merchant: input.merchant,
|
|
307
330
|
...(input.amount ? { amount: input.amount } : {}),
|
|
@@ -318,94 +341,180 @@ export class VaultClient {
|
|
|
318
341
|
headers: pickHeaders(input.request.headers, rec.passthroughHeaders),
|
|
319
342
|
body: input.request.body,
|
|
320
343
|
},
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
+
};
|
|
345
|
+
try {
|
|
346
|
+
created = await this.createAuthorization(payload, input.currency, AbortSignal.any([operationSignal, AbortSignal.timeout(30_000)]), input.merchantSignal);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (error instanceof PaymentOutcomeUnknownError)
|
|
350
|
+
throw error;
|
|
351
|
+
// A missing answer or generic 5xx can hide a committed row and a delivered approval link.
|
|
352
|
+
// Only the documented pre-create read-back errors prove it is safe to retry.
|
|
353
|
+
const safeReadFailure = error instanceof CheckoutApiError
|
|
354
|
+
&& error.status === 502 && (error.code === 'amount_unverifiable' || error.code === 'cse_key_unavailable');
|
|
355
|
+
if ((error instanceof CheckoutApiError && error.status >= 500 && !safeReadFailure)
|
|
356
|
+
|| (!(error instanceof CheckoutApiError) && !(error instanceof ApprovalDeclinedError))) {
|
|
357
|
+
throw new PaymentOutcomeUnknownError(null, 'authorization_create_unanswered');
|
|
358
|
+
}
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
if (!created || typeof created.id !== 'string' || !created.id)
|
|
362
|
+
throw new PaymentOutcomeUnknownError(null, 'authorization_create_malformed');
|
|
363
|
+
const authorizationId = created.id;
|
|
364
|
+
const stopSignal = input.merchantSignal
|
|
365
|
+
? AbortSignal.any([input.merchantSignal, ...(input.signal ? [input.signal] : [])]) : input.signal;
|
|
366
|
+
try {
|
|
367
|
+
try {
|
|
368
|
+
Promise.resolve(input.onAuthorizationCreated?.(authorizationId)).catch(() => { });
|
|
369
|
+
}
|
|
370
|
+
catch { /* observer only */ }
|
|
371
|
+
if (input.merchantSignal?.aborted)
|
|
372
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
373
|
+
try {
|
|
374
|
+
Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
|
|
344
375
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
throw new
|
|
376
|
+
catch { /* approval delivery must not lose an existing authorization */ }
|
|
377
|
+
while (Date.now() < deadline) {
|
|
378
|
+
if (stopSignal?.aborted)
|
|
379
|
+
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
380
|
+
await interruptibleSleep(Math.min(this.pollIntervalMs, Math.max(0, deadline - Date.now())), stopSignal);
|
|
381
|
+
if (stopSignal?.aborted)
|
|
382
|
+
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
383
|
+
let s;
|
|
384
|
+
const deadlineSignal = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
|
|
385
|
+
const pollSignal = stopSignal ? AbortSignal.any([stopSignal, deadlineSignal]) : deadlineSignal;
|
|
386
|
+
try {
|
|
387
|
+
s = await this.get(`/v2/checkout/authorizations/${authorizationId}`, pollSignal);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'authorization_poll_failed');
|
|
352
391
|
}
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
392
|
+
if (input.merchantSignal?.aborted)
|
|
393
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
394
|
+
if (!s || typeof s !== 'object' || Array.isArray(s) || typeof s.status !== 'string') {
|
|
395
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_malformed');
|
|
396
|
+
}
|
|
397
|
+
const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
|
|
398
|
+
? { amountAuthority: s.amount_authority }
|
|
399
|
+
: {};
|
|
400
|
+
if (s.status === 'submitted_on_device') {
|
|
401
|
+
// The device attested that the processor's form left it; the stamp
|
|
402
|
+
// is the whole fact and it is NOT an approval (see HostedFormReplay).
|
|
403
|
+
// Only a hosted_form row may carry this status; a stamp without its
|
|
404
|
+
// time is not one this SDK can act on. The device may already have paid,
|
|
405
|
+
// so the adapter holds the attempt until the merchant is reconciled.
|
|
406
|
+
const mode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
407
|
+
if (mode !== 'hosted_form')
|
|
408
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'submission_mode_mismatch');
|
|
409
|
+
if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
|
|
410
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
|
|
411
|
+
}
|
|
412
|
+
return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
|
|
413
|
+
}
|
|
414
|
+
if (s.status === 'approved') {
|
|
415
|
+
const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
416
|
+
if (approvedMode === 'hosted_form') {
|
|
417
|
+
// Never: the API finishes a hosted form as submitted_on_device, and
|
|
418
|
+
// its database refuses `approved` on that mode. An answer that says
|
|
419
|
+
// otherwise is not one to act on as a payment.
|
|
420
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
|
|
421
|
+
}
|
|
422
|
+
if (approvedMode === 'cse') {
|
|
423
|
+
const sub = s.substitutions;
|
|
424
|
+
const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
|
|
425
|
+
&& sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
|
|
426
|
+
&& Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
|
|
427
|
+
if (!fieldsOk)
|
|
428
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
|
|
429
|
+
// `remove`: sibling keys the API says to drop with the swap (Adyen's
|
|
430
|
+
// `brand`, stamped by adyen-web from the agent's dummy digits). Absent
|
|
431
|
+
// on an older API; anything but a list of names is refused, since a
|
|
432
|
+
// half-understood instruction would continue a body Adyen refuses.
|
|
433
|
+
const removeRaw = sub.remove;
|
|
434
|
+
if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
|
|
435
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_remove_malformed');
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
mode: 'cse',
|
|
439
|
+
authorizationId,
|
|
440
|
+
substitutions: {
|
|
441
|
+
encoding: 'json',
|
|
442
|
+
at: sub.at,
|
|
443
|
+
fields: { ...sub.fields },
|
|
444
|
+
...(removeRaw ? { remove: [...removeRaw] } : {}),
|
|
445
|
+
},
|
|
446
|
+
...amountAuthority,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
if (approvedMode !== 'token')
|
|
450
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
|
|
451
|
+
const response = s.response;
|
|
452
|
+
if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
|
|
453
|
+
|| typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
|
|
454
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_response_malformed');
|
|
367
455
|
}
|
|
368
456
|
return {
|
|
369
|
-
mode: '
|
|
457
|
+
mode: 'token',
|
|
370
458
|
authorizationId,
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
},
|
|
459
|
+
...response,
|
|
460
|
+
amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
|
|
461
|
+
chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
|
|
462
|
+
chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
|
|
463
|
+
chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
|
|
377
464
|
...amountAuthority,
|
|
378
465
|
};
|
|
379
466
|
}
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
if (s.status === 'declined') {
|
|
395
|
-
// The pre-replay checks declined it: typed, with the numbers, so the
|
|
396
|
-
// caller can say what happened rather than "the user said no".
|
|
397
|
-
if (s.reason === 'amount_mismatch') {
|
|
398
|
-
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');
|
|
467
|
+
if (s.status === 'declined') {
|
|
468
|
+
// The pre-replay checks declined it: typed, with the numbers, so the
|
|
469
|
+
// caller can say what happened rather than "the user said no".
|
|
470
|
+
if (s.reason === 'amount_mismatch') {
|
|
471
|
+
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');
|
|
472
|
+
}
|
|
473
|
+
if (s.reason === 'intent_not_confirmable')
|
|
474
|
+
throw new IntentNotConfirmableError(String(created.id));
|
|
475
|
+
if (s.reason === 'processor_refused') {
|
|
476
|
+
throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
|
|
477
|
+
}
|
|
478
|
+
throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
|
|
399
479
|
}
|
|
400
|
-
if (s.
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
throw new
|
|
480
|
+
if (s.status === 'expired') {
|
|
481
|
+
if (s.replay_attempted === false)
|
|
482
|
+
throw new ApprovalTimeoutError(timeoutMs);
|
|
483
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'expired_after_possible_replay');
|
|
404
484
|
}
|
|
405
|
-
|
|
485
|
+
if (s.status !== 'awaiting_approval')
|
|
486
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
|
|
406
487
|
}
|
|
488
|
+
// A local deadline is not server-side expiry; the person may still use the approval link.
|
|
489
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'local_approval_timeout');
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
if (input.merchantSignal?.aborted)
|
|
493
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
494
|
+
throw error;
|
|
407
495
|
}
|
|
408
|
-
|
|
496
|
+
finally {
|
|
497
|
+
if (input.merchantSignal?.aborted) {
|
|
498
|
+
// Drain a create acknowledgement even after the merchant aborts so its
|
|
499
|
+
// known ID can be retired. An unacknowledged create remains unknown.
|
|
500
|
+
// A started/finalized replay or failed cleanup never becomes a claimed
|
|
501
|
+
// cancellation, and this best-effort cleanup never retries a payment.
|
|
502
|
+
await this.cancelAuthorization(authorizationId).catch(() => { });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
|
|
507
|
+
async cancelAuthorization(authorizationId) {
|
|
508
|
+
if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
|
|
509
|
+
throw new Error('Invalid authorization ID.');
|
|
510
|
+
const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/cancel`, {}, AbortSignal.timeout(5_000));
|
|
511
|
+
if (result?.id !== authorizationId || result.status !== 'declined'
|
|
512
|
+
|| result.reason !== 'merchant_request_aborted' || result.cancelled !== true
|
|
513
|
+
|| result.processor_request_started !== false) {
|
|
514
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_cancel_unconfirmed');
|
|
515
|
+
}
|
|
516
|
+
return { id: authorizationId, status: 'declined', reason: 'merchant_request_aborted',
|
|
517
|
+
cancelled: true, processor_request_started: false };
|
|
409
518
|
}
|
|
410
519
|
/**
|
|
411
520
|
* POST the create, with two typed twists: a 502 `amount_unverifiable`
|
|
@@ -414,10 +523,12 @@ export class VaultClient {
|
|
|
414
523
|
* `amount_mismatch` becomes an AmountMismatchError at stage 'create' so the
|
|
415
524
|
* adapters treat it as an answered request, not a dead page.
|
|
416
525
|
*/
|
|
417
|
-
async createAuthorization(payload, currency) {
|
|
526
|
+
async createAuthorization(payload, currency, signal, stopRetries) {
|
|
418
527
|
for (let attempt = 0;; attempt++) {
|
|
528
|
+
if (stopRetries?.aborted)
|
|
529
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
419
530
|
try {
|
|
420
|
-
return await this.post('/v2/checkout/authorizations', payload);
|
|
531
|
+
return await this.post('/v2/checkout/authorizations', payload, signal, stopRetries);
|
|
421
532
|
}
|
|
422
533
|
catch (err) {
|
|
423
534
|
if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
|
|
@@ -430,7 +541,9 @@ export class VaultClient {
|
|
|
430
541
|
&& (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
|
|
431
542
|
if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
|
|
432
543
|
throw err;
|
|
433
|
-
await
|
|
544
|
+
await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], stopRetries ? AbortSignal.any([stopRetries, ...(signal ? [signal] : [])]) : signal);
|
|
545
|
+
if (signal?.aborted)
|
|
546
|
+
throw signal.reason;
|
|
434
547
|
}
|
|
435
548
|
}
|
|
436
549
|
}
|
|
@@ -457,6 +570,7 @@ export class VaultClient {
|
|
|
457
570
|
method: 'POST',
|
|
458
571
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
459
572
|
body: body.toString(),
|
|
573
|
+
signal: AbortSignal.timeout(30_000),
|
|
460
574
|
});
|
|
461
575
|
if (!r.ok) {
|
|
462
576
|
// RFC 6749 error shape, which real OAuth clients expect verbatim.
|
|
@@ -472,26 +586,35 @@ export class VaultClient {
|
|
|
472
586
|
return this.inflight;
|
|
473
587
|
}
|
|
474
588
|
/** Authenticated request that retries ONCE on a 401 with a fresh token. */
|
|
475
|
-
async call(path, init = {}, retried = false) {
|
|
476
|
-
const
|
|
477
|
-
const
|
|
589
|
+
async call(path, init = {}, retried = false, stopNewRequests) {
|
|
590
|
+
const signal = init.signal ?? AbortSignal.timeout(30_000);
|
|
591
|
+
const token = await withSignal(this.accessToken(), signal);
|
|
592
|
+
// Auth can outlive the merchant request. Stop a create that has not left
|
|
593
|
+
// yet, while preserving the response of one already sent for cleanup.
|
|
594
|
+
if (stopNewRequests?.aborted)
|
|
595
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
596
|
+
signal.throwIfAborted();
|
|
597
|
+
const r = await withSignal(this.fetch(`${this.baseUrl}${path}`, {
|
|
478
598
|
...init,
|
|
599
|
+
signal,
|
|
479
600
|
headers: { ...(init.headers ?? {}), authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
480
|
-
});
|
|
601
|
+
}), signal);
|
|
481
602
|
// A token can be revoked or expire early; one forced refresh, then give up.
|
|
482
603
|
if (r.status === 401 && !retried) {
|
|
483
|
-
|
|
484
|
-
|
|
604
|
+
if (stopNewRequests?.aborted)
|
|
605
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
606
|
+
await withSignal(this.accessToken(true), signal);
|
|
607
|
+
return this.call(path, { ...init, signal }, true, stopNewRequests);
|
|
485
608
|
}
|
|
486
609
|
if (!r.ok)
|
|
487
|
-
throw new CheckoutApiError(r.status, path, await r.text());
|
|
488
|
-
return r.json();
|
|
610
|
+
throw new CheckoutApiError(r.status, path, await withSignal(r.text(), signal));
|
|
611
|
+
return withSignal(r.json(), signal);
|
|
489
612
|
}
|
|
490
|
-
post(path, body) {
|
|
491
|
-
return this.call(path, { method: 'POST', body: JSON.stringify(body) });
|
|
613
|
+
post(path, body, signal, stopNewRequests) {
|
|
614
|
+
return this.call(path, { method: 'POST', body: JSON.stringify(body), signal }, false, stopNewRequests);
|
|
492
615
|
}
|
|
493
|
-
get(path) {
|
|
494
|
-
return this.call(path);
|
|
616
|
+
get(path, signal) {
|
|
617
|
+
return this.call(path, { signal });
|
|
495
618
|
}
|
|
496
619
|
}
|
|
497
620
|
/**
|
|
@@ -509,4 +632,25 @@ function pickHeaders(headers, allow) {
|
|
|
509
632
|
out['content-type'] = 'application/json';
|
|
510
633
|
return out;
|
|
511
634
|
}
|
|
512
|
-
|
|
635
|
+
/** Settle local work even if a custom fetch implementation ignores AbortSignal. */
|
|
636
|
+
function withSignal(operation, signal) {
|
|
637
|
+
return new Promise((resolve, reject) => {
|
|
638
|
+
const aborted = () => { signal.removeEventListener('abort', aborted); reject(signal.reason); };
|
|
639
|
+
if (signal.aborted) {
|
|
640
|
+
operation.catch(() => { });
|
|
641
|
+
aborted();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
645
|
+
operation.then(value => { signal.removeEventListener('abort', aborted); resolve(value); }, error => { signal.removeEventListener('abort', aborted); reject(error); });
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
function interruptibleSleep(ms, signal) {
|
|
649
|
+
if (signal?.aborted)
|
|
650
|
+
return Promise.resolve();
|
|
651
|
+
return new Promise((resolve) => {
|
|
652
|
+
const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); };
|
|
653
|
+
const timer = setTimeout(done, ms);
|
|
654
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
655
|
+
});
|
|
656
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, } from './client.js';
|
|
1
|
+
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, } from './client.js';
|
|
2
2
|
export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, } from './client.js';
|
|
3
3
|
export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
|
|
4
4
|
export type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';
|
|
@@ -8,3 +8,4 @@ export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted
|
|
|
8
8
|
export type { HostedFormSubmittedPageInput, SyntheticPage } from './hosted-form.js';
|
|
9
9
|
export { BUILTIN_REGISTRY, cardUrlPatterns, findRecognizer } from './registry.js';
|
|
10
10
|
export type { Recognizer, CheckoutMode } from './registry.js';
|
|
11
|
+
export type { CheckoutController, CheckoutState, MerchantResult, UserAction, LifecycleOptions, PaymentEndpointGuard } from './lifecycle.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, } from './client.js';
|
|
1
|
+
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, } from './client.js';
|
|
2
2
|
export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
|
|
3
3
|
export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
|
|
4
4
|
export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type ReplayResponse } from './client.js';
|
|
2
|
+
import type { CheckoutMode } from './registry.js';
|
|
3
|
+
/** A processor approval is not an order. Only the merchant can confirm this result. */
|
|
4
|
+
export type MerchantResult = {
|
|
5
|
+
status: 'completed';
|
|
6
|
+
orderId: string;
|
|
7
|
+
} | {
|
|
8
|
+
status: 'failed';
|
|
9
|
+
} | {
|
|
10
|
+
status: 'pending' | 'unknown';
|
|
11
|
+
} | {
|
|
12
|
+
status: 'requires_user_action';
|
|
13
|
+
reason: '3ds' | 'redirect' | 'other';
|
|
14
|
+
};
|
|
15
|
+
export interface CheckoutState {
|
|
16
|
+
status: 'idle' | 'awaiting_approval' | 'awaiting_merchant' | 'requires_user_action' | 'completed' | 'declined' | 'timed_out' | 'cancelled' | 'unsupported' | 'outcome_unknown' | 'failed';
|
|
17
|
+
authorizationId: string | null;
|
|
18
|
+
mode?: CheckoutMode;
|
|
19
|
+
orderId?: string;
|
|
20
|
+
/** Stable SDK category; never includes a request body, processor response, or approval link. */
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface UserAction {
|
|
24
|
+
reason: 'approval' | '3ds' | 'redirect' | 'other';
|
|
25
|
+
authorizationId: string | null;
|
|
26
|
+
/** Sensitive approval capability. Deliver privately; do not put this in ordinary telemetry. */
|
|
27
|
+
approvalUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface LifecycleOptions {
|
|
30
|
+
onStateChange?: (state: Readonly<CheckoutState>) => void;
|
|
31
|
+
onUserAction?: (action: UserAction) => void | Promise<void>;
|
|
32
|
+
/** Read authoritative merchant order state; do not click Pay or initiate a new charge here. */
|
|
33
|
+
resolveMerchantResult?: (state: Readonly<CheckoutState>) => Promise<MerchantResult>;
|
|
34
|
+
/** Hold further card requests after handoff until the merchant result is reconciled. Default false for compatibility. */
|
|
35
|
+
requireMerchantResult?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface CheckoutController {
|
|
38
|
+
getState(): Readonly<CheckoutState>;
|
|
39
|
+
/** Stop this attachment locally. Does not revoke an approval link or cancel a processor payment. */
|
|
40
|
+
cancel(): void;
|
|
41
|
+
/** Ask the application's merchant resolver. A rejection records unknown; never automatically retries payment. */
|
|
42
|
+
reconcile(): Promise<Readonly<CheckoutState>>;
|
|
43
|
+
/** Signal an observed challenge; the SDK cannot detect every processor's 3DS UI. */
|
|
44
|
+
requestUserAction(reason: '3ds' | 'redirect' | 'other'): Promise<void>;
|
|
45
|
+
/** Start a new attempt after merchant-confirmed failure. Cancelled, completed and unbound Stripe-token attachments cannot reset. */
|
|
46
|
+
retryAfterMerchantFailure(result: {
|
|
47
|
+
status: 'failed';
|
|
48
|
+
}): void;
|
|
49
|
+
}
|
|
50
|
+
/** Shared by the raw CDP and Playwright transports. No browser ownership or payment execution lives here. */
|
|
51
|
+
export declare class CheckoutLifecycle implements CheckoutController {
|
|
52
|
+
private readonly options;
|
|
53
|
+
private state;
|
|
54
|
+
private held;
|
|
55
|
+
private active;
|
|
56
|
+
private cancelled;
|
|
57
|
+
private merchantAborted;
|
|
58
|
+
private unboundStripeToken;
|
|
59
|
+
private reconciliation;
|
|
60
|
+
readonly abort: AbortController;
|
|
61
|
+
constructor(options: LifecycleOptions);
|
|
62
|
+
getState(): Readonly<CheckoutState>;
|
|
63
|
+
isBlocked(): boolean;
|
|
64
|
+
isCancelled(): boolean;
|
|
65
|
+
begin(): void;
|
|
66
|
+
end(): void;
|
|
67
|
+
private set;
|
|
68
|
+
approvalCreated(authorizationId: string): void;
|
|
69
|
+
approvalUrl(approvalUrl: string): void;
|
|
70
|
+
private notify;
|
|
71
|
+
cancel(): void;
|
|
72
|
+
/** The exact browser request is gone; a late approval cannot reopen it. */
|
|
73
|
+
merchantRequestAborted(): void;
|
|
74
|
+
unsupported(): void;
|
|
75
|
+
prepareHandoff(replay: ReplayResponse, requestUrl: string): void;
|
|
76
|
+
handedOff(replay: ReplayResponse): void;
|
|
77
|
+
failed(error: unknown, handoffStarted?: boolean): void;
|
|
78
|
+
requestUserAction(reason: '3ds' | 'redirect' | 'other'): Promise<void>;
|
|
79
|
+
reconcile(): Promise<Readonly<CheckoutState>>;
|
|
80
|
+
private resolve;
|
|
81
|
+
retryAfterMerchantFailure(result: {
|
|
82
|
+
status: 'failed';
|
|
83
|
+
}): void;
|
|
84
|
+
}
|
|
85
|
+
/** Exact origin and path, supplied by the integrator after observing a payment endpoint. No query/body matching. */
|
|
86
|
+
export interface PaymentEndpointGuard {
|
|
87
|
+
origin: string;
|
|
88
|
+
pathname: string;
|
|
89
|
+
methods?: readonly string[];
|
|
90
|
+
}
|
|
91
|
+
export declare function paymentEndpointGuards(input?: readonly PaymentEndpointGuard[]): {
|
|
92
|
+
patterns: string[];
|
|
93
|
+
matches(url: string, method?: string): boolean;
|
|
94
|
+
};
|