@agent-cards/checkout 0.2.0 → 0.3.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/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,8 @@ 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();
270
285
  const rec = findRecognizer(input.request.url, this.registry);
271
286
  if (!rec)
272
287
  throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
@@ -301,7 +316,13 @@ export class VaultClient {
301
316
  throw new Error('authorize needs amount (a display string), or amountCents with currency.');
302
317
  }
303
318
  const timeoutMs = input.timeoutMs ?? 15 * 60_000;
304
- const created = await this.createAuthorization({
319
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
320
+ throw new Error('timeoutMs must be a positive integer no larger than 2147483647.');
321
+ const deadline = Date.now() + timeoutMs;
322
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
323
+ const operationSignal = input.signal ? AbortSignal.any([input.signal, timeoutSignal]) : timeoutSignal;
324
+ let created;
325
+ const payload = {
305
326
  user: input.user,
306
327
  merchant: input.merchant,
307
328
  ...(input.amount ? { amount: input.amount } : {}),
@@ -318,13 +339,50 @@ export class VaultClient {
318
339
  headers: pickHeaders(input.request.headers, rec.passthroughHeaders),
319
340
  body: input.request.body,
320
341
  },
321
- }, input.currency);
322
- input.onApprovalUrl?.(created.approvalUrl);
323
- const authorizationId = String(created.id);
324
- const deadline = Date.now() + timeoutMs;
342
+ };
343
+ try {
344
+ created = await this.createAuthorization(payload, input.currency, operationSignal);
345
+ }
346
+ catch (error) {
347
+ // A missing answer or generic 5xx can hide a committed row and a delivered approval link.
348
+ // Only the documented pre-create read-back errors prove it is safe to retry.
349
+ const safeReadFailure = error instanceof CheckoutApiError
350
+ && error.status === 502 && (error.code === 'amount_unverifiable' || error.code === 'cse_key_unavailable');
351
+ if ((error instanceof CheckoutApiError && error.status >= 500 && !safeReadFailure)
352
+ || (!(error instanceof CheckoutApiError) && !(error instanceof ApprovalDeclinedError))) {
353
+ throw new PaymentOutcomeUnknownError(null, 'authorization_create_unanswered');
354
+ }
355
+ throw error;
356
+ }
357
+ if (!created || typeof created.id !== 'string' || !created.id)
358
+ throw new PaymentOutcomeUnknownError(null, 'authorization_create_malformed');
359
+ const authorizationId = created.id;
360
+ try {
361
+ Promise.resolve(input.onAuthorizationCreated?.(authorizationId)).catch(() => { });
362
+ }
363
+ catch { /* observer only */ }
364
+ try {
365
+ Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
366
+ }
367
+ catch { /* approval delivery must not lose an existing authorization */ }
325
368
  while (Date.now() < deadline) {
326
- await sleep(this.pollIntervalMs);
327
- const s = await this.get(`/v2/checkout/authorizations/${authorizationId}`);
369
+ if (input.signal?.aborted)
370
+ throw new PaymentOutcomeUnknownError(authorizationId, 'local_cancel');
371
+ await interruptibleSleep(Math.min(this.pollIntervalMs, Math.max(0, deadline - Date.now())), input.signal);
372
+ if (input.signal?.aborted)
373
+ throw new PaymentOutcomeUnknownError(authorizationId, 'local_cancel');
374
+ let s;
375
+ const deadlineSignal = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
376
+ const pollSignal = input.signal ? AbortSignal.any([input.signal, deadlineSignal]) : deadlineSignal;
377
+ try {
378
+ s = await this.get(`/v2/checkout/authorizations/${authorizationId}`, pollSignal);
379
+ }
380
+ catch {
381
+ throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_poll_failed');
382
+ }
383
+ if (!s || typeof s !== 'object' || Array.isArray(s) || typeof s.status !== 'string') {
384
+ throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_malformed');
385
+ }
328
386
  const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
329
387
  ? { amountAuthority: s.amount_authority }
330
388
  : {};
@@ -332,13 +390,13 @@ export class VaultClient {
332
390
  // The device attested that the processor's form left it; the stamp
333
391
  // is the whole fact and it is NOT an approval (see HostedFormReplay).
334
392
  // 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).
393
+ // time is not one this SDK can act on. The device may already have paid,
394
+ // so the adapter holds the attempt until the merchant is reconciled.
337
395
  const mode = typeof s.mode === 'string' ? s.mode : 'token';
338
396
  if (mode !== 'hosted_form')
339
- throw new Error(`malformed authorization ${authorizationId}: submitted_on_device on mode ${mode}`);
397
+ throw new PaymentOutcomeUnknownError(authorizationId, 'submission_mode_mismatch');
340
398
  if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
341
- throw new Error(`malformed hosted_form submission on authorization ${authorizationId}: no submitted_at`);
399
+ throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
342
400
  }
343
401
  return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
344
402
  }
@@ -348,7 +406,7 @@ export class VaultClient {
348
406
  // Never: the API finishes a hosted form as submitted_on_device, and
349
407
  // its database refuses `approved` on that mode. An answer that says
350
408
  // 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`);
409
+ throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
352
410
  }
353
411
  if (approvedMode === 'cse') {
354
412
  const sub = s.substitutions;
@@ -356,14 +414,14 @@ export class VaultClient {
356
414
  && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
357
415
  && Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
358
416
  if (!fieldsOk)
359
- throw new Error(`malformed substitutions on approved authorization ${authorizationId}`);
417
+ throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
360
418
  // `remove`: sibling keys the API says to drop with the swap (Adyen's
361
419
  // `brand`, stamped by adyen-web from the agent's dummy digits). Absent
362
420
  // on an older API; anything but a list of names is refused, since a
363
421
  // half-understood instruction would continue a body Adyen refuses.
364
422
  const removeRaw = sub.remove;
365
423
  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}`);
424
+ throw new PaymentOutcomeUnknownError(authorizationId, 'cse_remove_malformed');
367
425
  }
368
426
  return {
369
427
  mode: 'cse',
@@ -378,8 +436,12 @@ export class VaultClient {
378
436
  };
379
437
  }
380
438
  if (approvedMode !== 'token')
381
- throw new UnsupportedModeError(approvedMode);
439
+ throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
382
440
  const response = s.response;
441
+ if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
442
+ || typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
443
+ throw new PaymentOutcomeUnknownError(authorizationId, 'approved_response_malformed');
444
+ }
383
445
  return {
384
446
  mode: 'token',
385
447
  authorizationId,
@@ -404,8 +466,16 @@ export class VaultClient {
404
466
  }
405
467
  throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
406
468
  }
469
+ if (s.status === 'expired') {
470
+ if (s.replay_attempted === false)
471
+ throw new ApprovalTimeoutError(timeoutMs);
472
+ throw new PaymentOutcomeUnknownError(authorizationId, 'expired_after_possible_replay');
473
+ }
474
+ if (s.status !== 'awaiting_approval')
475
+ throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
407
476
  }
408
- throw new ApprovalTimeoutError(timeoutMs);
477
+ // A local deadline is not server-side expiry; the person may still use the approval link.
478
+ throw new PaymentOutcomeUnknownError(authorizationId, 'local_approval_timeout');
409
479
  }
410
480
  /**
411
481
  * POST the create, with two typed twists: a 502 `amount_unverifiable`
@@ -414,10 +484,10 @@ export class VaultClient {
414
484
  * `amount_mismatch` becomes an AmountMismatchError at stage 'create' so the
415
485
  * adapters treat it as an answered request, not a dead page.
416
486
  */
417
- async createAuthorization(payload, currency) {
487
+ async createAuthorization(payload, currency, signal) {
418
488
  for (let attempt = 0;; attempt++) {
419
489
  try {
420
- return await this.post('/v2/checkout/authorizations', payload);
490
+ return await this.post('/v2/checkout/authorizations', payload, signal);
421
491
  }
422
492
  catch (err) {
423
493
  if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
@@ -430,7 +500,9 @@ export class VaultClient {
430
500
  && (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
431
501
  if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
432
502
  throw err;
433
- await sleep(this.unverifiableRetryDelaysMs[attempt]);
503
+ await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], signal);
504
+ if (signal?.aborted)
505
+ throw signal.reason;
434
506
  }
435
507
  }
436
508
  }
@@ -457,6 +529,7 @@ export class VaultClient {
457
529
  method: 'POST',
458
530
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
459
531
  body: body.toString(),
532
+ signal: AbortSignal.timeout(30_000),
460
533
  });
461
534
  if (!r.ok) {
462
535
  // RFC 6749 error shape, which real OAuth clients expect verbatim.
@@ -473,25 +546,27 @@ export class VaultClient {
473
546
  }
474
547
  /** Authenticated request that retries ONCE on a 401 with a fresh token. */
475
548
  async call(path, init = {}, retried = false) {
476
- const token = await this.accessToken();
477
- const r = await this.fetch(`${this.baseUrl}${path}`, {
549
+ const signal = init.signal ?? AbortSignal.timeout(30_000);
550
+ const token = await withSignal(this.accessToken(), signal);
551
+ const r = await withSignal(this.fetch(`${this.baseUrl}${path}`, {
478
552
  ...init,
553
+ signal,
479
554
  headers: { ...(init.headers ?? {}), authorization: `Bearer ${token}`, 'content-type': 'application/json' },
480
- });
555
+ }), signal);
481
556
  // A token can be revoked or expire early; one forced refresh, then give up.
482
557
  if (r.status === 401 && !retried) {
483
- await this.accessToken(true);
484
- return this.call(path, init, true);
558
+ await withSignal(this.accessToken(true), signal);
559
+ return this.call(path, { ...init, signal }, true);
485
560
  }
486
561
  if (!r.ok)
487
- throw new CheckoutApiError(r.status, path, await r.text());
488
- return r.json();
562
+ throw new CheckoutApiError(r.status, path, await withSignal(r.text(), signal));
563
+ return withSignal(r.json(), signal);
489
564
  }
490
- post(path, body) {
491
- return this.call(path, { method: 'POST', body: JSON.stringify(body) });
565
+ post(path, body, signal) {
566
+ return this.call(path, { method: 'POST', body: JSON.stringify(body), signal });
492
567
  }
493
- get(path) {
494
- return this.call(path);
568
+ get(path, signal) {
569
+ return this.call(path, { signal });
495
570
  }
496
571
  }
497
572
  /**
@@ -509,4 +584,25 @@ function pickHeaders(headers, allow) {
509
584
  out['content-type'] = 'application/json';
510
585
  return out;
511
586
  }
512
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
587
+ /** Settle local work even if a custom fetch implementation ignores AbortSignal. */
588
+ function withSignal(operation, signal) {
589
+ return new Promise((resolve, reject) => {
590
+ const aborted = () => { signal.removeEventListener('abort', aborted); reject(signal.reason); };
591
+ if (signal.aborted) {
592
+ operation.catch(() => { });
593
+ aborted();
594
+ return;
595
+ }
596
+ signal.addEventListener('abort', aborted, { once: true });
597
+ operation.then(value => { signal.removeEventListener('abort', aborted); resolve(value); }, error => { signal.removeEventListener('abort', aborted); reject(error); });
598
+ });
599
+ }
600
+ function interruptibleSleep(ms, signal) {
601
+ if (signal?.aborted)
602
+ return Promise.resolve();
603
+ return new Promise((resolve) => {
604
+ const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); };
605
+ const timer = setTimeout(done, ms);
606
+ signal?.addEventListener('abort', done, { once: true });
607
+ });
608
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
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
- export { attachToCdp, attachToPlaywright } from './cdp.js';
4
- export type { CdpLike, AttachOptions } from './cdp.js';
3
+ export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
4
+ export type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';
5
5
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
6
6
  export type { Substitutions } from './substitute.js';
7
7
  export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
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,5 +1,5 @@
1
- export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, } from './client.js';
2
- export { attachToCdp, attachToPlaywright } from './cdp.js';
1
+ export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, } from './client.js';
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';
5
5
  export { BUILTIN_REGISTRY, cardUrlPatterns, findRecognizer } from './registry.js';
@@ -0,0 +1,91 @@
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 unboundStripeToken;
58
+ private reconciliation;
59
+ readonly abort: AbortController;
60
+ constructor(options: LifecycleOptions);
61
+ getState(): Readonly<CheckoutState>;
62
+ isBlocked(): boolean;
63
+ isCancelled(): boolean;
64
+ begin(): void;
65
+ end(): void;
66
+ private set;
67
+ approvalCreated(authorizationId: string): void;
68
+ approvalUrl(approvalUrl: string): void;
69
+ private notify;
70
+ cancel(): void;
71
+ unsupported(): void;
72
+ prepareHandoff(replay: ReplayResponse, requestUrl: string): void;
73
+ handedOff(replay: ReplayResponse): void;
74
+ failed(error: unknown, handoffStarted?: boolean): void;
75
+ requestUserAction(reason: '3ds' | 'redirect' | 'other'): Promise<void>;
76
+ reconcile(): Promise<Readonly<CheckoutState>>;
77
+ private resolve;
78
+ retryAfterMerchantFailure(result: {
79
+ status: 'failed';
80
+ }): void;
81
+ }
82
+ /** Exact origin and path, supplied by the integrator after observing a payment endpoint. No query/body matching. */
83
+ export interface PaymentEndpointGuard {
84
+ origin: string;
85
+ pathname: string;
86
+ methods?: readonly string[];
87
+ }
88
+ export declare function paymentEndpointGuards(input?: readonly PaymentEndpointGuard[]): {
89
+ patterns: string[];
90
+ matches(url: string, method?: string): boolean;
91
+ };
@@ -0,0 +1,193 @@
1
+ import { ApprovalDeclinedError, ApprovalTimeoutError, CheckoutCancelledError, IntentNotConfirmableError, PaymentOutcomeUnknownError } from './client.js';
2
+ /** Shared by the raw CDP and Playwright transports. No browser ownership or payment execution lives here. */
3
+ export class CheckoutLifecycle {
4
+ options;
5
+ state = { status: 'idle', authorizationId: null };
6
+ held = false;
7
+ active = false;
8
+ cancelled = false;
9
+ unboundStripeToken = false;
10
+ reconciliation = null;
11
+ abort = new AbortController();
12
+ constructor(options) {
13
+ this.options = options;
14
+ }
15
+ getState() { return { ...this.state }; }
16
+ isBlocked() { return this.held || this.cancelled; }
17
+ isCancelled() { return this.cancelled; }
18
+ begin() {
19
+ this.active = true;
20
+ this.set({ status: 'awaiting_approval', authorizationId: null });
21
+ }
22
+ end() { this.active = false; }
23
+ set(state) {
24
+ this.state = state;
25
+ // Application telemetry must never interrupt a paused payment after it reaches the processor.
26
+ try {
27
+ Promise.resolve(this.options.onStateChange?.(this.getState())).catch(() => { });
28
+ }
29
+ catch { /* observer only */ }
30
+ }
31
+ approvalCreated(authorizationId) {
32
+ this.set({ ...this.state, authorizationId });
33
+ }
34
+ approvalUrl(approvalUrl) {
35
+ this.notify({ reason: 'approval', authorizationId: this.state.authorizationId, approvalUrl });
36
+ }
37
+ notify(action) {
38
+ try {
39
+ Promise.resolve(this.options.onUserAction?.(action)).catch(() => { });
40
+ }
41
+ catch { /* observer only */ }
42
+ }
43
+ cancel() {
44
+ this.cancelled = true;
45
+ this.held = true;
46
+ this.abort.abort();
47
+ this.set({ ...this.state, status: this.active || this.state.authorizationId ? 'outcome_unknown' : 'cancelled', reason: 'local_cancel' });
48
+ }
49
+ unsupported() {
50
+ this.held = true;
51
+ if (this.state.authorizationId)
52
+ return; // do not hide an outstanding/completed payment behind a later unsupported request
53
+ this.set({ ...this.state, status: 'unsupported', reason: 'unrecognized_payment_endpoint' });
54
+ }
55
+ prepareHandoff(replay, requestUrl) {
56
+ // A tokenization approval has no authoritative PaymentIntent or amount
57
+ // binding. Neither the first observed confirm nor the returned opaque
58
+ // token can supply one. Hold even in compatibility mode: this attachment
59
+ // must never turn that token into an unreviewed payment continuation.
60
+ if (!replay.mode || replay.mode === 'token') {
61
+ try {
62
+ const url = new URL(requestUrl);
63
+ if (url.origin === 'https://api.stripe.com' && ['/v1/payment_methods', '/v1/tokens'].includes(url.pathname)) {
64
+ // Set before delivery: a lost browser acknowledgement must not reset
65
+ // an already-issued token into a retryable authorization.
66
+ this.unboundStripeToken = true;
67
+ this.held = true;
68
+ }
69
+ }
70
+ catch { /* unknown URL cannot establish a token binding */ }
71
+ }
72
+ }
73
+ handedOff(replay) {
74
+ const mismatch = (!replay.mode || replay.mode === 'token') && replay.amountVerified === false;
75
+ this.held = !!this.options.requireMerchantResult || replay.mode === 'hosted_form' || mismatch || this.unboundStripeToken;
76
+ this.set({ status: 'awaiting_merchant', authorizationId: replay.authorizationId, mode: replay.mode ?? 'token',
77
+ ...(mismatch ? { reason: 'charged_amount_mismatch' } : this.unboundStripeToken ? { reason: 'stripe_tokenization_unbound' } : {}) });
78
+ }
79
+ failed(error, handoffStarted = false) {
80
+ if (this.cancelled)
81
+ return;
82
+ const authorizationId = error instanceof PaymentOutcomeUnknownError ? error.authorizationId : this.state.authorizationId;
83
+ if (handoffStarted || error instanceof PaymentOutcomeUnknownError || error instanceof IntentNotConfirmableError) {
84
+ this.held = true;
85
+ this.set({ ...this.state, authorizationId, status: 'outcome_unknown', reason: error instanceof PaymentOutcomeUnknownError ? error.reason : error instanceof IntentNotConfirmableError ? 'intent_not_confirmable' : 'browser_handoff_failed' });
86
+ }
87
+ else if (error instanceof CheckoutCancelledError) {
88
+ this.cancel();
89
+ }
90
+ else if (error instanceof ApprovalTimeoutError) {
91
+ this.set({ ...this.state, status: 'timed_out', reason: 'approval_expired' });
92
+ }
93
+ else if (error instanceof ApprovalDeclinedError) {
94
+ this.set({ ...this.state, status: 'declined', reason: 'approval_declined' });
95
+ }
96
+ else {
97
+ this.set({ ...this.state, status: 'failed', reason: 'checkout_failed' });
98
+ }
99
+ }
100
+ async requestUserAction(reason) {
101
+ if (this.state.status === 'completed')
102
+ return;
103
+ this.held = true;
104
+ this.set({ ...this.state, status: 'requires_user_action', reason });
105
+ // Hooks deliver a link/live view owned by the integrator; the SDK does not invent challenge URLs.
106
+ try {
107
+ await this.options.onUserAction?.({ reason, authorizationId: this.state.authorizationId });
108
+ }
109
+ catch { /* observer only */ }
110
+ }
111
+ reconcile() {
112
+ if (this.state.status === 'completed')
113
+ return Promise.resolve(this.getState());
114
+ if (this.reconciliation)
115
+ return this.reconciliation;
116
+ if (!this.options.resolveMerchantResult)
117
+ return Promise.resolve(this.getState());
118
+ if (this.active)
119
+ throw new Error('Wait for the paused request to finish before reconciling the merchant order.');
120
+ this.reconciliation = this.resolve().finally(() => { this.reconciliation = null; });
121
+ return this.reconciliation;
122
+ }
123
+ async resolve() {
124
+ let result;
125
+ try {
126
+ result = await this.options.resolveMerchantResult(this.getState());
127
+ }
128
+ catch {
129
+ result = { status: 'unknown' };
130
+ }
131
+ if (!result || typeof result !== 'object')
132
+ result = { status: 'unknown' };
133
+ if (result.status === 'completed' && typeof result.orderId === 'string' && result.orderId.length > 0) {
134
+ this.held = true;
135
+ this.set({ ...this.state, status: 'completed', orderId: result.orderId });
136
+ }
137
+ else if (result.status === 'failed') {
138
+ // Keep the guard armed until the application deliberately starts another attempt.
139
+ this.held = true;
140
+ this.set({ ...this.state, status: 'failed', reason: 'merchant_confirmed_failure' });
141
+ }
142
+ else if (result.status === 'requires_user_action') {
143
+ await this.requestUserAction(result.reason);
144
+ }
145
+ else {
146
+ this.held = true;
147
+ this.set({ ...this.state, status: result.status === 'pending' ? 'awaiting_merchant' : 'outcome_unknown', reason: 'merchant_not_confirmed' });
148
+ }
149
+ return this.getState();
150
+ }
151
+ retryAfterMerchantFailure(result) {
152
+ if (this.active || this.reconciliation)
153
+ throw new Error('Cannot start another attempt while payment or reconciliation is in progress.');
154
+ if (this.cancelled)
155
+ throw new Error('This attachment was cancelled; attach again after reconciling the merchant order.');
156
+ if (result?.status !== 'failed')
157
+ throw new Error('A merchant-confirmed failure is required before retrying.');
158
+ if (this.state.status === 'completed')
159
+ throw new Error('This order is already complete. Use a new attachment for another order.');
160
+ if (this.unboundStripeToken)
161
+ throw new Error('This attachment delivered an unbound Stripe token; reconcile the merchant order and use a separately validated checkout flow.');
162
+ this.held = false;
163
+ this.set({ status: 'idle', authorizationId: null });
164
+ }
165
+ }
166
+ export function paymentEndpointGuards(input = []) {
167
+ const guards = input.map((guard) => {
168
+ const origin = new URL(guard.origin);
169
+ if (!['http:', 'https:'].includes(origin.protocol) || origin.origin !== guard.origin
170
+ || !guard.pathname.startsWith('/') || /[?#*]/.test(guard.pathname)) {
171
+ throw new Error('Payment endpoint guards require a canonical http(s) origin and an exact path without wildcards or query strings.');
172
+ }
173
+ const methods = (guard.methods ?? ['POST', 'PUT', 'PATCH']).map((method) => method.toUpperCase());
174
+ if (!methods.length || methods.some((method) => !['POST', 'PUT', 'PATCH', 'DELETE'].includes(method))) {
175
+ throw new Error('Payment endpoint guards can only block explicit mutation methods.');
176
+ }
177
+ return { ...guard, methods };
178
+ });
179
+ return {
180
+ patterns: guards.map((guard) => `${guard.origin}${guard.pathname}*`),
181
+ matches(raw, method) {
182
+ let url;
183
+ try {
184
+ url = new URL(raw);
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ return guards.some((guard) => url.origin === guard.origin && url.pathname === guard.pathname
190
+ && (method === undefined || guard.methods.includes(method.toUpperCase())));
191
+ },
192
+ };
193
+ }