@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/dist/client.js CHANGED
@@ -16,15 +16,14 @@ export class CardEncryptedError extends Error {
16
16
  }
17
17
  }
18
18
  /**
19
- * The API approved an authorization in a mode this SDK build cannot finish.
20
- * Unreachable by construction (syncRegistry asks only for SUPPORTED_MODES and
21
- * every create names its mode), so it is treated as terminal: retrying the
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 authorization was approved in mode "${mode}", which this version of @agent-cards/checkout cannot complete; upgrade the SDK.`);
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
- const created = await this.createAuthorization({
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
- }, input.currency);
322
- input.onApprovalUrl?.(created.approvalUrl);
323
- const authorizationId = String(created.id);
324
- const deadline = Date.now() + timeoutMs;
325
- while (Date.now() < deadline) {
326
- await sleep(this.pollIntervalMs);
327
- const s = await this.get(`/v2/checkout/authorizations/${authorizationId}`);
328
- const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
329
- ? { amountAuthority: s.amount_authority }
330
- : {};
331
- if (s.status === 'submitted_on_device') {
332
- // The device attested that the processor's form left it; the stamp
333
- // is the whole fact and it is NOT an approval (see HostedFormReplay).
334
- // Only a hosted_form row may carry this status; a stamp without its
335
- // time is not one this SDK can act on (not permanent: the next poll,
336
- // or request, may carry it).
337
- const mode = typeof s.mode === 'string' ? s.mode : 'token';
338
- if (mode !== 'hosted_form')
339
- throw new Error(`malformed authorization ${authorizationId}: submitted_on_device on mode ${mode}`);
340
- if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
341
- throw new Error(`malformed hosted_form submission on authorization ${authorizationId}: no submitted_at`);
342
- }
343
- return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
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
- if (s.status === 'approved') {
346
- const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
347
- if (approvedMode === 'hosted_form') {
348
- // Never: the API finishes a hosted form as submitted_on_device, and
349
- // its database refuses `approved` on that mode. An answer that says
350
- // otherwise is not one to act on as a payment.
351
- throw new Error(`malformed authorization ${authorizationId}: a hosted_form authorization finishes as submitted_on_device, never approved`);
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 (approvedMode === 'cse') {
354
- const sub = s.substitutions;
355
- const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
356
- && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
357
- && Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
358
- if (!fieldsOk)
359
- throw new Error(`malformed substitutions on approved authorization ${authorizationId}`);
360
- // `remove`: sibling keys the API says to drop with the swap (Adyen's
361
- // `brand`, stamped by adyen-web from the agent's dummy digits). Absent
362
- // on an older API; anything but a list of names is refused, since a
363
- // half-understood instruction would continue a body Adyen refuses.
364
- const removeRaw = sub.remove;
365
- if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
366
- throw new Error(`malformed substitutions.remove on approved authorization ${authorizationId}`);
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: 'cse',
457
+ mode: 'token',
370
458
  authorizationId,
371
- substitutions: {
372
- encoding: 'json',
373
- at: sub.at,
374
- fields: { ...sub.fields },
375
- ...(removeRaw ? { remove: [...removeRaw] } : {}),
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 (approvedMode !== 'token')
381
- throw new UnsupportedModeError(approvedMode);
382
- const response = s.response;
383
- return {
384
- mode: 'token',
385
- authorizationId,
386
- ...response,
387
- amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
388
- chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
389
- chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
390
- chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
391
- ...amountAuthority,
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.reason === 'intent_not_confirmable')
401
- throw new IntentNotConfirmableError(String(created.id));
402
- if (s.reason === 'processor_refused') {
403
- throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
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
- throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
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
- throw new ApprovalTimeoutError(timeoutMs);
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 sleep(this.unverifiableRetryDelaysMs[attempt]);
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 token = await this.accessToken();
477
- const r = await this.fetch(`${this.baseUrl}${path}`, {
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
- await this.accessToken(true);
484
- return this.call(path, init, true);
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
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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
+ };