@visa/cli 4.1.0-rc.21 → 4.1.0-rc.211

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 +200 -226
  2. package/dist/checkout-engine/adapters/generic.d.ts +69 -0
  3. package/dist/checkout-engine/adapters/generic.js +383 -58
  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 +264 -4
  11. package/dist/checkout-engine/cli-engine.js +803 -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 +47 -4
  19. package/dist/checkout-engine/executor.js +418 -131
  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 +226 -0
  32. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +174 -0
  33. package/dist/checkout-engine/mandate/mandate-ledger.js +410 -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 +28 -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 +772 -496
  56. package/dist/mcp-apps/ucp-checkout.html +280 -0
  57. package/dist/mcp-server/index.js +637 -175
  58. package/dist/skills/pair-visa-agent/RUNTIMES.md +93 -0
  59. package/dist/skills/pair-visa-agent/SKILL.md +479 -221
  60. package/dist/subway-direct.mjs +1 -0
  61. package/install.ps1 +5 -43
  62. package/install.sh +5 -37
  63. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  64. package/package.json +33 -27
  65. package/server.json +4 -4
  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"]');
@@ -203,17 +124,63 @@ async function fingerprintSubmitTarget(locator, kind, fallbackLabel) {
203
124
  };
204
125
  }, { targetKind: kind, targetFallbackLabel: fallbackLabel });
205
126
  }
206
- async function findSubmit(page) {
207
- const submitBtn = page.locator('button[type="submit"], input[type="submit"]').first();
208
- if ((await submitBtn.count().catch(() => 0)) > 0) {
209
- const label = ((await submitBtn.textContent().catch(() => '')) || '').trim() ||
210
- (await submitBtn.getAttribute('value').catch(() => '')) ||
211
- 'submit';
212
- return {
213
- desc: `submit button ("${label}")`,
214
- fingerprint: await fingerprintSubmitTarget(submitBtn, 'submit-control', 'submit'),
215
- click: () => submitBtn.click(),
216
- };
127
+ // Shopify checkouts keep INERT duplicates of the pay control in the DOM —
128
+ // aria-hidden="true", tabindex="-1", and/or zero-size. Playwright still reports
129
+ // those as "visible, enabled and stable", so a bare `.first()` resolves to one
130
+ // and then every click is swallowed by whatever paints on top of it
131
+ // (observed live: `<h3 id="billingAddress"> intercepts pointer events`, retried
132
+ // until the 6s timeout, deterministically, on casper.com). Rank the real
133
+ // controls ahead of the inert ones and prefer a pay-labelled control.
134
+ export function preferredSubmitIndex(cands, opts = {}) {
135
+ const indexed = cands.map((c, i) => ({ c, i }));
136
+ const usable = indexed.filter(({ c }) => c.visible && !c.ariaHidden && c.tabIndex !== -1 && c.area > 0);
137
+ const usablePayLike = usable.find(({ c }) => PAY_LABEL.test(c.label));
138
+ if (usablePayLike)
139
+ return usablePayLike.i;
140
+ if (!opts.allowDeferred)
141
+ return usable[0]?.i ?? -1;
142
+ // A genuine multi-step checkout can keep its final submit control inside a
143
+ // hidden payment section until a safe, non-submit Continue button advances
144
+ // the page. The human still needs that exact control bound into the review
145
+ // before approval. Accept it for fingerprinting only when it is not marked
146
+ // inert; the pre-click lookup remains strict and will refuse unless the same
147
+ // control becomes visible and has non-zero area after reveal.
148
+ const deferred = indexed.filter(({ c }) => !c.ariaHidden && c.tabIndex !== -1);
149
+ if (deferred.length === 0)
150
+ return -1;
151
+ const deferredPayLike = deferred.find(({ c }) => PAY_LABEL.test(c.label));
152
+ return (deferredPayLike ?? usable[0] ?? deferred[0]).i;
153
+ }
154
+ const PAY_LABEL = /pay|place order|complete order|submit order|buy now/i;
155
+ async function findSubmit(page, opts = {}) {
156
+ const controls = page.locator('button[type="submit"], input[type="submit"]');
157
+ const handles = await controls.all().catch(() => []);
158
+ if (handles.length > 0) {
159
+ const metas = await Promise.all(handles.map(async (h) => {
160
+ const text = ((await h.textContent().catch(() => '')) || '').trim();
161
+ const value = text || (await h.getAttribute('value').catch(() => '')) || '';
162
+ const ariaHidden = await h.getAttribute('aria-hidden').catch(() => null);
163
+ const tabIndexRaw = await h.getAttribute('tabindex').catch(() => null);
164
+ const visible = await h.isVisible().catch(() => false);
165
+ const box = await h.boundingBox().catch(() => null);
166
+ return {
167
+ label: value,
168
+ ariaHidden: ariaHidden === 'true',
169
+ tabIndex: tabIndexRaw === null ? null : Number(tabIndexRaw),
170
+ visible,
171
+ area: box ? box.width * box.height : 0,
172
+ };
173
+ }));
174
+ const idx = preferredSubmitIndex(metas, opts);
175
+ if (idx >= 0) {
176
+ const chosen = handles[idx];
177
+ const label = metas[idx].label || 'submit';
178
+ return {
179
+ desc: `submit button ("${label}")`,
180
+ fingerprint: await fingerprintSubmitTarget(chosen, 'submit-control', 'submit'),
181
+ click: () => chosen.click(),
182
+ };
183
+ }
217
184
  }
218
185
  const byText = page.getByRole('button', { name: SUBMIT_TEXT }).first();
219
186
  if ((await byText.count().catch(() => 0)) > 0) {
@@ -226,6 +193,19 @@ async function findSubmit(page) {
226
193
  }
227
194
  return null;
228
195
  }
196
+ // The diagnostic snapshot records the page ORIGIN only, never the full URL: a
197
+ // payment-session path/query (e.g. a live Stripe `cs_live_...` checkout-session
198
+ // id) must not be retained in the local receipt, which elsewhere promises
199
+ // "hostname only" (#7101). Falls back to the raw value only if it does not parse
200
+ // as a URL (never a real page.url()).
201
+ export function snapshotOrigin(rawUrl) {
202
+ try {
203
+ return new URL(rawUrl).origin;
204
+ }
205
+ catch {
206
+ return '';
207
+ }
208
+ }
229
209
  async function snapshotSummary(page) {
230
210
  const info = await page
231
211
  .evaluate(() => {
@@ -234,7 +214,7 @@ async function snapshotSummary(page) {
234
214
  return { title: document.title, heading: heading?.textContent?.trim() || '', body };
235
215
  })
236
216
  .catch(() => ({ title: '', heading: '', body: '' }));
237
- return `url=${page.url()} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
217
+ return `url=${snapshotOrigin(page.url())} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
238
218
  }
239
219
  async function readConfirmationRef(page) {
240
220
  const ref = await page
@@ -408,7 +388,7 @@ function unknownPreparedCheckoutResult(reviewId) {
408
388
  evidence.step('approval', { approved: false, reviewId, reason: detail });
409
389
  return makeResult('failed', {}, evidence, [], detail);
410
390
  }
411
- function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirmationRef) {
391
+ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirmationRef, failureCode) {
412
392
  const steps = evidence.getSteps();
413
393
  const approved = steps.find((step) => step.type === 'approval' && step.data.approved === true);
414
394
  const minted = steps.find((step) => step.type === 'credential-minted');
@@ -426,6 +406,12 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
426
406
  : filledRoles.has('number') || filledRoles.has('cvc')
427
407
  ? 'partially-exposed'
428
408
  : 'minted-not-exposed';
409
+ const terminalFailureCode = failureCode ??
410
+ (outcome === 'action-required'
411
+ ? 'human-action-required'
412
+ : outcome === 'blocked-by-mandate'
413
+ ? 'mandate-blocked'
414
+ : undefined);
429
415
  return {
430
416
  outcome,
431
417
  fields,
@@ -440,12 +426,24 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
440
426
  : {}),
441
427
  ...(completed ? { fillCompletedAt: completed.ts } : {}),
442
428
  },
429
+ ...(terminalFailureCode ? { failureCode: terminalFailureCode } : {}),
443
430
  ...(detail ? { detail } : {}),
444
431
  ...(confirmationRef ? { confirmationRef } : {}),
445
432
  };
446
433
  }
447
- async function readTransactionFacts(page, opts) {
448
- const pageAmount = await readPageAmount(page);
434
+ async function readTransactionFacts(page, opts, phase) {
435
+ const shopify = await isShopifyCheckoutPage(page);
436
+ const amountRead = shopify
437
+ ? phase === 'review'
438
+ ? await readStableShopifyAmount(page)
439
+ : await readShopifyAmount(page, true)
440
+ : await readGenericPageAmount(page);
441
+ const pageAmount = shopify && amountRead.kind === 'none'
442
+ ? {
443
+ kind: 'unreadable',
444
+ reason: 'Shopify final tax and total summary is not available',
445
+ }
446
+ : amountRead;
449
447
  const amountMinor = pageAmount.kind === 'ok'
450
448
  ? pageAmount.amountMinor
451
449
  : pageAmount.kind === 'none'
@@ -474,10 +472,10 @@ async function readTransactionFacts(page, opts) {
474
472
  currency,
475
473
  source,
476
474
  reason: pageAmount.kind === 'unreadable'
477
- ? 'page total is displayed but cannot be parsed unambiguously'
475
+ ? (pageAmount.reason ?? 'page total is displayed but cannot be parsed unambiguously')
478
476
  : 'transaction amount could not be determined',
479
477
  detail: pageAmount.kind === 'unreadable'
480
- ? 'transaction amount could not be determined (page total present but ambiguous, e.g. separator layout); refusing fail-closed'
478
+ ? `transaction amount could not be determined (${pageAmount.reason ?? 'page total present but ambiguous'}); refusing fail-closed`
481
479
  : 'transaction amount could not be determined (no readable page total, no amountMinor provided); refusing fail-closed',
482
480
  };
483
481
  }
@@ -731,7 +729,21 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
731
729
  mandate: { ...opts.mandate },
732
730
  };
733
731
  const evidence = new EvidenceLog();
734
- const context = await options.browser.newContext();
732
+ // Pin an English locale: amount reconciliation reads the order summary by
733
+ // its visible labels (Subtotal/Taxes/Total), and merchants localize by
734
+ // Accept-Language (observed live 2026-08-16: a Shopify checkout redirected
735
+ // to /es-us and rendered "Impuestos estimados", so the total never parsed
736
+ // and the review refused fail-closed on a perfectly good checkout).
737
+ // Present Web Bot Auth (RFC 9421) credentials when, and only when, an
738
+ // operator directory is configured to resolve them. Off by default: an
739
+ // unresolvable signature fails verification and is worse than none. The
740
+ // signature covers @authority, so it is bound to the checkout host — a
741
+ // cross-origin redirect simply arrives unverified, never wrongly verified.
742
+ const webBotAuthHeaders = webBotAuthHeadersOrNone(options.webBotAuth ?? null, options.url, Date.now() / 1000);
743
+ const context = await options.browser.newContext({
744
+ locale: 'en-US',
745
+ ...(webBotAuthHeaders ? { extraHTTPHeaders: webBotAuthHeaders } : {}),
746
+ });
735
747
  // Bound every action so a mis-detected or hidden element fails fast instead
736
748
  // of stalling on Playwright's long default timeout.
737
749
  context.setDefaultTimeout(6000);
@@ -787,7 +799,75 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
787
799
  }
788
800
  // Detection is read-only here. In particular, no adapter fill and no
789
801
  // Instrument.getCredential() call can occur before an explicit approval.
790
- const detected = await detectFields(page);
802
+ let detected = await detectFields(page);
803
+ const shopifyPage = await isShopifyCheckoutPage(page);
804
+ if (shopifyPage) {
805
+ // Same checkout session, English presentation — the amount reader needs
806
+ // the English summary labels (see shopifyEnglishCheckoutUrl).
807
+ const englishUrl = shopifyEnglishCheckoutUrl(page.url());
808
+ if (englishUrl) {
809
+ await page.goto(englishUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
810
+ await waitForStableDom(page);
811
+ if (await isShopifyCheckoutPage(page)) {
812
+ evidence.step('navigation', { url: page.url(), reason: 'shopify-locale-normalized' });
813
+ detected = await detectFields(page);
814
+ }
815
+ }
816
+ }
817
+ let adapter = selectAdapter(detected, { shopify: shopifyPage });
818
+ if (options.contact && adapter.prepareContact) {
819
+ const preparedContact = await adapter.prepareContact(page, options.contact);
820
+ for (const field of preparedContact.filled) {
821
+ evidence.step('contact-prefill', {
822
+ role: field.role,
823
+ confidence: field.confidence,
824
+ source: field.source,
825
+ frame: field.frame,
826
+ value: field.value,
827
+ ok: field.ok,
828
+ error: field.error,
829
+ });
830
+ }
831
+ const challenge = await detectShopifyChallenge(page);
832
+ if (challenge) {
833
+ evidence.step('outcome', {
834
+ outcome: 'action-required',
835
+ signal: challenge.signal,
836
+ phase: 'contact-prefill',
837
+ });
838
+ evidence.setSnapshotSummary(await snapshotSummary(page));
839
+ return {
840
+ status: 'finished',
841
+ result: makeResult('action-required', fields, evidence, requiresAdapter, `Shop Pay verification requires a human before review (${challenge.signal}); no payment credential was requested`),
842
+ };
843
+ }
844
+ if (!preparedContact.ok) {
845
+ evidence.setSnapshotSummary(await snapshotSummary(page));
846
+ return {
847
+ status: 'finished',
848
+ result: makeResult('failed', fields, evidence, requiresAdapter, preparedContact.detail ??
849
+ 'Shopify contact prefill did not complete; no payment credential was requested'),
850
+ };
851
+ }
852
+ await settle(page);
853
+ const prefillHost = new URL(page.url()).hostname;
854
+ if (prefillHost !== merchantHost) {
855
+ const reason = `merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
856
+ evidence.step('mandate-verdict', {
857
+ phase: 'contact-prefill',
858
+ ok: false,
859
+ reason,
860
+ });
861
+ evidence.setSnapshotSummary(await snapshotSummary(page));
862
+ return {
863
+ status: 'finished',
864
+ result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reason),
865
+ };
866
+ }
867
+ detected = await detectFields(page);
868
+ adapter = selectAdapter(detected, { shopify: shopifyPage });
869
+ evidence.step('adapter-selected', { adapter: adapter.name, phase: 'review' });
870
+ }
791
871
  fields = detected.fields;
792
872
  evidence.step('detect', {
793
873
  phase: 'review',
@@ -801,7 +881,27 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
801
881
  evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
802
882
  }
803
883
  }
804
- const facts = await readTransactionFacts(page, options);
884
+ // A checkout the runner cannot put a card INTO is not reviewable. Without a
885
+ // detected card-number field a later pay would fill nothing and dispatch
886
+ // whatever control the review happened to bind (observed live 2026-08-16:
887
+ // a Payhip storefront SEARCH form, a FastSpring "PayPal Checkout" label,
888
+ // and a Shopify discount-form "Submit" all reviewed clean this way — the
889
+ // real card fields sat in unreachable PSP iframes or an unrendered payment
890
+ // section). Every adapter fills from this same detection, so a missing
891
+ // number field here means no pay can ever succeed: refuse while it is
892
+ // still free.
893
+ if (!fields.number) {
894
+ const found = Object.keys(fields);
895
+ const reason = `no card number field detected (roles found: ${found.length ? found.join(', ') : 'none'}) — ` +
896
+ 'the card form is likely inside a PSP iframe or behind a later step, so a credential cannot be entered on this page';
897
+ evidence.step('detect', { phase: 'review', missingCardNumber: true, reason });
898
+ evidence.setSnapshotSummary(await snapshotSummary(page));
899
+ return {
900
+ status: 'finished',
901
+ result: makeResult('failed', fields, evidence, requiresAdapter, reason, undefined, 'card-number-field-unavailable'),
902
+ };
903
+ }
904
+ const facts = await readTransactionFacts(page, options, 'review');
805
905
  recordTransactionFacts(evidence, 'review', facts);
806
906
  if (!facts.ok) {
807
907
  evidence.step('mandate-verdict', { phase: 'review', ok: false, reason: facts.reason });
@@ -824,7 +924,10 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
824
924
  result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, verdict.reason),
825
925
  };
826
926
  }
827
- const submit = await findSubmit(page);
927
+ // Multi-step pages may expose the final submit control in a hidden section
928
+ // before a safe Continue reveals it. Fingerprint that exact control for the
929
+ // human review; the eventual click path still requires it to be usable.
930
+ const submit = await findSubmit(page, { allowDeferred: true });
828
931
  const review = Object.freeze({
829
932
  id: randomUUID(),
830
933
  url: page.url(),
@@ -912,7 +1015,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
912
1015
  evidence.setSnapshotSummary(await snapshotSummary(page));
913
1016
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason);
914
1017
  }
915
- const approvedFacts = await readTransactionFacts(page, options);
1018
+ const approvedFacts = await readTransactionFacts(page, options, 'approval');
916
1019
  recordTransactionFacts(evidence, 'approval', approvedFacts);
917
1020
  if (!approvedFacts.ok) {
918
1021
  evidence.step('mandate-verdict', {
@@ -945,7 +1048,10 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
945
1048
  evidence.setSnapshotSummary(await snapshotSummary(page));
946
1049
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedAtApproval);
947
1050
  }
948
- const approvalSubmit = await findSubmit(page);
1051
+ // Revalidate the same future target before credential minting. This may be
1052
+ // hidden on a multi-step checkout; the final lookup after reveal requires
1053
+ // the reviewed control to be visible and usable before any click.
1054
+ const approvalSubmit = await findSubmit(page, { allowDeferred: true });
949
1055
  const submitChangedAtApproval = submitTargetChangeReason(checkout.review, approvalSubmit);
950
1056
  if (submitChangedAtApproval) {
951
1057
  evidence.step('approval', {
@@ -967,6 +1073,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
967
1073
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, reason);
968
1074
  }
969
1075
  evidence.step('approval', { approved: true, reviewId: checkout.review.id });
1076
+ if (opts.mode === 'dry-run') {
1077
+ evidence.step('credential-skipped', {
1078
+ reason: 'dry-run stops before credential mint or merchant-page disclosure',
1079
+ });
1080
+ evidence.step('submit', { would: true, target: approvalSubmit?.desc ?? 'none found' });
1081
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1082
+ return makeResult('reviewed-dry-run', state.fields, evidence, requiresAdapter, approvalSubmit
1083
+ ? `validated the reviewed checkout; would click ${approvalSubmit.desc}`
1084
+ : 'validated the reviewed checkout; no submit control detected');
1085
+ }
970
1086
  const credential = await opts.instrument.getCredential({
971
1087
  merchantHost,
972
1088
  amountMinor: approvedFacts.amountMinor,
@@ -976,11 +1092,14 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
976
1092
  ...(credential.credentialExpiresAt
977
1093
  ? { credentialExpiresAt: credential.credentialExpiresAt }
978
1094
  : {}),
1095
+ ...traceHandleFields(credential),
979
1096
  });
980
1097
  // Reveal + fill loop: fills whatever is present, then reveals the next
981
1098
  // surface (card radio / next step) until the card number is filled.
982
1099
  const clicked = new Set();
983
1100
  let adapterName = null;
1101
+ let adapterFillOk = true;
1102
+ let adapterFillDetail = null;
984
1103
  for (let attempt = 0; attempt < 4; attempt++) {
985
1104
  // A reveal/continue action can navigate between attempts. Never expose
986
1105
  // the credential to a host other than the one the human reviewed.
@@ -1009,12 +1128,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1009
1128
  evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
1010
1129
  }
1011
1130
  }
1012
- const adapter = selectAdapter(detected);
1131
+ const adapter = selectAdapter(detected, {
1132
+ shopify: await isShopifyCheckoutPage(page),
1133
+ });
1013
1134
  if (adapter.name !== adapterName) {
1014
1135
  adapterName = adapter.name;
1015
1136
  evidence.step('adapter-selected', { adapter: adapter.name });
1016
1137
  }
1017
1138
  const fill = await adapter.fill(page, detected.fields, credential, opts.contact);
1139
+ adapterFillOk = fill.ok;
1140
+ adapterFillDetail = fill.detail ?? null;
1018
1141
  for (const f of fill.filled) {
1019
1142
  evidence.step('field-fill', {
1020
1143
  role: f.role,
@@ -1024,6 +1147,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1024
1147
  value: f.value,
1025
1148
  ok: f.ok,
1026
1149
  error: f.error,
1150
+ ...(f.relocated ? { relocated: true } : {}),
1027
1151
  });
1028
1152
  }
1029
1153
  const numberOk = fill.filled.some((f) => f.role === 'number' && f.ok);
@@ -1046,9 +1170,30 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1046
1170
  ? []
1047
1171
  : ['expiry']),
1048
1172
  ];
1173
+ // Roles the adapter TRIED to fill and never landed, across every reveal
1174
+ // attempt. A job only exists when the field was detected, was visible, and
1175
+ // we held a value for it (see `add()` in adapters/generic.ts) — so a failure
1176
+ // here is never "the page didn't ask for it". It means the page asked, we
1177
+ // answered, and the element refused.
1178
+ //
1179
+ // Roles that failed on an early attempt and succeeded after a reveal are
1180
+ // excluded: `successfulRoles` spans all four attempts, same as above.
1181
+ const failedFillRoles = [
1182
+ ...new Set(evidence
1183
+ .getSteps()
1184
+ .filter((step) => step.type === 'field-fill' && step.data.ok === false)
1185
+ .map((step) => String(step.data.role))),
1186
+ ]
1187
+ .filter((role) => !successfulRoles.has(role))
1188
+ .sort();
1049
1189
  evidence.step('fill-complete', {
1050
- ok: missingCredentialRoles.length === 0,
1190
+ // "Ready to submit", not "the card fields landed". Before 2026-08-17 this
1191
+ // read only the credential roles, so a whop.com run whose city/state/
1192
+ // postalCode all timed out recorded `ok: true` and clicked Get access on
1193
+ // a form it knew was incomplete.
1194
+ ok: missingCredentialRoles.length === 0 && failedFillRoles.length === 0,
1051
1195
  missingCredentialRoles,
1196
+ failedFillRoles,
1052
1197
  });
1053
1198
  if (options.debugShotsDir) {
1054
1199
  await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '2-filled', evidence, state.fields);
@@ -1056,7 +1201,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1056
1201
  // Re-run the full gate after fill as well. Contact/shipping fields can
1057
1202
  // change the total; any drift from the approved review refuses before a
1058
1203
  // submit click and requires the caller to prepare a new review.
1059
- const submitFacts = await readTransactionFacts(page, options);
1204
+ const submitFacts = await readTransactionFacts(page, options, 'pre-submit');
1060
1205
  recordTransactionFacts(evidence, 'pre-submit', submitFacts);
1061
1206
  if (!submitFacts.ok) {
1062
1207
  evidence.step('mandate-verdict', {
@@ -1099,29 +1244,89 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1099
1244
  evidence.setSnapshotSummary(await snapshotSummary(page));
1100
1245
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
1101
1246
  }
1102
- if (opts.mode === 'dry-run') {
1103
- evidence.step('submit', { would: true, target: submit?.desc ?? 'none found' });
1247
+ if (!adapterFillOk) {
1104
1248
  evidence.setSnapshotSummary(await snapshotSummary(page));
1105
- if (missingCredentialRoles.length > 0) {
1106
- const adapterRequired = requiresAdapter.size > 0;
1107
- return makeResult(adapterRequired ? 'adapter-required' : 'partial-fill', state.fields, evidence, requiresAdapter, adapterRequired
1108
- ? `credential fields require adapter: ${[...requiresAdapter].join(', ')}; missing ${missingCredentialRoles.join(', ')}`
1109
- : `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
1110
- }
1111
- return makeResult('filled-dry-run', state.fields, evidence, requiresAdapter, submit ? `would click ${submit.desc}` : 'no submit control detected');
1249
+ return makeResult('failed', state.fields, evidence, requiresAdapter, adapterFillDetail ?? `${adapterName ?? 'checkout'} adapter fill incomplete`);
1112
1250
  }
1113
1251
  if (missingCredentialRoles.length > 0) {
1114
1252
  evidence.setSnapshotSummary(await snapshotSummary(page));
1115
1253
  return makeResult('failed', state.fields, evidence, requiresAdapter, `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
1116
1254
  }
1255
+ // STOP BEFORE THE CLICK when any field we tried to fill refused. Submitting
1256
+ // a form we know is incomplete is how a PSP ends up holding a charge we
1257
+ // cannot then confirm or account for: the 2026-08-17 whop.com run filled the
1258
+ // card into Basis Theory iframes, watched city/state/postalCode time out at
1259
+ // 5s each, clicked Get access anyway, and could never observe an outcome.
1260
+ //
1261
+ // This refusal happens BEFORE the submit click, so nothing can be charged by
1262
+ // it — the safe direction, and the reason it is allowed to be strict. A
1263
+ // merchant whose address widget we cannot drive now fails cleanly and
1264
+ // retryably instead of dangerously.
1265
+ if (failedFillRoles.length > 0) {
1266
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1267
+ // Name the CAUSE per field, not just the field. The refusal is the only
1268
+ // artifact that survives to the operator (a v2 receipt carries no evidence
1269
+ // log), and "city, postalCode refused" without a reason means the next
1270
+ // person has to reproduce a live merchant to learn anything. Each cause
1271
+ // points at a different fix — see summarizeFillFailure.
1272
+ //
1273
+ // Last attempt wins: a role that failed differently across reveal passes
1274
+ // is best described by how it failed when we finally gave up on it.
1275
+ const lastFillError = (role) => {
1276
+ const errors = evidence
1277
+ .getSteps()
1278
+ .filter((step) => step.type === 'field-fill' && step.data.ok === false && step.data.role === role)
1279
+ .map((step) => (typeof step.data.error === 'string' ? step.data.error : undefined));
1280
+ return errors[errors.length - 1];
1281
+ };
1282
+ const reasons = failedFillRoles.map((role) => `${role} (${summarizeFillFailure(lastFillError(role))})`);
1283
+ 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');
1284
+ }
1117
1285
  if (!submit) {
1118
1286
  evidence.setSnapshotSummary(await snapshotSummary(page));
1119
1287
  return makeResult('failed', state.fields, evidence, requiresAdapter, 'no submit control detected');
1120
1288
  }
1289
+ // Capture the OTP poll watermark BEFORE the click. The click is what
1290
+ // triggers the merchant's verification email, so the watermark must precede
1291
+ // it — otherwise a fast OTP could arrive before we start looking (the 5s
1292
+ // waitForMessage skew is a second line of defence, but ordering matters).
1293
+ // This is just a timestamp — no PII — so it is safe to record.
1294
+ const otpWatermark = new Date().toISOString();
1295
+ evidence.step('note', { otpWatermarkCaptured: true });
1121
1296
  // The suppressed Link lookup must settle before the click or it breaks
1122
1297
  // Stripe's submit chain mid-flight (#5879) — see waitForLinkLookupQuiet.
1123
1298
  const linkQuiet = await waitForLinkLookupQuiet(page);
1124
1299
  evidence.step('note', { linkQuiet });
1300
+ // This is deliberately the last await before the irreversible click.
1301
+ // Mint-time validation is not enough: reveal/fill and Link suppression can
1302
+ // consume a short-lived DAVV. Refuse malformed or <60s credentials so an
1303
+ // expiry decline cannot masquerade as a form-fill failure.
1304
+ const credentialExpiresAt = credential.credentialExpiresAt;
1305
+ const expiryMissing = opts.instrument.kind === 'agentic-token' && credentialExpiresAt === undefined;
1306
+ const expiresMs = credentialExpiresAt === undefined ? Number.NaN : Date.parse(credentialExpiresAt);
1307
+ if (expiryMissing ||
1308
+ (credentialExpiresAt !== undefined &&
1309
+ (!Number.isFinite(expiresMs) || expiresMs - Date.now() < 60_000))) {
1310
+ evidence.step('credential-expiry-check', {
1311
+ ok: false,
1312
+ reason: expiryMissing ? 'missing' : 'invalid-or-expiring',
1313
+ ...(credentialExpiresAt !== undefined ? { credentialExpiresAt } : {}),
1314
+ });
1315
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1316
+ return makeResult('failed', state.fields, evidence, requiresAdapter, 'credential expired before submit; obtain a fresh intent and re-review');
1317
+ }
1318
+ if (credentialExpiresAt !== undefined) {
1319
+ evidence.step('credential-expiry-check', {
1320
+ ok: true,
1321
+ credentialExpiresAt,
1322
+ });
1323
+ }
1324
+ // ORDER IS LOAD-BEARING: record the click BEFORE performing it. The catch
1325
+ // block classifies a throw by whether this step exists — recorded means
1326
+ // "we may have charged" (`unverified`), absent means "retry is safe"
1327
+ // (`failed`). Recording after `submit.click()` would let a throw raised by
1328
+ // the click itself look retry-safe, which is the double-charge direction.
1329
+ // Pinned by "a throw AFTER the pay control was clicked reports unverified".
1125
1330
  evidence.step('submit', { clicked: true, target: submit.desc });
1126
1331
  await submit.click();
1127
1332
  await settle(page);
@@ -1142,6 +1347,57 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1142
1347
  });
1143
1348
  observed = reconcileHeldOutcome(observed, held);
1144
1349
  }
1350
+ // Agent-resolvable email OTP subroutine (SINGLE-USE). Fires only on a
1351
+ // 'verification-required' verdict (the merchant emailed a code to the
1352
+ // agent's own inbox) AND when a resolver is injected. Fills the code EXACTLY
1353
+ // ONCE, re-submits, and re-observes. Merchants invalidate a code on first
1354
+ // use, so a stale code is NEVER retried. On no resolver / timeout / missing
1355
+ // code field it falls through to the action-required (human) path below —
1356
+ // it never hangs and never re-fills credential material.
1357
+ if (observed.status === 'verification-required' && opts.resolveEmailOtp) {
1358
+ const otpDetect = await detectFields(page);
1359
+ const codeField = otpDetect.fields.oneTimeCode;
1360
+ if (!codeField) {
1361
+ evidence.step('note', { emailOtp: 'no one-time-code field detected' });
1362
+ }
1363
+ else {
1364
+ const resolution = await opts.resolveEmailOtp({
1365
+ after: otpWatermark,
1366
+ merchantHost: submitMerchantHost,
1367
+ });
1368
+ if (!resolution) {
1369
+ // Fail CLEAN: no code retrieved before the resolver's timeout.
1370
+ evidence.step('note', { emailOtp: 'not retrieved before timeout' });
1371
+ }
1372
+ else {
1373
+ // Fill once. maskOtp() ensures the code NEVER enters the evidence log
1374
+ // (receipt.ts's PAN backstop does not catch a 4-8 digit OTP). The
1375
+ // sender domain is a non-PII trust signal, safe to record.
1376
+ await page.locator(codeField.locator).fill(resolution.code);
1377
+ evidence.step('field-fill', {
1378
+ role: 'oneTimeCode',
1379
+ confidence: codeField.confidence,
1380
+ source: codeField.source,
1381
+ frame: codeField.frame,
1382
+ value: maskOtp(),
1383
+ ok: true,
1384
+ fromDomain: resolution.fromDomain,
1385
+ });
1386
+ const otpSubmit = await findSubmit(page);
1387
+ if (!otpSubmit) {
1388
+ evidence.step('note', { emailOtp: 'code filled but no submit control found' });
1389
+ }
1390
+ else {
1391
+ const otpLinkQuiet = await waitForLinkLookupQuiet(page);
1392
+ evidence.step('note', { linkQuiet: otpLinkQuiet, phase: 'post-otp' });
1393
+ evidence.step('submit', { clicked: true, target: otpSubmit.desc, phase: 'post-otp' });
1394
+ await otpSubmit.click();
1395
+ await settle(page);
1396
+ observed = await observeOutcome(page, { deadlineMs: opts.outcomeDeadlineMs });
1397
+ }
1398
+ }
1399
+ }
1400
+ }
1145
1401
  evidence.setSnapshotSummary(await snapshotSummary(page));
1146
1402
  if (options.debugShotsDir) {
1147
1403
  await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '3-outcome', evidence, state.fields);
@@ -1167,6 +1423,20 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1167
1423
  });
1168
1424
  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`);
1169
1425
  }
1426
+ if (observed.status === 'verification-required') {
1427
+ // Still needing an email code after the subroutine (no resolver injected,
1428
+ // the code never arrived, or no code field) — hand off to a human. Mapped
1429
+ // to the same action-required outcome; the code is single-use so we never
1430
+ // retry here.
1431
+ evidence.step('outcome', {
1432
+ outcome: 'action-required',
1433
+ signal: observed.signal,
1434
+ reason: 'email verification code required but not auto-resolved',
1435
+ attempts: observed.attempts,
1436
+ elapsedMs: observed.elapsedMs,
1437
+ });
1438
+ return makeResult('action-required', state.fields, evidence, requiresAdapter, `email verification required (${observed.signal}) — a human must enter the code sent to the inbox`);
1439
+ }
1170
1440
  if (observed.status === 'confirmed') {
1171
1441
  const confirmationRef = await readConfirmationRef(page);
1172
1442
  evidence.step('outcome', {
@@ -1178,19 +1448,35 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1178
1448
  });
1179
1449
  return makeResult('confirmed', state.fields, evidence, requiresAdapter, undefined, confirmationRef);
1180
1450
  }
1451
+ // The pay control was clicked and the observer reached its deadline with no
1452
+ // definitive answer. This is NOT a failure — it is the absence of an answer,
1453
+ // and the charge may well have captured. Reporting it as `failed` is what
1454
+ // let a caller re-run the 2026-08-17 whop.com purchase and draw a second $5.
1181
1455
  evidence.step('outcome', {
1182
- outcome: 'failed',
1456
+ outcome: 'unverified',
1183
1457
  reason: 'no confirmation or decline signal',
1184
1458
  lastSeen: observed.lastSeen,
1185
1459
  attempts: observed.attempts,
1186
1460
  elapsedMs: observed.elapsedMs,
1187
1461
  });
1188
- return makeResult('failed', state.fields, evidence, requiresAdapter, observed.lastSeen === 'processing'
1189
- ? 'submitted but outcome unknown (page still processing at deadline)'
1190
- : 'submitted but outcome unknown');
1462
+ return makeResult('unverified', state.fields, evidence, requiresAdapter, observed.lastSeen === 'processing'
1463
+ ? 'submitted but outcome unknown (page still processing at deadline) — the charge may have gone through; verify with the merchant before any retry'
1464
+ : 'submitted but outcome unknown — the charge may have gone through; verify with the merchant before any retry');
1191
1465
  }
1192
1466
  catch (err) {
1193
1467
  const detail = err.message;
1468
+ // A throw AFTER the pay control was clicked (browser teardown, navigation
1469
+ // race, evidence I/O) leaves the same open question as the deadline path: we
1470
+ // clicked, and we do not know what happened. It must not report `failed`
1471
+ // either. A throw before the click never disclosed a payable form, so it
1472
+ // stays a clean, retry-safe failure.
1473
+ const submitted = evidence
1474
+ .getSteps()
1475
+ .some((step) => step.type === 'submit' && step.data.clicked === true);
1476
+ if (submitted) {
1477
+ evidence.step('outcome', { outcome: 'unverified', error: detail });
1478
+ 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`);
1479
+ }
1194
1480
  evidence.step('outcome', { outcome: 'failed', error: detail });
1195
1481
  return makeResult('failed', state.fields, evidence, requiresAdapter, detail);
1196
1482
  }
@@ -1219,8 +1505,8 @@ export async function cancelPreparedCheckout(reviewId, detail = 'checkout cancel
1219
1505
  // approves that exact review ID. Human-in-the-loop callers should use the
1220
1506
  // explicit prepareCheckout()/submitApprovedCheckout() pair instead.
1221
1507
  export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
1222
- const { instrument, contact, mode, outcomeDeadlineMs, ...prepareOptions } = opts;
1223
- const preparation = await prepareCheckout(prepareOptions, store);
1508
+ const { instrument, contact, mode, outcomeDeadlineMs, resolveEmailOtp, ...prepareOptions } = opts;
1509
+ const preparation = await prepareCheckout({ ...prepareOptions, contact }, store);
1224
1510
  if (preparation.status === 'finished')
1225
1511
  return preparation.result;
1226
1512
  return submitApprovedCheckout(preparation.checkout.review.id, {
@@ -1229,5 +1515,6 @@ export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
1229
1515
  contact,
1230
1516
  mode,
1231
1517
  outcomeDeadlineMs,
1518
+ ...(resolveEmailOtp ? { resolveEmailOtp } : {}),
1232
1519
  }, store);
1233
1520
  }