@visa/cli 4.1.0-rc.17 → 4.1.0-rc.171

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.
Files changed (77) hide show
  1. package/README.md +189 -232
  2. package/dist/checkout-engine/adapters/generic.d.ts +67 -0
  3. package/dist/checkout-engine/adapters/generic.js +233 -14
  4. package/dist/checkout-engine/adapters/index.d.ts +4 -1
  5. package/dist/checkout-engine/adapters/index.js +10 -3
  6. package/dist/checkout-engine/adapters/shopify.d.ts +55 -0
  7. package/dist/checkout-engine/adapters/shopify.js +514 -0
  8. package/dist/checkout-engine/amount.d.ts +15 -0
  9. package/dist/checkout-engine/amount.js +72 -0
  10. package/dist/checkout-engine/cli-engine.d.ts +259 -4
  11. package/dist/checkout-engine/cli-engine.js +797 -43
  12. package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
  13. package/dist/checkout-engine/confirmed-merchants.js +165 -0
  14. package/dist/checkout-engine/detect.d.ts +1 -1
  15. package/dist/checkout-engine/detect.js +26 -0
  16. package/dist/checkout-engine/evidence.d.ts +4 -1
  17. package/dist/checkout-engine/evidence.js +51 -6
  18. package/dist/checkout-engine/executor.d.ts +62 -4
  19. package/dist/checkout-engine/executor.js +402 -122
  20. package/dist/checkout-engine/hosted-approval.d.ts +124 -7
  21. package/dist/checkout-engine/hosted-approval.js +381 -54
  22. package/dist/checkout-engine/index.d.ts +8 -2
  23. package/dist/checkout-engine/index.js +7 -1
  24. package/dist/checkout-engine/instrument.d.ts +7 -0
  25. package/dist/checkout-engine/instrument.js +4 -0
  26. package/dist/checkout-engine/known-merchants.d.ts +10 -0
  27. package/dist/checkout-engine/known-merchants.js +38 -0
  28. package/dist/checkout-engine/live-fill-approval.d.ts +5 -20
  29. package/dist/checkout-engine/live-fill-approval.js +20 -51
  30. package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
  31. package/dist/checkout-engine/mandate/card-mandate.js +227 -0
  32. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +178 -0
  33. package/dist/checkout-engine/mandate/mandate-ledger.js +395 -0
  34. package/dist/checkout-engine/outcome.d.ts +2 -2
  35. package/dist/checkout-engine/outcome.js +36 -1
  36. package/dist/checkout-engine/owner-only-file.d.ts +9 -0
  37. package/dist/checkout-engine/owner-only-file.js +20 -1
  38. package/dist/checkout-engine/receipt-dir.d.ts +6 -0
  39. package/dist/checkout-engine/receipt-dir.js +8 -0
  40. package/dist/checkout-engine/receipt.d.ts +42 -2
  41. package/dist/checkout-engine/receipt.js +43 -14
  42. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  43. package/dist/checkout-engine/trace-handles.js +12 -0
  44. package/dist/checkout-engine/types.d.ts +20 -2
  45. package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
  46. package/dist/checkout-engine/unresolved-charges.js +125 -0
  47. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +53 -1
  48. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +78 -10
  49. package/dist/checkout-engine/vgs-live-instrument.d.ts +38 -35
  50. package/dist/checkout-engine/vgs-live-instrument.js +51 -74
  51. package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
  52. package/dist/checkout-engine/vic-confirmation.js +9 -3
  53. package/dist/checkout-engine/web-bot-auth.d.ts +92 -0
  54. package/dist/checkout-engine/web-bot-auth.js +159 -0
  55. package/dist/cli.js +665 -502
  56. package/dist/mcp-apps/ucp-checkout.html +280 -0
  57. package/dist/mcp-server/index.js +516 -174
  58. package/dist/skills/pair-visa-agent/RUNTIMES.md +93 -0
  59. package/dist/skills/pair-visa-agent/SKILL.md +465 -221
  60. package/dist/subway-direct.mjs +1 -0
  61. package/install.ps1 +3 -41
  62. package/install.sh +4 -36
  63. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  64. package/package.json +18 -14
  65. package/server.json +3 -3
  66. package/dist/checkout-engine/inline-target.d.ts +0 -13
  67. package/dist/checkout-engine/inline-target.js +0 -37
  68. package/dist/checkout-engine/pay-args.d.ts +0 -14
  69. package/dist/checkout-engine/pay-args.js +0 -44
  70. package/dist/checkout-engine/pay.d.ts +0 -1
  71. package/dist/checkout-engine/pay.js +0 -13
  72. package/dist/checkout-engine/repo-env.d.ts +0 -11
  73. package/dist/checkout-engine/repo-env.js +0 -23
  74. package/dist/checkout-engine/run-live-fill.d.ts +0 -1
  75. package/dist/checkout-engine/run-live-fill.js +0 -443
  76. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
  77. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
@@ -2,7 +2,8 @@
2
2
  // prepareCheckout(): navigate -> stabilize -> mandate gate -> resolve the
3
3
  // review facts. No credential is requested or filled in this phase.
4
4
  // submitApprovedCheckout(): verify the approval is bound to that review ->
5
- // revalidate -> mint credential -> fill -> revalidate -> submit.
5
+ // revalidate -> in dry-run stop without requesting a credential; in submit
6
+ // mode mint -> fill -> revalidate -> submit.
6
7
  //
7
8
  // runCheckout() remains the one-shot, auto-approved compatibility wrapper.
8
9
  //
@@ -11,16 +12,22 @@
11
12
  // runs before instrument.getCredential(). No credential is minted and no
12
13
  // field is filled on a page the mandate does not cover.
13
14
  // - The PRE-SUBMIT gate re-runs the full check with the resolved amount and
14
- // currency. No submit ever happens without it passing. Dry-run never
15
- // clicks submit.
15
+ // currency. No submit ever happens without it passing. Dry-run requests no
16
+ // credential and neither fills fields nor clicks submit.
16
17
  import { randomUUID } from 'node:crypto';
17
18
  import { mkdir } from 'node:fs/promises';
18
19
  import { join } from 'node:path';
19
20
  import { detectFields } from './detect.js';
20
21
  import { checkMandate, checkMandatePreFill } from './mandate.js';
21
- import { EvidenceLog } from './evidence.js';
22
+ import { EvidenceLog, maskOtp } from './evidence.js';
22
23
  import { observeOutcome } from './outcome.js';
23
24
  import { selectAdapter } from './adapters/index.js';
25
+ import { summarizeFillFailure } from './adapters/generic.js';
26
+ import { traceHandleFields } from './trace-handles.js';
27
+ import { readGenericPageAmount } from './amount.js';
28
+ import { webBotAuthHeadersOrNone } from './web-bot-auth.js';
29
+ import { detectShopifyChallenge, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, shopifyEnglishCheckoutUrl, } from './adapters/shopify.js';
30
+ export { minorFromDecimal, pageCurrency } from './amount.js';
24
31
  const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
25
32
  const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
26
33
  // Post-submit confirmed/declined/challenge signals live in outcome.ts
@@ -35,92 +42,6 @@ async function settle(page) {
35
42
  await page.waitForTimeout(200);
36
43
  await page.waitForLoadState('networkidle', { timeout: 1500 }).catch(() => { });
37
44
  }
38
- // Convert a human decimal like "$50.00" to integer minor units without
39
- // floats. Fail-closed on separator ambiguity: only layouts with exactly one
40
- // reading are parsed; anything else returns null and the caller refuses. The
41
- // dangerous direction is UNDER-reading (an EU "1.234,56" read as 1.23 lets an
42
- // over-cap total pass the gate), so no layout is ever guessed.
43
- // Only 2-decimal currencies are supported (see pageCurrency's ISO allowlist).
44
- export function minorFromDecimal(text) {
45
- const m = text.match(/\d[\d.,]*/);
46
- if (!m)
47
- return null;
48
- const token = m[0].replace(/[.,]+$/, '');
49
- // "1234" — plain integer major units.
50
- if (/^\d+$/.test(token))
51
- return Number.parseInt(token, 10) * 100;
52
- // "1,234.56" — thousands groups of exactly 3 plus a 2-digit decimal.
53
- if (/^\d{1,3}(,\d{3})+\.\d{2}$/.test(token)) {
54
- const [whole, frac] = token.replace(/,/g, '').split('.');
55
- return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac, 10);
56
- }
57
- // "1.234,56" — the EU mirror: dot thousands, comma decimal.
58
- if (/^\d{1,3}(\.\d{3})+,\d{2}$/.test(token)) {
59
- const [whole, frac] = token.replace(/\./g, '').split(',');
60
- return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac, 10);
61
- }
62
- // "49.99" / "49.9" — dot decimal. A 3-digit dot group ("1.234") is EU
63
- // thousands, not a decimal, so only 1-2 fraction digits qualify.
64
- if (/^\d+\.\d{1,2}$/.test(token)) {
65
- const [whole, frac] = token.split('.');
66
- return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac.padEnd(2, '0'), 10);
67
- }
68
- // "49,99" / "49,9" — comma decimal. Unambiguous: a thousands group is
69
- // always exactly 3 digits, so a 1-2 digit comma tail can only be a decimal.
70
- if (/^\d+,\d{1,2}$/.test(token)) {
71
- const [whole, frac] = token.split(',');
72
- return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac.padEnd(2, '0'), 10);
73
- }
74
- // Everything else ("1,234" thousands-or-3-decimals, "1.2.3", ...) is
75
- // ambiguous: refuse rather than guess.
76
- return null;
77
- }
78
- // Currency stated by the page's total text, when unambiguous. "$" is shared
79
- // by USD/CAD/AUD/... and never qualifies. The ISO allowlist is 2-decimal
80
- // currencies only, matching minorFromDecimal's scaling.
81
- const ISO_CURRENCIES = ['USD', 'EUR', 'GBP', 'CAD', 'AUD', 'CHF', 'NZD'];
82
- export function pageCurrency(text) {
83
- const iso = text.match(/\b([A-Z]{3})\b/);
84
- if (iso && ISO_CURRENCIES.includes(iso[1]))
85
- return iso[1];
86
- if (text.includes('€'))
87
- return 'EUR';
88
- if (text.includes('£'))
89
- return 'GBP';
90
- return null;
91
- }
92
- // Read the order total from the page. Prefers an explicit data-total-minor
93
- // attribute (machine-readable), else parses a labelled total from the page.
94
- // Either way the currency comes from the element's text, or stays null. A
95
- // total that is present but ambiguous is 'unreadable' — the executor refuses
96
- // rather than falling back to the caller amount, because the page is showing
97
- // the user a number we cannot verify against the mandate.
98
- async function readPageAmount(page) {
99
- const explicitLoc = page.locator('[data-total-minor]').first();
100
- if ((await explicitLoc.count().catch(() => 0)) > 0) {
101
- const explicit = await explicitLoc.getAttribute('data-total-minor').catch(() => null);
102
- if (explicit && /^\d+$/.test(explicit)) {
103
- const text = (await explicitLoc.textContent().catch(() => null)) ?? '';
104
- return {
105
- kind: 'ok',
106
- amountMinor: Number.parseInt(explicit, 10),
107
- currency: pageCurrency(text),
108
- source: 'page-attr',
109
- };
110
- }
111
- }
112
- const totalLoc = page.locator('#order-total, .order-total, [data-testid="order-total"]').first();
113
- if ((await totalLoc.count().catch(() => 0)) > 0) {
114
- const totalText = await totalLoc.textContent().catch(() => null);
115
- if (totalText && /\d/.test(totalText)) {
116
- const amountMinor = minorFromDecimal(totalText);
117
- if (amountMinor == null)
118
- return { kind: 'unreadable' };
119
- return { kind: 'ok', amountMinor, currency: pageCurrency(totalText), source: 'page-text' };
120
- }
121
- }
122
- return { kind: 'none' };
123
- }
124
45
  async function tryReveal(page, evidence, clicked) {
125
46
  // 1) A payment-method radio for card/credit/debit (accordion layouts).
126
47
  const radios = page.locator('input[type="radio"]');
@@ -226,6 +147,19 @@ async function findSubmit(page) {
226
147
  }
227
148
  return null;
228
149
  }
150
+ // The diagnostic snapshot records the page ORIGIN only, never the full URL: a
151
+ // payment-session path/query (e.g. a live Stripe `cs_live_...` checkout-session
152
+ // id) must not be retained in the local receipt, which elsewhere promises
153
+ // "hostname only" (#7101). Falls back to the raw value only if it does not parse
154
+ // as a URL (never a real page.url()).
155
+ export function snapshotOrigin(rawUrl) {
156
+ try {
157
+ return new URL(rawUrl).origin;
158
+ }
159
+ catch {
160
+ return '';
161
+ }
162
+ }
229
163
  async function snapshotSummary(page) {
230
164
  const info = await page
231
165
  .evaluate(() => {
@@ -234,7 +168,7 @@ async function snapshotSummary(page) {
234
168
  return { title: document.title, heading: heading?.textContent?.trim() || '', body };
235
169
  })
236
170
  .catch(() => ({ title: '', heading: '', body: '' }));
237
- return `url=${page.url()} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
171
+ return `url=${snapshotOrigin(page.url())} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
238
172
  }
239
173
  async function readConfirmationRef(page) {
240
174
  const ref = await page
@@ -408,7 +342,7 @@ function unknownPreparedCheckoutResult(reviewId) {
408
342
  evidence.step('approval', { approved: false, reviewId, reason: detail });
409
343
  return makeResult('failed', {}, evidence, [], detail);
410
344
  }
411
- function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirmationRef) {
345
+ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirmationRef, failureCode) {
412
346
  const steps = evidence.getSteps();
413
347
  const approved = steps.find((step) => step.type === 'approval' && step.data.approved === true);
414
348
  const minted = steps.find((step) => step.type === 'credential-minted');
@@ -426,6 +360,12 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
426
360
  : filledRoles.has('number') || filledRoles.has('cvc')
427
361
  ? 'partially-exposed'
428
362
  : 'minted-not-exposed';
363
+ const terminalFailureCode = failureCode ??
364
+ (outcome === 'action-required'
365
+ ? 'human-action-required'
366
+ : outcome === 'blocked-by-mandate'
367
+ ? 'mandate-blocked'
368
+ : undefined);
429
369
  return {
430
370
  outcome,
431
371
  fields,
@@ -440,12 +380,24 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
440
380
  : {}),
441
381
  ...(completed ? { fillCompletedAt: completed.ts } : {}),
442
382
  },
383
+ ...(terminalFailureCode ? { failureCode: terminalFailureCode } : {}),
443
384
  ...(detail ? { detail } : {}),
444
385
  ...(confirmationRef ? { confirmationRef } : {}),
445
386
  };
446
387
  }
447
- async function readTransactionFacts(page, opts) {
448
- const pageAmount = await readPageAmount(page);
388
+ async function readTransactionFacts(page, opts, phase) {
389
+ const shopify = await isShopifyCheckoutPage(page);
390
+ const amountRead = shopify
391
+ ? phase === 'review'
392
+ ? await readStableShopifyAmount(page)
393
+ : await readShopifyAmount(page, true)
394
+ : await readGenericPageAmount(page);
395
+ const pageAmount = shopify && amountRead.kind === 'none'
396
+ ? {
397
+ kind: 'unreadable',
398
+ reason: 'Shopify final tax and total summary is not available',
399
+ }
400
+ : amountRead;
449
401
  const amountMinor = pageAmount.kind === 'ok'
450
402
  ? pageAmount.amountMinor
451
403
  : pageAmount.kind === 'none'
@@ -474,10 +426,10 @@ async function readTransactionFacts(page, opts) {
474
426
  currency,
475
427
  source,
476
428
  reason: pageAmount.kind === 'unreadable'
477
- ? 'page total is displayed but cannot be parsed unambiguously'
429
+ ? (pageAmount.reason ?? 'page total is displayed but cannot be parsed unambiguously')
478
430
  : 'transaction amount could not be determined',
479
431
  detail: pageAmount.kind === 'unreadable'
480
- ? 'transaction amount could not be determined (page total present but ambiguous, e.g. separator layout); refusing fail-closed'
432
+ ? `transaction amount could not be determined (${pageAmount.reason ?? 'page total present but ambiguous'}); refusing fail-closed`
481
433
  : 'transaction amount could not be determined (no readable page total, no amountMinor provided); refusing fail-closed',
482
434
  };
483
435
  }
@@ -630,11 +582,17 @@ async function captureDebugShot(page, dir, reviewId, label, evidence, fields) {
630
582
  export function isStripeLinkConsumerRequest(url) {
631
583
  return /(?:^|\/\/)([a-z0-9.-]*\.)?stripe\.com\/v1\/consumers\/sessions\/(?:lookup|start_verification)\b/i.test(url);
632
584
  }
633
- async function suppressStripeLink(page, evidence) {
634
- let suppressed = 0;
585
+ // Keyed by Page so the tracker installed at prepare time is reachable from the
586
+ // approved-submit leg without threading through the session store types.
587
+ const linkSuppressionByPage = new WeakMap();
588
+ // Exported for the link-quiet unit tests (a fake Page captures the route
589
+ // handler); production callers stay inside this module.
590
+ export async function suppressStripeLink(page, evidence) {
591
+ const state = { suppressed: 0, waiters: [] };
592
+ linkSuppressionByPage.set(page, state);
635
593
  await page.route((u) => isStripeLinkConsumerRequest(typeof u === 'string' ? u : u.href), (route) => {
636
- suppressed += 1;
637
- if (suppressed === 1) {
594
+ state.suppressed += 1;
595
+ if (state.suppressed === 1) {
638
596
  // origin + pathname only — never the full URL. The lookup carries the
639
597
  // email in the POST body today, but keep an operator email out of the
640
598
  // evidence log even if Stripe moves a param to the query string (#5708).
@@ -649,9 +607,45 @@ async function suppressStripeLink(page, evidence) {
649
607
  }
650
608
  evidence.step('note', { linkSuppressed: safe });
651
609
  }
610
+ for (const wake of state.waiters.splice(0))
611
+ wake();
652
612
  return route.abort();
653
613
  });
654
614
  }
615
+ /**
616
+ * Wait for the suppressed Stripe Link lookup to fire and settle BEFORE the
617
+ * submit click. Stripe debounces its consumer-session lookup ~300ms after the
618
+ * email input changes; our fill→click gap is single-digit ms, so the (aborted)
619
+ * lookup used to land INSIDE Stripe's in-flight submit chain and kill it
620
+ * silently — the click looked accepted but tokenization never ran and the page
621
+ * sat on the form until the outcome deadline (#5879: three identical live
622
+ * stalls at donate.stripe.com). Verified live A/B on that page: instant click →
623
+ * dead submit, no /v1/payment_methods; lookup settled first → tokenization and
624
+ * the confirm step both reached.
625
+ *
626
+ * If the lookup already fired, only the short settle applies (lets Stripe's
627
+ * abort handling unwind). If it never fires — non-Link page variants, no email
628
+ * field — the bound expires and the click proceeds as before.
629
+ */
630
+ export async function waitForLinkLookupQuiet(page, opts = {}) {
631
+ const boundMs = opts.boundMs ?? 1500;
632
+ const settleMs = opts.settleMs ?? 250;
633
+ const delay = opts.delay ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
634
+ const state = linkSuppressionByPage.get(page);
635
+ if (!state)
636
+ return { fired: false, waitedMs: 0 };
637
+ const started = Date.now();
638
+ if (state.suppressed === 0) {
639
+ await Promise.race([
640
+ new Promise((resolve) => state.waiters.push(resolve)),
641
+ delay(boundMs),
642
+ ]);
643
+ }
644
+ const fired = state.suppressed > 0;
645
+ if (fired)
646
+ await delay(settleMs);
647
+ return { fired, waitedMs: Date.now() - started };
648
+ }
655
649
  // Payer-chosen amount inputs. Deliberately payment-link-specific (Stripe's
656
650
  // customUnitAmount): on an ordinary checkout the total is merchant-controlled
657
651
  // and typing into anything price-like must never happen.
@@ -689,7 +683,21 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
689
683
  mandate: { ...opts.mandate },
690
684
  };
691
685
  const evidence = new EvidenceLog();
692
- const context = await options.browser.newContext();
686
+ // Pin an English locale: amount reconciliation reads the order summary by
687
+ // its visible labels (Subtotal/Taxes/Total), and merchants localize by
688
+ // Accept-Language (observed live 2026-08-16: a Shopify checkout redirected
689
+ // to /es-us and rendered "Impuestos estimados", so the total never parsed
690
+ // and the review refused fail-closed on a perfectly good checkout).
691
+ // Present Web Bot Auth (RFC 9421) credentials when, and only when, an
692
+ // operator directory is configured to resolve them. Off by default: an
693
+ // unresolvable signature fails verification and is worse than none. The
694
+ // signature covers @authority, so it is bound to the checkout host — a
695
+ // cross-origin redirect simply arrives unverified, never wrongly verified.
696
+ const webBotAuthHeaders = webBotAuthHeadersOrNone(options.webBotAuth ?? null, options.url, Date.now() / 1000);
697
+ const context = await options.browser.newContext({
698
+ locale: 'en-US',
699
+ ...(webBotAuthHeaders ? { extraHTTPHeaders: webBotAuthHeaders } : {}),
700
+ });
693
701
  // Bound every action so a mis-detected or hidden element fails fast instead
694
702
  // of stalling on Playwright's long default timeout.
695
703
  context.setDefaultTimeout(6000);
@@ -745,7 +753,75 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
745
753
  }
746
754
  // Detection is read-only here. In particular, no adapter fill and no
747
755
  // Instrument.getCredential() call can occur before an explicit approval.
748
- const detected = await detectFields(page);
756
+ let detected = await detectFields(page);
757
+ const shopifyPage = await isShopifyCheckoutPage(page);
758
+ if (shopifyPage) {
759
+ // Same checkout session, English presentation — the amount reader needs
760
+ // the English summary labels (see shopifyEnglishCheckoutUrl).
761
+ const englishUrl = shopifyEnglishCheckoutUrl(page.url());
762
+ if (englishUrl) {
763
+ await page.goto(englishUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
764
+ await waitForStableDom(page);
765
+ if (await isShopifyCheckoutPage(page)) {
766
+ evidence.step('navigation', { url: page.url(), reason: 'shopify-locale-normalized' });
767
+ detected = await detectFields(page);
768
+ }
769
+ }
770
+ }
771
+ let adapter = selectAdapter(detected, { shopify: shopifyPage });
772
+ if (options.contact && adapter.prepareContact) {
773
+ const preparedContact = await adapter.prepareContact(page, options.contact);
774
+ for (const field of preparedContact.filled) {
775
+ evidence.step('contact-prefill', {
776
+ role: field.role,
777
+ confidence: field.confidence,
778
+ source: field.source,
779
+ frame: field.frame,
780
+ value: field.value,
781
+ ok: field.ok,
782
+ error: field.error,
783
+ });
784
+ }
785
+ const challenge = await detectShopifyChallenge(page);
786
+ if (challenge) {
787
+ evidence.step('outcome', {
788
+ outcome: 'action-required',
789
+ signal: challenge.signal,
790
+ phase: 'contact-prefill',
791
+ });
792
+ evidence.setSnapshotSummary(await snapshotSummary(page));
793
+ return {
794
+ status: 'finished',
795
+ result: makeResult('action-required', fields, evidence, requiresAdapter, `Shop Pay verification requires a human before review (${challenge.signal}); no payment credential was requested`),
796
+ };
797
+ }
798
+ if (!preparedContact.ok) {
799
+ evidence.setSnapshotSummary(await snapshotSummary(page));
800
+ return {
801
+ status: 'finished',
802
+ result: makeResult('failed', fields, evidence, requiresAdapter, preparedContact.detail ??
803
+ 'Shopify contact prefill did not complete; no payment credential was requested'),
804
+ };
805
+ }
806
+ await settle(page);
807
+ const prefillHost = new URL(page.url()).hostname;
808
+ if (prefillHost !== merchantHost) {
809
+ const reason = `merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
810
+ evidence.step('mandate-verdict', {
811
+ phase: 'contact-prefill',
812
+ ok: false,
813
+ reason,
814
+ });
815
+ evidence.setSnapshotSummary(await snapshotSummary(page));
816
+ return {
817
+ status: 'finished',
818
+ result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reason),
819
+ };
820
+ }
821
+ detected = await detectFields(page);
822
+ adapter = selectAdapter(detected, { shopify: shopifyPage });
823
+ evidence.step('adapter-selected', { adapter: adapter.name, phase: 'review' });
824
+ }
749
825
  fields = detected.fields;
750
826
  evidence.step('detect', {
751
827
  phase: 'review',
@@ -759,7 +835,27 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
759
835
  evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
760
836
  }
761
837
  }
762
- const facts = await readTransactionFacts(page, options);
838
+ // A checkout the runner cannot put a card INTO is not reviewable. Without a
839
+ // detected card-number field a later pay would fill nothing and dispatch
840
+ // whatever control the review happened to bind (observed live 2026-08-16:
841
+ // a Payhip storefront SEARCH form, a FastSpring "PayPal Checkout" label,
842
+ // and a Shopify discount-form "Submit" all reviewed clean this way — the
843
+ // real card fields sat in unreachable PSP iframes or an unrendered payment
844
+ // section). Every adapter fills from this same detection, so a missing
845
+ // number field here means no pay can ever succeed: refuse while it is
846
+ // still free.
847
+ if (!fields.number) {
848
+ const found = Object.keys(fields);
849
+ const reason = `no card number field detected (roles found: ${found.length ? found.join(', ') : 'none'}) — ` +
850
+ 'the card form is likely inside a PSP iframe or behind a later step, so a credential cannot be entered on this page';
851
+ evidence.step('detect', { phase: 'review', missingCardNumber: true, reason });
852
+ evidence.setSnapshotSummary(await snapshotSummary(page));
853
+ return {
854
+ status: 'finished',
855
+ result: makeResult('failed', fields, evidence, requiresAdapter, reason, undefined, 'card-number-field-unavailable'),
856
+ };
857
+ }
858
+ const facts = await readTransactionFacts(page, options, 'review');
763
859
  recordTransactionFacts(evidence, 'review', facts);
764
860
  if (!facts.ok) {
765
861
  evidence.step('mandate-verdict', { phase: 'review', ok: false, reason: facts.reason });
@@ -870,7 +966,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
870
966
  evidence.setSnapshotSummary(await snapshotSummary(page));
871
967
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason);
872
968
  }
873
- const approvedFacts = await readTransactionFacts(page, options);
969
+ const approvedFacts = await readTransactionFacts(page, options, 'approval');
874
970
  recordTransactionFacts(evidence, 'approval', approvedFacts);
875
971
  if (!approvedFacts.ok) {
876
972
  evidence.step('mandate-verdict', {
@@ -925,6 +1021,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
925
1021
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, reason);
926
1022
  }
927
1023
  evidence.step('approval', { approved: true, reviewId: checkout.review.id });
1024
+ if (opts.mode === 'dry-run') {
1025
+ evidence.step('credential-skipped', {
1026
+ reason: 'dry-run stops before credential mint or merchant-page disclosure',
1027
+ });
1028
+ evidence.step('submit', { would: true, target: approvalSubmit?.desc ?? 'none found' });
1029
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1030
+ return makeResult('reviewed-dry-run', state.fields, evidence, requiresAdapter, approvalSubmit
1031
+ ? `validated the reviewed checkout; would click ${approvalSubmit.desc}`
1032
+ : 'validated the reviewed checkout; no submit control detected');
1033
+ }
928
1034
  const credential = await opts.instrument.getCredential({
929
1035
  merchantHost,
930
1036
  amountMinor: approvedFacts.amountMinor,
@@ -934,11 +1040,14 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
934
1040
  ...(credential.credentialExpiresAt
935
1041
  ? { credentialExpiresAt: credential.credentialExpiresAt }
936
1042
  : {}),
1043
+ ...traceHandleFields(credential),
937
1044
  });
938
1045
  // Reveal + fill loop: fills whatever is present, then reveals the next
939
1046
  // surface (card radio / next step) until the card number is filled.
940
1047
  const clicked = new Set();
941
1048
  let adapterName = null;
1049
+ let adapterFillOk = true;
1050
+ let adapterFillDetail = null;
942
1051
  for (let attempt = 0; attempt < 4; attempt++) {
943
1052
  // A reveal/continue action can navigate between attempts. Never expose
944
1053
  // the credential to a host other than the one the human reviewed.
@@ -967,12 +1076,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
967
1076
  evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
968
1077
  }
969
1078
  }
970
- const adapter = selectAdapter(detected);
1079
+ const adapter = selectAdapter(detected, {
1080
+ shopify: await isShopifyCheckoutPage(page),
1081
+ });
971
1082
  if (adapter.name !== adapterName) {
972
1083
  adapterName = adapter.name;
973
1084
  evidence.step('adapter-selected', { adapter: adapter.name });
974
1085
  }
975
1086
  const fill = await adapter.fill(page, detected.fields, credential, opts.contact);
1087
+ adapterFillOk = fill.ok;
1088
+ adapterFillDetail = fill.detail ?? null;
976
1089
  for (const f of fill.filled) {
977
1090
  evidence.step('field-fill', {
978
1091
  role: f.role,
@@ -1004,9 +1117,30 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1004
1117
  ? []
1005
1118
  : ['expiry']),
1006
1119
  ];
1120
+ // Roles the adapter TRIED to fill and never landed, across every reveal
1121
+ // attempt. A job only exists when the field was detected, was visible, and
1122
+ // we held a value for it (see `add()` in adapters/generic.ts) — so a failure
1123
+ // here is never "the page didn't ask for it". It means the page asked, we
1124
+ // answered, and the element refused.
1125
+ //
1126
+ // Roles that failed on an early attempt and succeeded after a reveal are
1127
+ // excluded: `successfulRoles` spans all four attempts, same as above.
1128
+ const failedFillRoles = [
1129
+ ...new Set(evidence
1130
+ .getSteps()
1131
+ .filter((step) => step.type === 'field-fill' && step.data.ok === false)
1132
+ .map((step) => String(step.data.role))),
1133
+ ]
1134
+ .filter((role) => !successfulRoles.has(role))
1135
+ .sort();
1007
1136
  evidence.step('fill-complete', {
1008
- ok: missingCredentialRoles.length === 0,
1137
+ // "Ready to submit", not "the card fields landed". Before 2026-08-17 this
1138
+ // read only the credential roles, so a whop.com run whose city/state/
1139
+ // postalCode all timed out recorded `ok: true` and clicked Get access on
1140
+ // a form it knew was incomplete.
1141
+ ok: missingCredentialRoles.length === 0 && failedFillRoles.length === 0,
1009
1142
  missingCredentialRoles,
1143
+ failedFillRoles,
1010
1144
  });
1011
1145
  if (options.debugShotsDir) {
1012
1146
  await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '2-filled', evidence, state.fields);
@@ -1014,7 +1148,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1014
1148
  // Re-run the full gate after fill as well. Contact/shipping fields can
1015
1149
  // change the total; any drift from the approved review refuses before a
1016
1150
  // submit click and requires the caller to prepare a new review.
1017
- const submitFacts = await readTransactionFacts(page, options);
1151
+ const submitFacts = await readTransactionFacts(page, options, 'pre-submit');
1018
1152
  recordTransactionFacts(evidence, 'pre-submit', submitFacts);
1019
1153
  if (!submitFacts.ok) {
1020
1154
  evidence.step('mandate-verdict', {
@@ -1057,25 +1191,89 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1057
1191
  evidence.setSnapshotSummary(await snapshotSummary(page));
1058
1192
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
1059
1193
  }
1060
- if (opts.mode === 'dry-run') {
1061
- evidence.step('submit', { would: true, target: submit?.desc ?? 'none found' });
1194
+ if (!adapterFillOk) {
1062
1195
  evidence.setSnapshotSummary(await snapshotSummary(page));
1063
- if (missingCredentialRoles.length > 0) {
1064
- const adapterRequired = requiresAdapter.size > 0;
1065
- return makeResult(adapterRequired ? 'adapter-required' : 'partial-fill', state.fields, evidence, requiresAdapter, adapterRequired
1066
- ? `credential fields require adapter: ${[...requiresAdapter].join(', ')}; missing ${missingCredentialRoles.join(', ')}`
1067
- : `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
1068
- }
1069
- return makeResult('filled-dry-run', state.fields, evidence, requiresAdapter, submit ? `would click ${submit.desc}` : 'no submit control detected');
1196
+ return makeResult('failed', state.fields, evidence, requiresAdapter, adapterFillDetail ?? `${adapterName ?? 'checkout'} adapter fill incomplete`);
1070
1197
  }
1071
1198
  if (missingCredentialRoles.length > 0) {
1072
1199
  evidence.setSnapshotSummary(await snapshotSummary(page));
1073
1200
  return makeResult('failed', state.fields, evidence, requiresAdapter, `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
1074
1201
  }
1202
+ // STOP BEFORE THE CLICK when any field we tried to fill refused. Submitting
1203
+ // a form we know is incomplete is how a PSP ends up holding a charge we
1204
+ // cannot then confirm or account for: the 2026-08-17 whop.com run filled the
1205
+ // card into Basis Theory iframes, watched city/state/postalCode time out at
1206
+ // 5s each, clicked Get access anyway, and could never observe an outcome.
1207
+ //
1208
+ // This refusal happens BEFORE the submit click, so nothing can be charged by
1209
+ // it — the safe direction, and the reason it is allowed to be strict. A
1210
+ // merchant whose address widget we cannot drive now fails cleanly and
1211
+ // retryably instead of dangerously.
1212
+ if (failedFillRoles.length > 0) {
1213
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1214
+ // Name the CAUSE per field, not just the field. The refusal is the only
1215
+ // artifact that survives to the operator (a v2 receipt carries no evidence
1216
+ // log), and "city, postalCode refused" without a reason means the next
1217
+ // person has to reproduce a live merchant to learn anything. Each cause
1218
+ // points at a different fix — see summarizeFillFailure.
1219
+ //
1220
+ // Last attempt wins: a role that failed differently across reveal passes
1221
+ // is best described by how it failed when we finally gave up on it.
1222
+ const lastFillError = (role) => {
1223
+ const errors = evidence
1224
+ .getSteps()
1225
+ .filter((step) => step.type === 'field-fill' && step.data.ok === false && step.data.role === role)
1226
+ .map((step) => (typeof step.data.error === 'string' ? step.data.error : undefined));
1227
+ return errors[errors.length - 1];
1228
+ };
1229
+ const reasons = failedFillRoles.map((role) => `${role} (${summarizeFillFailure(lastFillError(role))})`);
1230
+ return makeResult('partial-fill', state.fields, evidence, requiresAdapter, `required field fill failed: ${reasons.join(', ')} — not submitting an incomplete form. Nothing was charged.`, undefined, 'required-field-unfillable');
1231
+ }
1075
1232
  if (!submit) {
1076
1233
  evidence.setSnapshotSummary(await snapshotSummary(page));
1077
1234
  return makeResult('failed', state.fields, evidence, requiresAdapter, 'no submit control detected');
1078
1235
  }
1236
+ // Capture the OTP poll watermark BEFORE the click. The click is what
1237
+ // triggers the merchant's verification email, so the watermark must precede
1238
+ // it — otherwise a fast OTP could arrive before we start looking (the 5s
1239
+ // waitForMessage skew is a second line of defence, but ordering matters).
1240
+ // This is just a timestamp — no PII — so it is safe to record.
1241
+ const otpWatermark = new Date().toISOString();
1242
+ evidence.step('note', { otpWatermarkCaptured: true });
1243
+ // The suppressed Link lookup must settle before the click or it breaks
1244
+ // Stripe's submit chain mid-flight (#5879) — see waitForLinkLookupQuiet.
1245
+ const linkQuiet = await waitForLinkLookupQuiet(page);
1246
+ evidence.step('note', { linkQuiet });
1247
+ // This is deliberately the last await before the irreversible click.
1248
+ // Mint-time validation is not enough: reveal/fill and Link suppression can
1249
+ // consume a short-lived DAVV. Refuse malformed or <60s credentials so an
1250
+ // expiry decline cannot masquerade as a form-fill failure.
1251
+ const credentialExpiresAt = credential.credentialExpiresAt;
1252
+ const expiryMissing = opts.instrument.kind === 'agentic-token' && credentialExpiresAt === undefined;
1253
+ const expiresMs = credentialExpiresAt === undefined ? Number.NaN : Date.parse(credentialExpiresAt);
1254
+ if (expiryMissing ||
1255
+ (credentialExpiresAt !== undefined &&
1256
+ (!Number.isFinite(expiresMs) || expiresMs - Date.now() < 60_000))) {
1257
+ evidence.step('credential-expiry-check', {
1258
+ ok: false,
1259
+ reason: expiryMissing ? 'missing' : 'invalid-or-expiring',
1260
+ ...(credentialExpiresAt !== undefined ? { credentialExpiresAt } : {}),
1261
+ });
1262
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1263
+ return makeResult('failed', state.fields, evidence, requiresAdapter, 'credential expired before submit; obtain a fresh intent and re-review');
1264
+ }
1265
+ if (credentialExpiresAt !== undefined) {
1266
+ evidence.step('credential-expiry-check', {
1267
+ ok: true,
1268
+ credentialExpiresAt,
1269
+ });
1270
+ }
1271
+ // ORDER IS LOAD-BEARING: record the click BEFORE performing it. The catch
1272
+ // block classifies a throw by whether this step exists — recorded means
1273
+ // "we may have charged" (`unverified`), absent means "retry is safe"
1274
+ // (`failed`). Recording after `submit.click()` would let a throw raised by
1275
+ // the click itself look retry-safe, which is the double-charge direction.
1276
+ // Pinned by "a throw AFTER the pay control was clicked reports unverified".
1079
1277
  evidence.step('submit', { clicked: true, target: submit.desc });
1080
1278
  await submit.click();
1081
1279
  await settle(page);
@@ -1096,6 +1294,57 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1096
1294
  });
1097
1295
  observed = reconcileHeldOutcome(observed, held);
1098
1296
  }
1297
+ // Agent-resolvable email OTP subroutine (SINGLE-USE). Fires only on a
1298
+ // 'verification-required' verdict (the merchant emailed a code to the
1299
+ // agent's own inbox) AND when a resolver is injected. Fills the code EXACTLY
1300
+ // ONCE, re-submits, and re-observes. Merchants invalidate a code on first
1301
+ // use, so a stale code is NEVER retried. On no resolver / timeout / missing
1302
+ // code field it falls through to the action-required (human) path below —
1303
+ // it never hangs and never re-fills credential material.
1304
+ if (observed.status === 'verification-required' && opts.resolveEmailOtp) {
1305
+ const otpDetect = await detectFields(page);
1306
+ const codeField = otpDetect.fields.oneTimeCode;
1307
+ if (!codeField) {
1308
+ evidence.step('note', { emailOtp: 'no one-time-code field detected' });
1309
+ }
1310
+ else {
1311
+ const resolution = await opts.resolveEmailOtp({
1312
+ after: otpWatermark,
1313
+ merchantHost: submitMerchantHost,
1314
+ });
1315
+ if (!resolution) {
1316
+ // Fail CLEAN: no code retrieved before the resolver's timeout.
1317
+ evidence.step('note', { emailOtp: 'not retrieved before timeout' });
1318
+ }
1319
+ else {
1320
+ // Fill once. maskOtp() ensures the code NEVER enters the evidence log
1321
+ // (receipt.ts's PAN backstop does not catch a 4-8 digit OTP). The
1322
+ // sender domain is a non-PII trust signal, safe to record.
1323
+ await page.locator(codeField.locator).fill(resolution.code);
1324
+ evidence.step('field-fill', {
1325
+ role: 'oneTimeCode',
1326
+ confidence: codeField.confidence,
1327
+ source: codeField.source,
1328
+ frame: codeField.frame,
1329
+ value: maskOtp(),
1330
+ ok: true,
1331
+ fromDomain: resolution.fromDomain,
1332
+ });
1333
+ const otpSubmit = await findSubmit(page);
1334
+ if (!otpSubmit) {
1335
+ evidence.step('note', { emailOtp: 'code filled but no submit control found' });
1336
+ }
1337
+ else {
1338
+ const otpLinkQuiet = await waitForLinkLookupQuiet(page);
1339
+ evidence.step('note', { linkQuiet: otpLinkQuiet, phase: 'post-otp' });
1340
+ evidence.step('submit', { clicked: true, target: otpSubmit.desc, phase: 'post-otp' });
1341
+ await otpSubmit.click();
1342
+ await settle(page);
1343
+ observed = await observeOutcome(page, { deadlineMs: opts.outcomeDeadlineMs });
1344
+ }
1345
+ }
1346
+ }
1347
+ }
1099
1348
  evidence.setSnapshotSummary(await snapshotSummary(page));
1100
1349
  if (options.debugShotsDir) {
1101
1350
  await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '3-outcome', evidence, state.fields);
@@ -1121,6 +1370,20 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1121
1370
  });
1122
1371
  return makeResult('action-required', state.fields, evidence, requiresAdapter, `issuer verification required (${observed.signal}) — a human must complete the challenge; no charge exists until it is completed`);
1123
1372
  }
1373
+ if (observed.status === 'verification-required') {
1374
+ // Still needing an email code after the subroutine (no resolver injected,
1375
+ // the code never arrived, or no code field) — hand off to a human. Mapped
1376
+ // to the same action-required outcome; the code is single-use so we never
1377
+ // retry here.
1378
+ evidence.step('outcome', {
1379
+ outcome: 'action-required',
1380
+ signal: observed.signal,
1381
+ reason: 'email verification code required but not auto-resolved',
1382
+ attempts: observed.attempts,
1383
+ elapsedMs: observed.elapsedMs,
1384
+ });
1385
+ return makeResult('action-required', state.fields, evidence, requiresAdapter, `email verification required (${observed.signal}) — a human must enter the code sent to the inbox`);
1386
+ }
1124
1387
  if (observed.status === 'confirmed') {
1125
1388
  const confirmationRef = await readConfirmationRef(page);
1126
1389
  evidence.step('outcome', {
@@ -1132,19 +1395,35 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1132
1395
  });
1133
1396
  return makeResult('confirmed', state.fields, evidence, requiresAdapter, undefined, confirmationRef);
1134
1397
  }
1398
+ // The pay control was clicked and the observer reached its deadline with no
1399
+ // definitive answer. This is NOT a failure — it is the absence of an answer,
1400
+ // and the charge may well have captured. Reporting it as `failed` is what
1401
+ // let a caller re-run the 2026-08-17 whop.com purchase and draw a second $5.
1135
1402
  evidence.step('outcome', {
1136
- outcome: 'failed',
1403
+ outcome: 'unverified',
1137
1404
  reason: 'no confirmation or decline signal',
1138
1405
  lastSeen: observed.lastSeen,
1139
1406
  attempts: observed.attempts,
1140
1407
  elapsedMs: observed.elapsedMs,
1141
1408
  });
1142
- return makeResult('failed', state.fields, evidence, requiresAdapter, observed.lastSeen === 'processing'
1143
- ? 'submitted but outcome unknown (page still processing at deadline)'
1144
- : 'submitted but outcome unknown');
1409
+ return makeResult('unverified', state.fields, evidence, requiresAdapter, observed.lastSeen === 'processing'
1410
+ ? 'submitted but outcome unknown (page still processing at deadline) — the charge may have gone through; verify with the merchant before any retry'
1411
+ : 'submitted but outcome unknown — the charge may have gone through; verify with the merchant before any retry');
1145
1412
  }
1146
1413
  catch (err) {
1147
1414
  const detail = err.message;
1415
+ // A throw AFTER the pay control was clicked (browser teardown, navigation
1416
+ // race, evidence I/O) leaves the same open question as the deadline path: we
1417
+ // clicked, and we do not know what happened. It must not report `failed`
1418
+ // either. A throw before the click never disclosed a payable form, so it
1419
+ // stays a clean, retry-safe failure.
1420
+ const submitted = evidence
1421
+ .getSteps()
1422
+ .some((step) => step.type === 'submit' && step.data.clicked === true);
1423
+ if (submitted) {
1424
+ evidence.step('outcome', { outcome: 'unverified', error: detail });
1425
+ return makeResult('unverified', state.fields, evidence, requiresAdapter, `${detail} — the pay control was already clicked; the charge may have gone through, so verify with the merchant before any retry`);
1426
+ }
1148
1427
  evidence.step('outcome', { outcome: 'failed', error: detail });
1149
1428
  return makeResult('failed', state.fields, evidence, requiresAdapter, detail);
1150
1429
  }
@@ -1173,8 +1452,8 @@ export async function cancelPreparedCheckout(reviewId, detail = 'checkout cancel
1173
1452
  // approves that exact review ID. Human-in-the-loop callers should use the
1174
1453
  // explicit prepareCheckout()/submitApprovedCheckout() pair instead.
1175
1454
  export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
1176
- const { instrument, contact, mode, outcomeDeadlineMs, ...prepareOptions } = opts;
1177
- const preparation = await prepareCheckout(prepareOptions, store);
1455
+ const { instrument, contact, mode, outcomeDeadlineMs, resolveEmailOtp, ...prepareOptions } = opts;
1456
+ const preparation = await prepareCheckout({ ...prepareOptions, contact }, store);
1178
1457
  if (preparation.status === 'finished')
1179
1458
  return preparation.result;
1180
1459
  return submitApprovedCheckout(preparation.checkout.review.id, {
@@ -1183,5 +1462,6 @@ export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
1183
1462
  contact,
1184
1463
  mode,
1185
1464
  outcomeDeadlineMs,
1465
+ ...(resolveEmailOtp ? { resolveEmailOtp } : {}),
1186
1466
  }, store);
1187
1467
  }