@visa/cli 4.1.0-rc.16 → 4.1.0-rc.161
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +189 -232
- package/dist/checkout-engine/adapters/generic.d.ts +46 -0
- package/dist/checkout-engine/adapters/generic.js +188 -14
- package/dist/checkout-engine/adapters/index.d.ts +4 -1
- package/dist/checkout-engine/adapters/index.js +10 -3
- package/dist/checkout-engine/adapters/shopify.d.ts +55 -0
- package/dist/checkout-engine/adapters/shopify.js +514 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/cli-engine.d.ts +259 -4
- package/dist/checkout-engine/cli-engine.js +797 -43
- package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
- package/dist/checkout-engine/confirmed-merchants.js +165 -0
- package/dist/checkout-engine/detect.d.ts +1 -1
- package/dist/checkout-engine/detect.js +26 -0
- package/dist/checkout-engine/evidence.d.ts +4 -1
- package/dist/checkout-engine/evidence.js +51 -6
- package/dist/checkout-engine/executor.d.ts +39 -4
- package/dist/checkout-engine/executor.js +323 -117
- package/dist/checkout-engine/hosted-approval.d.ts +124 -7
- package/dist/checkout-engine/hosted-approval.js +379 -54
- package/dist/checkout-engine/index.d.ts +8 -2
- package/dist/checkout-engine/index.js +7 -1
- package/dist/checkout-engine/instrument.d.ts +7 -0
- package/dist/checkout-engine/instrument.js +4 -0
- package/dist/checkout-engine/known-merchants.d.ts +10 -0
- package/dist/checkout-engine/known-merchants.js +38 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +0 -20
- package/dist/checkout-engine/live-fill-approval.js +15 -51
- package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
- package/dist/checkout-engine/mandate/card-mandate.js +227 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +178 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +395 -0
- package/dist/checkout-engine/outcome.d.ts +2 -2
- package/dist/checkout-engine/outcome.js +36 -1
- package/dist/checkout-engine/owner-only-file.d.ts +9 -0
- package/dist/checkout-engine/owner-only-file.js +20 -1
- package/dist/checkout-engine/receipt-dir.d.ts +6 -0
- package/dist/checkout-engine/receipt-dir.js +8 -0
- package/dist/checkout-engine/receipt.d.ts +42 -2
- package/dist/checkout-engine/receipt.js +30 -14
- package/dist/checkout-engine/trace-handles.d.ts +8 -0
- package/dist/checkout-engine/trace-handles.js +12 -0
- package/dist/checkout-engine/types.d.ts +20 -2
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +53 -1
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +78 -10
- package/dist/checkout-engine/vgs-live-instrument.d.ts +38 -35
- package/dist/checkout-engine/vgs-live-instrument.js +51 -74
- package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
- package/dist/checkout-engine/vic-confirmation.js +9 -3
- package/dist/checkout-engine/web-bot-auth.d.ts +92 -0
- package/dist/checkout-engine/web-bot-auth.js +159 -0
- package/dist/cli.js +663 -492
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +514 -174
- package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -0
- package/dist/skills/pair-visa-agent/SKILL.md +526 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +3 -41
- package/install.sh +4 -36
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +18 -14
- package/server.json +3 -3
- package/dist/checkout-engine/inline-target.d.ts +0 -13
- package/dist/checkout-engine/inline-target.js +0 -37
- package/dist/checkout-engine/pay-args.d.ts +0 -14
- package/dist/checkout-engine/pay-args.js +0 -44
- package/dist/checkout-engine/pay.d.ts +0 -1
- package/dist/checkout-engine/pay.js +0 -13
- package/dist/checkout-engine/repo-env.d.ts +0 -11
- package/dist/checkout-engine/repo-env.js +0 -23
- package/dist/checkout-engine/run-live-fill.d.ts +0 -1
- package/dist/checkout-engine/run-live-fill.js +0 -443
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
- 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 ->
|
|
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,21 @@
|
|
|
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
|
|
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 { traceHandleFields } from './trace-handles.js';
|
|
26
|
+
import { readGenericPageAmount } from './amount.js';
|
|
27
|
+
import { webBotAuthHeadersOrNone } from './web-bot-auth.js';
|
|
28
|
+
import { detectShopifyChallenge, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, shopifyEnglishCheckoutUrl, } from './adapters/shopify.js';
|
|
29
|
+
export { minorFromDecimal, pageCurrency } from './amount.js';
|
|
24
30
|
const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
|
|
25
31
|
const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
|
|
26
32
|
// Post-submit confirmed/declined/challenge signals live in outcome.ts
|
|
@@ -35,92 +41,6 @@ async function settle(page) {
|
|
|
35
41
|
await page.waitForTimeout(200);
|
|
36
42
|
await page.waitForLoadState('networkidle', { timeout: 1500 }).catch(() => { });
|
|
37
43
|
}
|
|
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
44
|
async function tryReveal(page, evidence, clicked) {
|
|
125
45
|
// 1) A payment-method radio for card/credit/debit (accordion layouts).
|
|
126
46
|
const radios = page.locator('input[type="radio"]');
|
|
@@ -226,6 +146,19 @@ async function findSubmit(page) {
|
|
|
226
146
|
}
|
|
227
147
|
return null;
|
|
228
148
|
}
|
|
149
|
+
// The diagnostic snapshot records the page ORIGIN only, never the full URL: a
|
|
150
|
+
// payment-session path/query (e.g. a live Stripe `cs_live_...` checkout-session
|
|
151
|
+
// id) must not be retained in the local receipt, which elsewhere promises
|
|
152
|
+
// "hostname only" (#7101). Falls back to the raw value only if it does not parse
|
|
153
|
+
// as a URL (never a real page.url()).
|
|
154
|
+
export function snapshotOrigin(rawUrl) {
|
|
155
|
+
try {
|
|
156
|
+
return new URL(rawUrl).origin;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
229
162
|
async function snapshotSummary(page) {
|
|
230
163
|
const info = await page
|
|
231
164
|
.evaluate(() => {
|
|
@@ -234,7 +167,7 @@ async function snapshotSummary(page) {
|
|
|
234
167
|
return { title: document.title, heading: heading?.textContent?.trim() || '', body };
|
|
235
168
|
})
|
|
236
169
|
.catch(() => ({ title: '', heading: '', body: '' }));
|
|
237
|
-
return `url=${page.url()} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
|
|
170
|
+
return `url=${snapshotOrigin(page.url())} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
|
|
238
171
|
}
|
|
239
172
|
async function readConfirmationRef(page) {
|
|
240
173
|
const ref = await page
|
|
@@ -408,7 +341,7 @@ function unknownPreparedCheckoutResult(reviewId) {
|
|
|
408
341
|
evidence.step('approval', { approved: false, reviewId, reason: detail });
|
|
409
342
|
return makeResult('failed', {}, evidence, [], detail);
|
|
410
343
|
}
|
|
411
|
-
function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirmationRef) {
|
|
344
|
+
function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirmationRef, failureCode) {
|
|
412
345
|
const steps = evidence.getSteps();
|
|
413
346
|
const approved = steps.find((step) => step.type === 'approval' && step.data.approved === true);
|
|
414
347
|
const minted = steps.find((step) => step.type === 'credential-minted');
|
|
@@ -426,6 +359,12 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
|
|
|
426
359
|
: filledRoles.has('number') || filledRoles.has('cvc')
|
|
427
360
|
? 'partially-exposed'
|
|
428
361
|
: 'minted-not-exposed';
|
|
362
|
+
const terminalFailureCode = failureCode ??
|
|
363
|
+
(outcome === 'action-required'
|
|
364
|
+
? 'human-action-required'
|
|
365
|
+
: outcome === 'blocked-by-mandate'
|
|
366
|
+
? 'mandate-blocked'
|
|
367
|
+
: undefined);
|
|
429
368
|
return {
|
|
430
369
|
outcome,
|
|
431
370
|
fields,
|
|
@@ -440,12 +379,24 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
|
|
|
440
379
|
: {}),
|
|
441
380
|
...(completed ? { fillCompletedAt: completed.ts } : {}),
|
|
442
381
|
},
|
|
382
|
+
...(terminalFailureCode ? { failureCode: terminalFailureCode } : {}),
|
|
443
383
|
...(detail ? { detail } : {}),
|
|
444
384
|
...(confirmationRef ? { confirmationRef } : {}),
|
|
445
385
|
};
|
|
446
386
|
}
|
|
447
|
-
async function readTransactionFacts(page, opts) {
|
|
448
|
-
const
|
|
387
|
+
async function readTransactionFacts(page, opts, phase) {
|
|
388
|
+
const shopify = await isShopifyCheckoutPage(page);
|
|
389
|
+
const amountRead = shopify
|
|
390
|
+
? phase === 'review'
|
|
391
|
+
? await readStableShopifyAmount(page)
|
|
392
|
+
: await readShopifyAmount(page, true)
|
|
393
|
+
: await readGenericPageAmount(page);
|
|
394
|
+
const pageAmount = shopify && amountRead.kind === 'none'
|
|
395
|
+
? {
|
|
396
|
+
kind: 'unreadable',
|
|
397
|
+
reason: 'Shopify final tax and total summary is not available',
|
|
398
|
+
}
|
|
399
|
+
: amountRead;
|
|
449
400
|
const amountMinor = pageAmount.kind === 'ok'
|
|
450
401
|
? pageAmount.amountMinor
|
|
451
402
|
: pageAmount.kind === 'none'
|
|
@@ -474,10 +425,10 @@ async function readTransactionFacts(page, opts) {
|
|
|
474
425
|
currency,
|
|
475
426
|
source,
|
|
476
427
|
reason: pageAmount.kind === 'unreadable'
|
|
477
|
-
? 'page total is displayed but cannot be parsed unambiguously'
|
|
428
|
+
? (pageAmount.reason ?? 'page total is displayed but cannot be parsed unambiguously')
|
|
478
429
|
: 'transaction amount could not be determined',
|
|
479
430
|
detail: pageAmount.kind === 'unreadable'
|
|
480
|
-
?
|
|
431
|
+
? `transaction amount could not be determined (${pageAmount.reason ?? 'page total present but ambiguous'}); refusing fail-closed`
|
|
481
432
|
: 'transaction amount could not be determined (no readable page total, no amountMinor provided); refusing fail-closed',
|
|
482
433
|
};
|
|
483
434
|
}
|
|
@@ -630,11 +581,17 @@ async function captureDebugShot(page, dir, reviewId, label, evidence, fields) {
|
|
|
630
581
|
export function isStripeLinkConsumerRequest(url) {
|
|
631
582
|
return /(?:^|\/\/)([a-z0-9.-]*\.)?stripe\.com\/v1\/consumers\/sessions\/(?:lookup|start_verification)\b/i.test(url);
|
|
632
583
|
}
|
|
633
|
-
|
|
634
|
-
|
|
584
|
+
// Keyed by Page so the tracker installed at prepare time is reachable from the
|
|
585
|
+
// approved-submit leg without threading through the session store types.
|
|
586
|
+
const linkSuppressionByPage = new WeakMap();
|
|
587
|
+
// Exported for the link-quiet unit tests (a fake Page captures the route
|
|
588
|
+
// handler); production callers stay inside this module.
|
|
589
|
+
export async function suppressStripeLink(page, evidence) {
|
|
590
|
+
const state = { suppressed: 0, waiters: [] };
|
|
591
|
+
linkSuppressionByPage.set(page, state);
|
|
635
592
|
await page.route((u) => isStripeLinkConsumerRequest(typeof u === 'string' ? u : u.href), (route) => {
|
|
636
|
-
suppressed += 1;
|
|
637
|
-
if (suppressed === 1) {
|
|
593
|
+
state.suppressed += 1;
|
|
594
|
+
if (state.suppressed === 1) {
|
|
638
595
|
// origin + pathname only — never the full URL. The lookup carries the
|
|
639
596
|
// email in the POST body today, but keep an operator email out of the
|
|
640
597
|
// evidence log even if Stripe moves a param to the query string (#5708).
|
|
@@ -649,9 +606,45 @@ async function suppressStripeLink(page, evidence) {
|
|
|
649
606
|
}
|
|
650
607
|
evidence.step('note', { linkSuppressed: safe });
|
|
651
608
|
}
|
|
609
|
+
for (const wake of state.waiters.splice(0))
|
|
610
|
+
wake();
|
|
652
611
|
return route.abort();
|
|
653
612
|
});
|
|
654
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Wait for the suppressed Stripe Link lookup to fire and settle BEFORE the
|
|
616
|
+
* submit click. Stripe debounces its consumer-session lookup ~300ms after the
|
|
617
|
+
* email input changes; our fill→click gap is single-digit ms, so the (aborted)
|
|
618
|
+
* lookup used to land INSIDE Stripe's in-flight submit chain and kill it
|
|
619
|
+
* silently — the click looked accepted but tokenization never ran and the page
|
|
620
|
+
* sat on the form until the outcome deadline (#5879: three identical live
|
|
621
|
+
* stalls at donate.stripe.com). Verified live A/B on that page: instant click →
|
|
622
|
+
* dead submit, no /v1/payment_methods; lookup settled first → tokenization and
|
|
623
|
+
* the confirm step both reached.
|
|
624
|
+
*
|
|
625
|
+
* If the lookup already fired, only the short settle applies (lets Stripe's
|
|
626
|
+
* abort handling unwind). If it never fires — non-Link page variants, no email
|
|
627
|
+
* field — the bound expires and the click proceeds as before.
|
|
628
|
+
*/
|
|
629
|
+
export async function waitForLinkLookupQuiet(page, opts = {}) {
|
|
630
|
+
const boundMs = opts.boundMs ?? 1500;
|
|
631
|
+
const settleMs = opts.settleMs ?? 250;
|
|
632
|
+
const delay = opts.delay ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
633
|
+
const state = linkSuppressionByPage.get(page);
|
|
634
|
+
if (!state)
|
|
635
|
+
return { fired: false, waitedMs: 0 };
|
|
636
|
+
const started = Date.now();
|
|
637
|
+
if (state.suppressed === 0) {
|
|
638
|
+
await Promise.race([
|
|
639
|
+
new Promise((resolve) => state.waiters.push(resolve)),
|
|
640
|
+
delay(boundMs),
|
|
641
|
+
]);
|
|
642
|
+
}
|
|
643
|
+
const fired = state.suppressed > 0;
|
|
644
|
+
if (fired)
|
|
645
|
+
await delay(settleMs);
|
|
646
|
+
return { fired, waitedMs: Date.now() - started };
|
|
647
|
+
}
|
|
655
648
|
// Payer-chosen amount inputs. Deliberately payment-link-specific (Stripe's
|
|
656
649
|
// customUnitAmount): on an ordinary checkout the total is merchant-controlled
|
|
657
650
|
// and typing into anything price-like must never happen.
|
|
@@ -689,7 +682,21 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
689
682
|
mandate: { ...opts.mandate },
|
|
690
683
|
};
|
|
691
684
|
const evidence = new EvidenceLog();
|
|
692
|
-
|
|
685
|
+
// Pin an English locale: amount reconciliation reads the order summary by
|
|
686
|
+
// its visible labels (Subtotal/Taxes/Total), and merchants localize by
|
|
687
|
+
// Accept-Language (observed live 2026-08-16: a Shopify checkout redirected
|
|
688
|
+
// to /es-us and rendered "Impuestos estimados", so the total never parsed
|
|
689
|
+
// and the review refused fail-closed on a perfectly good checkout).
|
|
690
|
+
// Present Web Bot Auth (RFC 9421) credentials when, and only when, an
|
|
691
|
+
// operator directory is configured to resolve them. Off by default: an
|
|
692
|
+
// unresolvable signature fails verification and is worse than none. The
|
|
693
|
+
// signature covers @authority, so it is bound to the checkout host — a
|
|
694
|
+
// cross-origin redirect simply arrives unverified, never wrongly verified.
|
|
695
|
+
const webBotAuthHeaders = webBotAuthHeadersOrNone(options.webBotAuth ?? null, options.url, Date.now() / 1000);
|
|
696
|
+
const context = await options.browser.newContext({
|
|
697
|
+
locale: 'en-US',
|
|
698
|
+
...(webBotAuthHeaders ? { extraHTTPHeaders: webBotAuthHeaders } : {}),
|
|
699
|
+
});
|
|
693
700
|
// Bound every action so a mis-detected or hidden element fails fast instead
|
|
694
701
|
// of stalling on Playwright's long default timeout.
|
|
695
702
|
context.setDefaultTimeout(6000);
|
|
@@ -745,7 +752,75 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
745
752
|
}
|
|
746
753
|
// Detection is read-only here. In particular, no adapter fill and no
|
|
747
754
|
// Instrument.getCredential() call can occur before an explicit approval.
|
|
748
|
-
|
|
755
|
+
let detected = await detectFields(page);
|
|
756
|
+
const shopifyPage = await isShopifyCheckoutPage(page);
|
|
757
|
+
if (shopifyPage) {
|
|
758
|
+
// Same checkout session, English presentation — the amount reader needs
|
|
759
|
+
// the English summary labels (see shopifyEnglishCheckoutUrl).
|
|
760
|
+
const englishUrl = shopifyEnglishCheckoutUrl(page.url());
|
|
761
|
+
if (englishUrl) {
|
|
762
|
+
await page.goto(englishUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
|
|
763
|
+
await waitForStableDom(page);
|
|
764
|
+
if (await isShopifyCheckoutPage(page)) {
|
|
765
|
+
evidence.step('navigation', { url: page.url(), reason: 'shopify-locale-normalized' });
|
|
766
|
+
detected = await detectFields(page);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
let adapter = selectAdapter(detected, { shopify: shopifyPage });
|
|
771
|
+
if (options.contact && adapter.prepareContact) {
|
|
772
|
+
const preparedContact = await adapter.prepareContact(page, options.contact);
|
|
773
|
+
for (const field of preparedContact.filled) {
|
|
774
|
+
evidence.step('contact-prefill', {
|
|
775
|
+
role: field.role,
|
|
776
|
+
confidence: field.confidence,
|
|
777
|
+
source: field.source,
|
|
778
|
+
frame: field.frame,
|
|
779
|
+
value: field.value,
|
|
780
|
+
ok: field.ok,
|
|
781
|
+
error: field.error,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
const challenge = await detectShopifyChallenge(page);
|
|
785
|
+
if (challenge) {
|
|
786
|
+
evidence.step('outcome', {
|
|
787
|
+
outcome: 'action-required',
|
|
788
|
+
signal: challenge.signal,
|
|
789
|
+
phase: 'contact-prefill',
|
|
790
|
+
});
|
|
791
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
792
|
+
return {
|
|
793
|
+
status: 'finished',
|
|
794
|
+
result: makeResult('action-required', fields, evidence, requiresAdapter, `Shop Pay verification requires a human before review (${challenge.signal}); no payment credential was requested`),
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
if (!preparedContact.ok) {
|
|
798
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
799
|
+
return {
|
|
800
|
+
status: 'finished',
|
|
801
|
+
result: makeResult('failed', fields, evidence, requiresAdapter, preparedContact.detail ??
|
|
802
|
+
'Shopify contact prefill did not complete; no payment credential was requested'),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
await settle(page);
|
|
806
|
+
const prefillHost = new URL(page.url()).hostname;
|
|
807
|
+
if (prefillHost !== merchantHost) {
|
|
808
|
+
const reason = `merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
|
|
809
|
+
evidence.step('mandate-verdict', {
|
|
810
|
+
phase: 'contact-prefill',
|
|
811
|
+
ok: false,
|
|
812
|
+
reason,
|
|
813
|
+
});
|
|
814
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
815
|
+
return {
|
|
816
|
+
status: 'finished',
|
|
817
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reason),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
detected = await detectFields(page);
|
|
821
|
+
adapter = selectAdapter(detected, { shopify: shopifyPage });
|
|
822
|
+
evidence.step('adapter-selected', { adapter: adapter.name, phase: 'review' });
|
|
823
|
+
}
|
|
749
824
|
fields = detected.fields;
|
|
750
825
|
evidence.step('detect', {
|
|
751
826
|
phase: 'review',
|
|
@@ -759,7 +834,27 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
759
834
|
evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
|
|
760
835
|
}
|
|
761
836
|
}
|
|
762
|
-
|
|
837
|
+
// A checkout the runner cannot put a card INTO is not reviewable. Without a
|
|
838
|
+
// detected card-number field a later pay would fill nothing and dispatch
|
|
839
|
+
// whatever control the review happened to bind (observed live 2026-08-16:
|
|
840
|
+
// a Payhip storefront SEARCH form, a FastSpring "PayPal Checkout" label,
|
|
841
|
+
// and a Shopify discount-form "Submit" all reviewed clean this way — the
|
|
842
|
+
// real card fields sat in unreachable PSP iframes or an unrendered payment
|
|
843
|
+
// section). Every adapter fills from this same detection, so a missing
|
|
844
|
+
// number field here means no pay can ever succeed: refuse while it is
|
|
845
|
+
// still free.
|
|
846
|
+
if (!fields.number) {
|
|
847
|
+
const found = Object.keys(fields);
|
|
848
|
+
const reason = `no card number field detected (roles found: ${found.length ? found.join(', ') : 'none'}) — ` +
|
|
849
|
+
'the card form is likely inside a PSP iframe or behind a later step, so a credential cannot be entered on this page';
|
|
850
|
+
evidence.step('detect', { phase: 'review', missingCardNumber: true, reason });
|
|
851
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
852
|
+
return {
|
|
853
|
+
status: 'finished',
|
|
854
|
+
result: makeResult('failed', fields, evidence, requiresAdapter, reason, undefined, 'card-number-field-unavailable'),
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
const facts = await readTransactionFacts(page, options, 'review');
|
|
763
858
|
recordTransactionFacts(evidence, 'review', facts);
|
|
764
859
|
if (!facts.ok) {
|
|
765
860
|
evidence.step('mandate-verdict', { phase: 'review', ok: false, reason: facts.reason });
|
|
@@ -870,7 +965,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
870
965
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
871
966
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason);
|
|
872
967
|
}
|
|
873
|
-
const approvedFacts = await readTransactionFacts(page, options);
|
|
968
|
+
const approvedFacts = await readTransactionFacts(page, options, 'approval');
|
|
874
969
|
recordTransactionFacts(evidence, 'approval', approvedFacts);
|
|
875
970
|
if (!approvedFacts.ok) {
|
|
876
971
|
evidence.step('mandate-verdict', {
|
|
@@ -925,6 +1020,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
925
1020
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, reason);
|
|
926
1021
|
}
|
|
927
1022
|
evidence.step('approval', { approved: true, reviewId: checkout.review.id });
|
|
1023
|
+
if (opts.mode === 'dry-run') {
|
|
1024
|
+
evidence.step('credential-skipped', {
|
|
1025
|
+
reason: 'dry-run stops before credential mint or merchant-page disclosure',
|
|
1026
|
+
});
|
|
1027
|
+
evidence.step('submit', { would: true, target: approvalSubmit?.desc ?? 'none found' });
|
|
1028
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1029
|
+
return makeResult('reviewed-dry-run', state.fields, evidence, requiresAdapter, approvalSubmit
|
|
1030
|
+
? `validated the reviewed checkout; would click ${approvalSubmit.desc}`
|
|
1031
|
+
: 'validated the reviewed checkout; no submit control detected');
|
|
1032
|
+
}
|
|
928
1033
|
const credential = await opts.instrument.getCredential({
|
|
929
1034
|
merchantHost,
|
|
930
1035
|
amountMinor: approvedFacts.amountMinor,
|
|
@@ -934,11 +1039,14 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
934
1039
|
...(credential.credentialExpiresAt
|
|
935
1040
|
? { credentialExpiresAt: credential.credentialExpiresAt }
|
|
936
1041
|
: {}),
|
|
1042
|
+
...traceHandleFields(credential),
|
|
937
1043
|
});
|
|
938
1044
|
// Reveal + fill loop: fills whatever is present, then reveals the next
|
|
939
1045
|
// surface (card radio / next step) until the card number is filled.
|
|
940
1046
|
const clicked = new Set();
|
|
941
1047
|
let adapterName = null;
|
|
1048
|
+
let adapterFillOk = true;
|
|
1049
|
+
let adapterFillDetail = null;
|
|
942
1050
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
943
1051
|
// A reveal/continue action can navigate between attempts. Never expose
|
|
944
1052
|
// the credential to a host other than the one the human reviewed.
|
|
@@ -967,12 +1075,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
967
1075
|
evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
|
|
968
1076
|
}
|
|
969
1077
|
}
|
|
970
|
-
const adapter = selectAdapter(detected
|
|
1078
|
+
const adapter = selectAdapter(detected, {
|
|
1079
|
+
shopify: await isShopifyCheckoutPage(page),
|
|
1080
|
+
});
|
|
971
1081
|
if (adapter.name !== adapterName) {
|
|
972
1082
|
adapterName = adapter.name;
|
|
973
1083
|
evidence.step('adapter-selected', { adapter: adapter.name });
|
|
974
1084
|
}
|
|
975
1085
|
const fill = await adapter.fill(page, detected.fields, credential, opts.contact);
|
|
1086
|
+
adapterFillOk = fill.ok;
|
|
1087
|
+
adapterFillDetail = fill.detail ?? null;
|
|
976
1088
|
for (const f of fill.filled) {
|
|
977
1089
|
evidence.step('field-fill', {
|
|
978
1090
|
role: f.role,
|
|
@@ -1014,7 +1126,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1014
1126
|
// Re-run the full gate after fill as well. Contact/shipping fields can
|
|
1015
1127
|
// change the total; any drift from the approved review refuses before a
|
|
1016
1128
|
// submit click and requires the caller to prepare a new review.
|
|
1017
|
-
const submitFacts = await readTransactionFacts(page, options);
|
|
1129
|
+
const submitFacts = await readTransactionFacts(page, options, 'pre-submit');
|
|
1018
1130
|
recordTransactionFacts(evidence, 'pre-submit', submitFacts);
|
|
1019
1131
|
if (!submitFacts.ok) {
|
|
1020
1132
|
evidence.step('mandate-verdict', {
|
|
@@ -1057,16 +1169,9 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1057
1169
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1058
1170
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
|
|
1059
1171
|
}
|
|
1060
|
-
if (
|
|
1061
|
-
evidence.step('submit', { would: true, target: submit?.desc ?? 'none found' });
|
|
1172
|
+
if (!adapterFillOk) {
|
|
1062
1173
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1063
|
-
|
|
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');
|
|
1174
|
+
return makeResult('failed', state.fields, evidence, requiresAdapter, adapterFillDetail ?? `${adapterName ?? 'checkout'} adapter fill incomplete`);
|
|
1070
1175
|
}
|
|
1071
1176
|
if (missingCredentialRoles.length > 0) {
|
|
1072
1177
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
@@ -1076,6 +1181,41 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1076
1181
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1077
1182
|
return makeResult('failed', state.fields, evidence, requiresAdapter, 'no submit control detected');
|
|
1078
1183
|
}
|
|
1184
|
+
// Capture the OTP poll watermark BEFORE the click. The click is what
|
|
1185
|
+
// triggers the merchant's verification email, so the watermark must precede
|
|
1186
|
+
// it — otherwise a fast OTP could arrive before we start looking (the 5s
|
|
1187
|
+
// waitForMessage skew is a second line of defence, but ordering matters).
|
|
1188
|
+
// This is just a timestamp — no PII — so it is safe to record.
|
|
1189
|
+
const otpWatermark = new Date().toISOString();
|
|
1190
|
+
evidence.step('note', { otpWatermarkCaptured: true });
|
|
1191
|
+
// The suppressed Link lookup must settle before the click or it breaks
|
|
1192
|
+
// Stripe's submit chain mid-flight (#5879) — see waitForLinkLookupQuiet.
|
|
1193
|
+
const linkQuiet = await waitForLinkLookupQuiet(page);
|
|
1194
|
+
evidence.step('note', { linkQuiet });
|
|
1195
|
+
// This is deliberately the last await before the irreversible click.
|
|
1196
|
+
// Mint-time validation is not enough: reveal/fill and Link suppression can
|
|
1197
|
+
// consume a short-lived DAVV. Refuse malformed or <60s credentials so an
|
|
1198
|
+
// expiry decline cannot masquerade as a form-fill failure.
|
|
1199
|
+
const credentialExpiresAt = credential.credentialExpiresAt;
|
|
1200
|
+
const expiryMissing = opts.instrument.kind === 'agentic-token' && credentialExpiresAt === undefined;
|
|
1201
|
+
const expiresMs = credentialExpiresAt === undefined ? Number.NaN : Date.parse(credentialExpiresAt);
|
|
1202
|
+
if (expiryMissing ||
|
|
1203
|
+
(credentialExpiresAt !== undefined &&
|
|
1204
|
+
(!Number.isFinite(expiresMs) || expiresMs - Date.now() < 60_000))) {
|
|
1205
|
+
evidence.step('credential-expiry-check', {
|
|
1206
|
+
ok: false,
|
|
1207
|
+
reason: expiryMissing ? 'missing' : 'invalid-or-expiring',
|
|
1208
|
+
...(credentialExpiresAt !== undefined ? { credentialExpiresAt } : {}),
|
|
1209
|
+
});
|
|
1210
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1211
|
+
return makeResult('failed', state.fields, evidence, requiresAdapter, 'credential expired before submit; obtain a fresh intent and re-review');
|
|
1212
|
+
}
|
|
1213
|
+
if (credentialExpiresAt !== undefined) {
|
|
1214
|
+
evidence.step('credential-expiry-check', {
|
|
1215
|
+
ok: true,
|
|
1216
|
+
credentialExpiresAt,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1079
1219
|
evidence.step('submit', { clicked: true, target: submit.desc });
|
|
1080
1220
|
await submit.click();
|
|
1081
1221
|
await settle(page);
|
|
@@ -1096,6 +1236,57 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1096
1236
|
});
|
|
1097
1237
|
observed = reconcileHeldOutcome(observed, held);
|
|
1098
1238
|
}
|
|
1239
|
+
// Agent-resolvable email OTP subroutine (SINGLE-USE). Fires only on a
|
|
1240
|
+
// 'verification-required' verdict (the merchant emailed a code to the
|
|
1241
|
+
// agent's own inbox) AND when a resolver is injected. Fills the code EXACTLY
|
|
1242
|
+
// ONCE, re-submits, and re-observes. Merchants invalidate a code on first
|
|
1243
|
+
// use, so a stale code is NEVER retried. On no resolver / timeout / missing
|
|
1244
|
+
// code field it falls through to the action-required (human) path below —
|
|
1245
|
+
// it never hangs and never re-fills credential material.
|
|
1246
|
+
if (observed.status === 'verification-required' && opts.resolveEmailOtp) {
|
|
1247
|
+
const otpDetect = await detectFields(page);
|
|
1248
|
+
const codeField = otpDetect.fields.oneTimeCode;
|
|
1249
|
+
if (!codeField) {
|
|
1250
|
+
evidence.step('note', { emailOtp: 'no one-time-code field detected' });
|
|
1251
|
+
}
|
|
1252
|
+
else {
|
|
1253
|
+
const resolution = await opts.resolveEmailOtp({
|
|
1254
|
+
after: otpWatermark,
|
|
1255
|
+
merchantHost: submitMerchantHost,
|
|
1256
|
+
});
|
|
1257
|
+
if (!resolution) {
|
|
1258
|
+
// Fail CLEAN: no code retrieved before the resolver's timeout.
|
|
1259
|
+
evidence.step('note', { emailOtp: 'not retrieved before timeout' });
|
|
1260
|
+
}
|
|
1261
|
+
else {
|
|
1262
|
+
// Fill once. maskOtp() ensures the code NEVER enters the evidence log
|
|
1263
|
+
// (receipt.ts's PAN backstop does not catch a 4-8 digit OTP). The
|
|
1264
|
+
// sender domain is a non-PII trust signal, safe to record.
|
|
1265
|
+
await page.locator(codeField.locator).fill(resolution.code);
|
|
1266
|
+
evidence.step('field-fill', {
|
|
1267
|
+
role: 'oneTimeCode',
|
|
1268
|
+
confidence: codeField.confidence,
|
|
1269
|
+
source: codeField.source,
|
|
1270
|
+
frame: codeField.frame,
|
|
1271
|
+
value: maskOtp(),
|
|
1272
|
+
ok: true,
|
|
1273
|
+
fromDomain: resolution.fromDomain,
|
|
1274
|
+
});
|
|
1275
|
+
const otpSubmit = await findSubmit(page);
|
|
1276
|
+
if (!otpSubmit) {
|
|
1277
|
+
evidence.step('note', { emailOtp: 'code filled but no submit control found' });
|
|
1278
|
+
}
|
|
1279
|
+
else {
|
|
1280
|
+
const otpLinkQuiet = await waitForLinkLookupQuiet(page);
|
|
1281
|
+
evidence.step('note', { linkQuiet: otpLinkQuiet, phase: 'post-otp' });
|
|
1282
|
+
evidence.step('submit', { clicked: true, target: otpSubmit.desc, phase: 'post-otp' });
|
|
1283
|
+
await otpSubmit.click();
|
|
1284
|
+
await settle(page);
|
|
1285
|
+
observed = await observeOutcome(page, { deadlineMs: opts.outcomeDeadlineMs });
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1099
1290
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1100
1291
|
if (options.debugShotsDir) {
|
|
1101
1292
|
await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '3-outcome', evidence, state.fields);
|
|
@@ -1121,6 +1312,20 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1121
1312
|
});
|
|
1122
1313
|
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
1314
|
}
|
|
1315
|
+
if (observed.status === 'verification-required') {
|
|
1316
|
+
// Still needing an email code after the subroutine (no resolver injected,
|
|
1317
|
+
// the code never arrived, or no code field) — hand off to a human. Mapped
|
|
1318
|
+
// to the same action-required outcome; the code is single-use so we never
|
|
1319
|
+
// retry here.
|
|
1320
|
+
evidence.step('outcome', {
|
|
1321
|
+
outcome: 'action-required',
|
|
1322
|
+
signal: observed.signal,
|
|
1323
|
+
reason: 'email verification code required but not auto-resolved',
|
|
1324
|
+
attempts: observed.attempts,
|
|
1325
|
+
elapsedMs: observed.elapsedMs,
|
|
1326
|
+
});
|
|
1327
|
+
return makeResult('action-required', state.fields, evidence, requiresAdapter, `email verification required (${observed.signal}) — a human must enter the code sent to the inbox`);
|
|
1328
|
+
}
|
|
1124
1329
|
if (observed.status === 'confirmed') {
|
|
1125
1330
|
const confirmationRef = await readConfirmationRef(page);
|
|
1126
1331
|
evidence.step('outcome', {
|
|
@@ -1173,8 +1378,8 @@ export async function cancelPreparedCheckout(reviewId, detail = 'checkout cancel
|
|
|
1173
1378
|
// approves that exact review ID. Human-in-the-loop callers should use the
|
|
1174
1379
|
// explicit prepareCheckout()/submitApprovedCheckout() pair instead.
|
|
1175
1380
|
export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
|
|
1176
|
-
const { instrument, contact, mode, outcomeDeadlineMs, ...prepareOptions } = opts;
|
|
1177
|
-
const preparation = await prepareCheckout(prepareOptions, store);
|
|
1381
|
+
const { instrument, contact, mode, outcomeDeadlineMs, resolveEmailOtp, ...prepareOptions } = opts;
|
|
1382
|
+
const preparation = await prepareCheckout({ ...prepareOptions, contact }, store);
|
|
1178
1383
|
if (preparation.status === 'finished')
|
|
1179
1384
|
return preparation.result;
|
|
1180
1385
|
return submitApprovedCheckout(preparation.checkout.review.id, {
|
|
@@ -1183,5 +1388,6 @@ export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
|
|
|
1183
1388
|
contact,
|
|
1184
1389
|
mode,
|
|
1185
1390
|
outcomeDeadlineMs,
|
|
1391
|
+
...(resolveEmailOtp ? { resolveEmailOtp } : {}),
|
|
1186
1392
|
}, store);
|
|
1187
1393
|
}
|