@agent-cards/checkout 0.3.0 → 0.4.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
@@ -6,6 +6,16 @@ import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecogni
6
6
  */
7
7
  export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
8
8
  const AMOUNT_AUTHORITIES = ['stripe_payment_intent', 'hosted_form_sum', 'display_only'];
9
+ export class CheckoutPreparationError extends Error {
10
+ preparationId;
11
+ reason;
12
+ constructor(preparationId, reason) {
13
+ super(`Checkout preparation unavailable: ${reason}`);
14
+ this.preparationId = preparationId;
15
+ this.reason = reason;
16
+ this.name = 'CheckoutPreparationError';
17
+ }
18
+ }
9
19
  export class CardEncryptedError extends Error {
10
20
  psp;
11
21
  constructor(psp) {
@@ -223,6 +233,8 @@ export class VaultClient {
223
233
  pollIntervalMs;
224
234
  unverifiableRetryDelaysMs;
225
235
  registry;
236
+ preparations = new WeakSet();
237
+ usedPreparations = new WeakSet();
226
238
  constructor(opts) {
227
239
  this.opts = opts;
228
240
  this.baseUrl = (opts.baseUrl ?? 'https://api.agentcard.sh').replace(/\/$/, '');
@@ -274,14 +286,125 @@ export class VaultClient {
274
286
  cardUrlPatterns() {
275
287
  return deriveCardUrlPatterns(this.registry);
276
288
  }
289
+ /** Wait for real device approval before the caller starts native tokenization. */
290
+ async prepareCheckout(input) {
291
+ input = { ...input };
292
+ const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
293
+ if (input.psp !== 'square' || !['production', 'sandbox'].includes(input.environment))
294
+ throw fail('unsupported_processor');
295
+ if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0 || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
296
+ throw fail('amount_required');
297
+ const origin = new URL(input.merchantOrigin);
298
+ if (!(origin.protocol === 'https:' || (origin.protocol === 'http:' && origin.hostname === 'localhost')) || origin.origin !== input.merchantOrigin)
299
+ throw fail('merchant_origin_invalid');
300
+ if (!input.checkoutKey || !input.user || !input.merchant)
301
+ throw fail('checkout_context_required');
302
+ const timeoutMs = input.timeoutMs ?? 15 * 60_000;
303
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
304
+ throw fail('timeout_invalid');
305
+ const signal = input.signal ? AbortSignal.any([input.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
306
+ if (signal.aborted)
307
+ throw fail('cancelled');
308
+ let id = null;
309
+ let ready = false;
310
+ try {
311
+ // Drain a sent creation even after caller cancellation to retire its ID.
312
+ // The separate stop signal prevents dispatch after a slow OAuth exchange.
313
+ const created = await this.post('/v2/checkout/preparations', {
314
+ user: input.user, merchant: input.merchant, amount_cents: input.amountCents, currency: input.currency.toLowerCase(),
315
+ ...(input.cardId ? { card_id: input.cardId } : {}), psp: input.psp, mode: 'token',
316
+ environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
317
+ }, AbortSignal.timeout(30_000), signal);
318
+ if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
319
+ throw fail('create_unconfirmed');
320
+ const preparationId = created.id;
321
+ id = preparationId;
322
+ try {
323
+ Promise.resolve(input.onPreparationCreated?.(preparationId)).catch(() => { });
324
+ }
325
+ catch { /* observer only */ }
326
+ if (signal.aborted)
327
+ throw fail('cancelled', id);
328
+ if (typeof created.approvalUrl !== 'string')
329
+ throw fail('approval_url_missing', id);
330
+ try {
331
+ Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
332
+ }
333
+ catch { /* observer only */ }
334
+ while (!signal.aborted) {
335
+ const state = await this.get(`/v2/checkout/preparations/${id}`, signal);
336
+ if (signal.aborted)
337
+ throw fail('cancelled', id);
338
+ if (state?.id !== id)
339
+ throw fail('status_unconfirmed', id);
340
+ if (state.status === 'ready') {
341
+ const expiry = Date.parse(state.ready_expires_at);
342
+ if (!Number.isFinite(expiry) || expiry <= Date.now() || typeof state.card_id !== 'string' || !state.card_id
343
+ || state.payment_status !== 'not_started' || state.amount_authority !== 'display_only'
344
+ || state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
345
+ || state.amount_cents !== input.amountCents || state.currency !== input.currency.toLowerCase()
346
+ || state.psp !== 'square' || state.mode !== 'token' || state.environment !== input.environment
347
+ || state.checkout_key !== input.checkoutKey)
348
+ throw fail('ready_unconfirmed', id);
349
+ const prepared = Object.freeze({
350
+ id: preparationId, status: 'ready', psp: input.psp, environment: input.environment, expiresAt: state.ready_expires_at,
351
+ cardId: state.card_id, user: input.user, merchant: input.merchant, amountCents: input.amountCents,
352
+ currency: input.currency.toLowerCase(), merchantOrigin: input.merchantOrigin, checkoutKey: input.checkoutKey,
353
+ paymentStatus: 'not_started', amountAuthority: 'display_only',
354
+ });
355
+ this.preparations.add(prepared);
356
+ ready = true;
357
+ return prepared;
358
+ }
359
+ if (state.status !== 'awaiting_approval')
360
+ throw fail(['cancelled', 'expired', 'bound'].includes(state.status) ? state.status : 'status_unconfirmed', id);
361
+ await interruptibleSleep(this.pollIntervalMs, signal);
362
+ }
363
+ throw fail('cancelled', id);
364
+ }
365
+ catch (error) {
366
+ if (error instanceof CheckoutPreparationError)
367
+ throw error;
368
+ throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
369
+ }
370
+ finally {
371
+ if (id && !ready)
372
+ await this.cancelPreparation(id).catch(() => { });
373
+ }
374
+ }
375
+ /** Cancel only an unconsumed preparation; a bound request is reconciled separately. */
376
+ async cancelPreparation(id) {
377
+ if (!/^cprep_[A-Za-z0-9_-]{1,128}$/.test(id))
378
+ throw new CheckoutPreparationError(null, 'id_invalid');
379
+ const state = await this.post(`/v2/checkout/preparations/${id}/cancel`, {}, AbortSignal.timeout(5_000));
380
+ if (state?.id !== id || !['cancelled', 'expired'].includes(state.status))
381
+ throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
382
+ }
277
383
  /**
278
384
  * Hand us a paused tokenization request. We ask the cardholder to approve,
279
385
  * their device supplies the card and calls the merchant, and you get back the
280
386
  * response to replay into the browser. Your process never sees a card.
281
387
  */
282
388
  async authorize(input) {
389
+ const preparation = input.preparation;
390
+ if (preparation) {
391
+ if (!this.preparations.has(preparation) || this.usedPreparations.has(preparation))
392
+ throw new CheckoutPreparationError(preparation.id ?? null, 'already_used_or_foreign');
393
+ // Consume locally before any await, including OAuth, and never recycle it.
394
+ this.usedPreparations.add(preparation);
395
+ const url = new URL(input.request.url);
396
+ const host = preparation.environment === 'production' ? 'pci-connect.squareup.com' : 'pci-connect.squareupsandbox.com';
397
+ if (Date.parse(preparation.expiresAt) <= Date.now())
398
+ throw new CheckoutPreparationError(preparation.id, 'expired');
399
+ if (input.user !== preparation.user || input.merchant !== preparation.merchant || input.amountCents !== preparation.amountCents
400
+ || input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
401
+ || url.origin !== `https://${host}` || url.pathname !== '/v2/card-nonce' || url.username || url.password)
402
+ throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
403
+ }
283
404
  if (input.signal?.aborted)
284
405
  throw new CheckoutCancelledError();
406
+ if (input.merchantSignal?.aborted)
407
+ throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
285
408
  const rec = findRecognizer(input.request.url, this.registry);
286
409
  if (!rec)
287
410
  throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
@@ -333,6 +456,7 @@ export class VaultClient {
333
456
  // the recognizer and refuses a disagreement before a row exists.
334
457
  mode,
335
458
  ...(input.cardId ? { cardId: input.cardId } : {}),
459
+ ...(preparation ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin } : {}),
336
460
  request: {
337
461
  url: input.request.url,
338
462
  method: input.request.method,
@@ -341,141 +465,218 @@ export class VaultClient {
341
465
  },
342
466
  };
343
467
  try {
344
- created = await this.createAuthorization(payload, input.currency, operationSignal);
468
+ created = await this.createAuthorization(payload, input.currency, AbortSignal.any([operationSignal, AbortSignal.timeout(30_000)]), input.merchantSignal);
345
469
  }
346
470
  catch (error) {
471
+ if (error instanceof PaymentOutcomeUnknownError) {
472
+ if (preparation && !error.authorizationId)
473
+ throw new PaymentOutcomeUnknownError(await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated), error.reason);
474
+ throw error;
475
+ }
476
+ if (preparation && error instanceof CheckoutApiError && error.code === 'preparation_bound') {
477
+ const id = typeof error.details.authorization_id === 'string' && /^cauth_[A-Za-z0-9_-]+$/.test(error.details.authorization_id) ? error.details.authorization_id : null;
478
+ if (id) {
479
+ try {
480
+ Promise.resolve(input.onAuthorizationCreated?.(id)).catch(() => { });
481
+ }
482
+ catch { /* observer only */ }
483
+ }
484
+ throw new PaymentOutcomeUnknownError(id, 'preparation_already_bound');
485
+ }
347
486
  // A missing answer or generic 5xx can hide a committed row and a delivered approval link.
348
487
  // Only the documented pre-create read-back errors prove it is safe to retry.
349
488
  const safeReadFailure = error instanceof CheckoutApiError
350
489
  && error.status === 502 && (error.code === 'amount_unverifiable' || error.code === 'cse_key_unavailable');
351
490
  if ((error instanceof CheckoutApiError && error.status >= 500 && !safeReadFailure)
352
491
  || (!(error instanceof CheckoutApiError) && !(error instanceof ApprovalDeclinedError))) {
353
- throw new PaymentOutcomeUnknownError(null, 'authorization_create_unanswered');
492
+ throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_unanswered');
354
493
  }
355
494
  throw error;
356
495
  }
357
496
  if (!created || typeof created.id !== 'string' || !created.id)
358
- throw new PaymentOutcomeUnknownError(null, 'authorization_create_malformed');
497
+ throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_malformed');
359
498
  const authorizationId = created.id;
499
+ let failed = false;
500
+ const stopSignal = input.merchantSignal
501
+ ? AbortSignal.any([input.merchantSignal, ...(input.signal ? [input.signal] : [])]) : input.signal;
360
502
  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 */ }
368
- while (Date.now() < deadline) {
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
503
  try {
378
- s = await this.get(`/v2/checkout/authorizations/${authorizationId}`, pollSignal);
379
- }
380
- catch {
381
- throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_poll_failed');
504
+ Promise.resolve(input.onAuthorizationCreated?.(authorizationId)).catch(() => { });
382
505
  }
383
- if (!s || typeof s !== 'object' || Array.isArray(s) || typeof s.status !== 'string') {
384
- throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_malformed');
385
- }
386
- const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
387
- ? { amountAuthority: s.amount_authority }
388
- : {};
389
- if (s.status === 'submitted_on_device') {
390
- // The device attested that the processor's form left it; the stamp
391
- // is the whole fact and it is NOT an approval (see HostedFormReplay).
392
- // Only a hosted_form row may carry this status; a stamp without its
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.
395
- const mode = typeof s.mode === 'string' ? s.mode : 'token';
396
- if (mode !== 'hosted_form')
397
- throw new PaymentOutcomeUnknownError(authorizationId, 'submission_mode_mismatch');
398
- if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
399
- throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
506
+ catch { /* observer only */ }
507
+ if (input.merchantSignal?.aborted)
508
+ throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
509
+ if (!preparation) {
510
+ try {
511
+ Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
400
512
  }
401
- return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
513
+ catch { /* approval delivery must not lose an existing authorization */ }
402
514
  }
403
- if (s.status === 'approved') {
404
- const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
405
- if (approvedMode === 'hosted_form') {
406
- // Never: the API finishes a hosted form as submitted_on_device, and
407
- // its database refuses `approved` on that mode. An answer that says
408
- // otherwise is not one to act on as a payment.
409
- throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
515
+ while (Date.now() < deadline) {
516
+ if (stopSignal?.aborted)
517
+ throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
518
+ await interruptibleSleep(Math.min(this.pollIntervalMs, Math.max(0, deadline - Date.now())), stopSignal);
519
+ if (stopSignal?.aborted)
520
+ throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
521
+ let s;
522
+ const deadlineSignal = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
523
+ const pollSignal = stopSignal ? AbortSignal.any([stopSignal, deadlineSignal]) : deadlineSignal;
524
+ try {
525
+ s = await this.get(`/v2/checkout/authorizations/${authorizationId}`, pollSignal);
526
+ }
527
+ catch {
528
+ throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'authorization_poll_failed');
529
+ }
530
+ if (input.merchantSignal?.aborted)
531
+ throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
532
+ if (!s || typeof s !== 'object' || Array.isArray(s) || typeof s.status !== 'string') {
533
+ throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_malformed');
534
+ }
535
+ const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
536
+ ? { amountAuthority: s.amount_authority }
537
+ : {};
538
+ if (s.status === 'submitted_on_device') {
539
+ // The device attested that the processor's form left it; the stamp
540
+ // is the whole fact and it is NOT an approval (see HostedFormReplay).
541
+ // Only a hosted_form row may carry this status; a stamp without its
542
+ // time is not one this SDK can act on. The device may already have paid,
543
+ // so the adapter holds the attempt until the merchant is reconciled.
544
+ const mode = typeof s.mode === 'string' ? s.mode : 'token';
545
+ if (mode !== 'hosted_form')
546
+ throw new PaymentOutcomeUnknownError(authorizationId, 'submission_mode_mismatch');
547
+ if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
548
+ throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
549
+ }
550
+ return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
410
551
  }
411
- if (approvedMode === 'cse') {
412
- const sub = s.substitutions;
413
- const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
414
- && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
415
- && Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
416
- if (!fieldsOk)
417
- throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
418
- // `remove`: sibling keys the API says to drop with the swap (Adyen's
419
- // `brand`, stamped by adyen-web from the agent's dummy digits). Absent
420
- // on an older API; anything but a list of names is refused, since a
421
- // half-understood instruction would continue a body Adyen refuses.
422
- const removeRaw = sub.remove;
423
- if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
424
- throw new PaymentOutcomeUnknownError(authorizationId, 'cse_remove_malformed');
552
+ if (s.status === 'approved') {
553
+ const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
554
+ if (approvedMode === 'hosted_form') {
555
+ // Never: the API finishes a hosted form as submitted_on_device, and
556
+ // its database refuses `approved` on that mode. An answer that says
557
+ // otherwise is not one to act on as a payment.
558
+ throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
559
+ }
560
+ if (approvedMode === 'cse') {
561
+ const sub = s.substitutions;
562
+ const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
563
+ && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
564
+ && Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
565
+ if (!fieldsOk)
566
+ throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
567
+ // `remove`: sibling keys the API says to drop with the swap (Adyen's
568
+ // `brand`, stamped by adyen-web from the agent's dummy digits). Absent
569
+ // on an older API; anything but a list of names is refused, since a
570
+ // half-understood instruction would continue a body Adyen refuses.
571
+ const removeRaw = sub.remove;
572
+ if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
573
+ throw new PaymentOutcomeUnknownError(authorizationId, 'cse_remove_malformed');
574
+ }
575
+ return {
576
+ mode: 'cse',
577
+ authorizationId,
578
+ substitutions: {
579
+ encoding: 'json',
580
+ at: sub.at,
581
+ fields: { ...sub.fields },
582
+ ...(removeRaw ? { remove: [...removeRaw] } : {}),
583
+ },
584
+ ...amountAuthority,
585
+ };
586
+ }
587
+ if (approvedMode !== 'token')
588
+ throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
589
+ const response = s.response;
590
+ if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
591
+ || typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
592
+ throw new PaymentOutcomeUnknownError(authorizationId, 'approved_response_malformed');
425
593
  }
426
594
  return {
427
- mode: 'cse',
595
+ mode: 'token',
428
596
  authorizationId,
429
- substitutions: {
430
- encoding: 'json',
431
- at: sub.at,
432
- fields: { ...sub.fields },
433
- ...(removeRaw ? { remove: [...removeRaw] } : {}),
434
- },
597
+ ...response,
598
+ amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
599
+ chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
600
+ chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
601
+ chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
435
602
  ...amountAuthority,
436
603
  };
437
604
  }
438
- if (approvedMode !== 'token')
439
- throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
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
- }
445
- return {
446
- mode: 'token',
447
- authorizationId,
448
- ...response,
449
- amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
450
- chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
451
- chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
452
- chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
453
- ...amountAuthority,
454
- };
455
- }
456
- if (s.status === 'declined') {
457
- // The pre-replay checks declined it: typed, with the numbers, so the
458
- // caller can say what happened rather than "the user said no".
459
- if (s.reason === 'amount_mismatch') {
460
- 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');
605
+ if (s.status === 'declined') {
606
+ // The pre-replay checks declined it: typed, with the numbers, so the
607
+ // caller can say what happened rather than "the user said no".
608
+ if (s.reason === 'amount_mismatch') {
609
+ 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');
610
+ }
611
+ if (s.reason === 'intent_not_confirmable')
612
+ throw new IntentNotConfirmableError(String(created.id));
613
+ if (s.reason === 'processor_refused') {
614
+ throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
615
+ }
616
+ throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
461
617
  }
462
- if (s.reason === 'intent_not_confirmable')
463
- throw new IntentNotConfirmableError(String(created.id));
464
- if (s.reason === 'processor_refused') {
465
- throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
618
+ if (s.status === 'expired') {
619
+ if (s.replay_attempted === false)
620
+ throw new ApprovalTimeoutError(timeoutMs);
621
+ throw new PaymentOutcomeUnknownError(authorizationId, 'expired_after_possible_replay');
466
622
  }
467
- throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
623
+ if (s.status !== 'awaiting_approval')
624
+ throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
468
625
  }
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');
626
+ // A local deadline is not server-side expiry; the person may still use the approval link.
627
+ throw new PaymentOutcomeUnknownError(authorizationId, 'local_approval_timeout');
628
+ }
629
+ catch (error) {
630
+ failed = true;
631
+ if (input.merchantSignal?.aborted)
632
+ throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
633
+ throw error;
634
+ }
635
+ finally {
636
+ if (input.merchantSignal?.aborted || (preparation && failed)) {
637
+ // Drain a create acknowledgement even after the merchant aborts so its
638
+ // known ID can be retired. An unacknowledged create remains unknown.
639
+ // A started/finalized replay or failed cleanup never becomes a claimed
640
+ // cancellation, and this best-effort cleanup never retries a payment.
641
+ await this.cancelAuthorization(authorizationId).catch(() => { });
473
642
  }
474
- if (s.status !== 'awaiting_approval')
475
- throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
476
643
  }
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');
644
+ }
645
+ /** A lost bind acknowledgement must never resume the request. Recover metadata only for safe cleanup. */
646
+ async retireUncertainPreparation(preparation, onCreated) {
647
+ // Race cancellation atomically against a create still arriving at the API.
648
+ // A bound preparation refuses cancellation; resolve its ID exactly once below.
649
+ await this.cancelPreparation(preparation.id).catch(() => { });
650
+ let state;
651
+ try {
652
+ state = await this.get(`/v2/checkout/preparations/${preparation.id}`, AbortSignal.timeout(3_000));
653
+ }
654
+ catch {
655
+ return null;
656
+ }
657
+ if (state?.id !== preparation.id || state.status !== 'bound' || typeof state.authorization_id !== 'string'
658
+ || !/^cauth_[A-Za-z0-9_-]{1,128}$/.test(state.authorization_id))
659
+ return null;
660
+ const id = state.authorization_id;
661
+ try {
662
+ Promise.resolve(onCreated?.(id)).catch(() => { });
663
+ }
664
+ catch { /* observer only */ }
665
+ await this.cancelAuthorization(id).catch(() => { });
666
+ return id;
667
+ }
668
+ /** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
669
+ async cancelAuthorization(authorizationId) {
670
+ if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
671
+ throw new Error('Invalid authorization ID.');
672
+ const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/cancel`, {}, AbortSignal.timeout(5_000));
673
+ if (result?.id !== authorizationId || result.status !== 'declined'
674
+ || result.reason !== 'merchant_request_aborted' || result.cancelled !== true
675
+ || result.processor_request_started !== false) {
676
+ throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_cancel_unconfirmed');
677
+ }
678
+ return { id: authorizationId, status: 'declined', reason: 'merchant_request_aborted',
679
+ cancelled: true, processor_request_started: false };
479
680
  }
480
681
  /**
481
682
  * POST the create, with two typed twists: a 502 `amount_unverifiable`
@@ -484,10 +685,12 @@ export class VaultClient {
484
685
  * `amount_mismatch` becomes an AmountMismatchError at stage 'create' so the
485
686
  * adapters treat it as an answered request, not a dead page.
486
687
  */
487
- async createAuthorization(payload, currency, signal) {
688
+ async createAuthorization(payload, currency, signal, stopRetries) {
488
689
  for (let attempt = 0;; attempt++) {
690
+ if (stopRetries?.aborted)
691
+ throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
489
692
  try {
490
- return await this.post('/v2/checkout/authorizations', payload, signal);
693
+ return await this.post('/v2/checkout/authorizations', payload, signal, stopRetries);
491
694
  }
492
695
  catch (err) {
493
696
  if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
@@ -496,11 +699,11 @@ export class VaultClient {
496
699
  }
497
700
  // Two 502s the API asks to be retried: Stripe did not answer the
498
701
  // amount read-back, or Adyen did not answer the public-key fetch.
499
- const retryable = err instanceof CheckoutApiError && err.status === 502
702
+ const retryable = !payload.preparation_id && err instanceof CheckoutApiError && err.status === 502
500
703
  && (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
501
704
  if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
502
705
  throw err;
503
- await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], signal);
706
+ await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], stopRetries ? AbortSignal.any([stopRetries, ...(signal ? [signal] : [])]) : signal);
504
707
  if (signal?.aborted)
505
708
  throw signal.reason;
506
709
  }
@@ -545,9 +748,14 @@ export class VaultClient {
545
748
  return this.inflight;
546
749
  }
547
750
  /** Authenticated request that retries ONCE on a 401 with a fresh token. */
548
- async call(path, init = {}, retried = false) {
751
+ async call(path, init = {}, retried = false, stopNewRequests) {
549
752
  const signal = init.signal ?? AbortSignal.timeout(30_000);
550
753
  const token = await withSignal(this.accessToken(), signal);
754
+ // Auth can outlive the merchant request. Stop a create that has not left
755
+ // yet, while preserving the response of one already sent for cleanup.
756
+ if (stopNewRequests?.aborted)
757
+ throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
758
+ signal.throwIfAborted();
551
759
  const r = await withSignal(this.fetch(`${this.baseUrl}${path}`, {
552
760
  ...init,
553
761
  signal,
@@ -555,15 +763,17 @@ export class VaultClient {
555
763
  }), signal);
556
764
  // A token can be revoked or expire early; one forced refresh, then give up.
557
765
  if (r.status === 401 && !retried) {
766
+ if (stopNewRequests?.aborted)
767
+ throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
558
768
  await withSignal(this.accessToken(true), signal);
559
- return this.call(path, { ...init, signal }, true);
769
+ return this.call(path, { ...init, signal }, true, stopNewRequests);
560
770
  }
561
771
  if (!r.ok)
562
772
  throw new CheckoutApiError(r.status, path, await withSignal(r.text(), signal));
563
773
  return withSignal(r.json(), signal);
564
774
  }
565
- post(path, body, signal) {
566
- return this.call(path, { method: 'POST', body: JSON.stringify(body), signal });
775
+ post(path, body, signal, stopNewRequests) {
776
+ return this.call(path, { method: 'POST', body: JSON.stringify(body), signal }, false, stopNewRequests);
567
777
  }
568
778
  get(path, signal) {
569
779
  return this.call(path, { signal });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, } from './client.js';
2
- export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, } from './client.js';
1
+ export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
2
+ export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, PrepareCheckoutOptions, PrepareCheckoutInput, PreparedCheckout, } from './client.js';
3
3
  export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
4
4
  export type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';
5
5
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, } from './client.js';
1
+ export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } 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';
@@ -1,4 +1,4 @@
1
- import { type ReplayResponse } from './client.js';
1
+ import { CheckoutPreparationError, type PrepareCheckoutOptions, type PreparedCheckout, type ReplayResponse } from './client.js';
2
2
  import type { CheckoutMode } from './registry.js';
3
3
  /** A processor approval is not an order. Only the merchant can confirm this result. */
4
4
  export type MerchantResult = {
@@ -13,8 +13,9 @@ export type MerchantResult = {
13
13
  reason: '3ds' | 'redirect' | 'other';
14
14
  };
15
15
  export interface CheckoutState {
16
- status: 'idle' | 'awaiting_approval' | 'awaiting_merchant' | 'requires_user_action' | 'completed' | 'declined' | 'timed_out' | 'cancelled' | 'unsupported' | 'outcome_unknown' | 'failed';
16
+ status: 'idle' | 'awaiting_approval' | 'ready_to_submit' | 'awaiting_merchant' | 'requires_user_action' | 'completed' | 'declined' | 'timed_out' | 'cancelled' | 'unsupported' | 'outcome_unknown' | 'failed';
17
17
  authorizationId: string | null;
18
+ preparationId?: string;
18
19
  mode?: CheckoutMode;
19
20
  orderId?: string;
20
21
  /** Stable SDK category; never includes a request body, processor response, or approval link. */
@@ -36,7 +37,9 @@ export interface LifecycleOptions {
36
37
  }
37
38
  export interface CheckoutController {
38
39
  getState(): Readonly<CheckoutState>;
39
- /** Stop this attachment locally. Does not revoke an approval link or cancel a processor payment. */
40
+ /** Await device consent before the caller starts the first native Pay action. One use per attachment. */
41
+ prepare(options: PrepareCheckoutOptions): Promise<PreparedCheckout>;
42
+ /** Stop locally and best-effort retire an unbound preparation. Does not cancel a processor payment. */
40
43
  cancel(): void;
41
44
  /** Ask the application's merchant resolver. A rejection records unknown; never automatically retries payment. */
42
45
  reconcile(): Promise<Readonly<CheckoutState>>;
@@ -54,11 +57,20 @@ export declare class CheckoutLifecycle implements CheckoutController {
54
57
  private held;
55
58
  private active;
56
59
  private cancelled;
60
+ private merchantAborted;
57
61
  private unboundStripeToken;
58
62
  private reconciliation;
63
+ private preparationHandler?;
64
+ private preparationUsed;
59
65
  readonly abort: AbortController;
60
66
  constructor(options: LifecycleOptions);
61
67
  getState(): Readonly<CheckoutState>;
68
+ setPreparationHandler(handler: (options: PrepareCheckoutOptions) => Promise<PreparedCheckout>): void;
69
+ prepare(options: PrepareCheckoutOptions): Promise<PreparedCheckout>;
70
+ preparing(): void;
71
+ preparationCreated(preparationId: string): void;
72
+ prepared(preparation: PreparedCheckout): void;
73
+ preparationFailed(error: CheckoutPreparationError): void;
62
74
  isBlocked(): boolean;
63
75
  isCancelled(): boolean;
64
76
  begin(): void;
@@ -68,6 +80,8 @@ export declare class CheckoutLifecycle implements CheckoutController {
68
80
  approvalUrl(approvalUrl: string): void;
69
81
  private notify;
70
82
  cancel(): void;
83
+ /** The exact browser request is gone; a late approval cannot reopen it. */
84
+ merchantRequestAborted(): void;
71
85
  unsupported(): void;
72
86
  prepareHandoff(replay: ReplayResponse, requestUrl: string): void;
73
87
  handedOff(replay: ReplayResponse): void;