@visa/cli 4.1.0-rc.24 → 4.1.0-rc.240
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 +262 -227
- package/dist/checkout-engine/adapters/generic.d.ts +69 -0
- package/dist/checkout-engine/adapters/generic.js +383 -58
- 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 +80 -0
- package/dist/checkout-engine/adapters/shopify.js +688 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/browser-launch.d.ts +9 -4
- package/dist/checkout-engine/browser-launch.js +19 -4
- package/dist/checkout-engine/browserbase-browser.d.ts +24 -0
- package/dist/checkout-engine/browserbase-browser.js +186 -0
- package/dist/checkout-engine/cli-engine.d.ts +297 -4
- package/dist/checkout-engine/cli-engine.js +942 -48
- 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 +64 -4
- package/dist/checkout-engine/executor.js +604 -145
- package/dist/checkout-engine/hosted-approval.d.ts +124 -7
- package/dist/checkout-engine/hosted-approval.js +384 -54
- package/dist/checkout-engine/index.d.ts +9 -2
- package/dist/checkout-engine/index.js +8 -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 +5 -20
- package/dist/checkout-engine/live-fill-approval.js +20 -51
- package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
- package/dist/checkout-engine/mandate/card-mandate.js +226 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +174 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +410 -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 +56 -2
- package/dist/checkout-engine/receipt.js +55 -16
- 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 +28 -2
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +134 -0
- 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 +98 -0
- package/dist/checkout-engine/web-bot-auth.js +218 -0
- package/dist/cli.js +968 -505
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +807 -175
- package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
- package/dist/skills/pair-visa-agent/SKILL.md +436 -248
- package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
- package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
- package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
- package/dist/skills/visa-ucp-shopping/SKILL.md +84 -0
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +9 -46
- package/install.sh +5 -37
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +32 -28
- package/server.json +4 -4
- 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,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
|
|
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 { assertShopifyGuestCheckout, ensureShopifyGuestCheckout, 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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
?
|
|
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
|
}
|
|
@@ -501,7 +499,10 @@ function recordTransactionFacts(evidence, phase, facts) {
|
|
|
501
499
|
currency: facts.currency,
|
|
502
500
|
});
|
|
503
501
|
}
|
|
504
|
-
function reviewChangeReason(review, merchantHost, facts) {
|
|
502
|
+
function reviewChangeReason(review, merchantHost, facts, merchantOrigin) {
|
|
503
|
+
if (review.merchantOrigin && merchantOrigin !== review.merchantOrigin) {
|
|
504
|
+
return `merchant origin changed after review: ${review.merchantOrigin} -> ${merchantOrigin ?? 'invalid'}`;
|
|
505
|
+
}
|
|
505
506
|
if (merchantHost !== review.merchantHost) {
|
|
506
507
|
return `merchant changed after review: ${review.merchantHost} -> ${merchantHost}`;
|
|
507
508
|
}
|
|
@@ -513,6 +514,40 @@ function reviewChangeReason(review, merchantHost, facts) {
|
|
|
513
514
|
}
|
|
514
515
|
return null;
|
|
515
516
|
}
|
|
517
|
+
function exactOrigin(value) {
|
|
518
|
+
try {
|
|
519
|
+
const url = new URL(value);
|
|
520
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
521
|
+
return null;
|
|
522
|
+
return url.origin.toLowerCase();
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
export function trustedMerchantOriginRefusal(options, pageUrl, expectedOrigin) {
|
|
529
|
+
const identity = options.trustedMerchantIdentity;
|
|
530
|
+
if (!identity)
|
|
531
|
+
return null;
|
|
532
|
+
if (Date.parse(identity.expiresAt) <= Date.now())
|
|
533
|
+
return 'trusted UCP checkout handoff expired';
|
|
534
|
+
const origin = exactOrigin(pageUrl);
|
|
535
|
+
if (!origin)
|
|
536
|
+
return 'trusted UCP checkout reached a non-HTTPS or credentialed origin';
|
|
537
|
+
if (expectedOrigin) {
|
|
538
|
+
return origin === expectedOrigin
|
|
539
|
+
? null
|
|
540
|
+
: `merchant origin changed after review: ${expectedOrigin} -> ${origin}`;
|
|
541
|
+
}
|
|
542
|
+
return identity.allowedOrigins.includes(origin)
|
|
543
|
+
? null
|
|
544
|
+
: `trusted UCP checkout reached undeclared origin ${origin}`;
|
|
545
|
+
}
|
|
546
|
+
function mandateForPage(options, pageUrl) {
|
|
547
|
+
if (!options.trustedMerchantIdentity)
|
|
548
|
+
return options.mandate;
|
|
549
|
+
return { ...options.mandate, merchantHost: new URL(pageUrl).hostname };
|
|
550
|
+
}
|
|
516
551
|
function submitTargetChangeReason(review, current) {
|
|
517
552
|
const reviewed = review.submitTargetFingerprint;
|
|
518
553
|
if (!reviewed && !current)
|
|
@@ -729,9 +764,31 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
729
764
|
const options = {
|
|
730
765
|
...opts,
|
|
731
766
|
mandate: { ...opts.mandate },
|
|
767
|
+
...(opts.trustedMerchantIdentity
|
|
768
|
+
? {
|
|
769
|
+
trustedMerchantIdentity: Object.freeze({
|
|
770
|
+
...opts.trustedMerchantIdentity,
|
|
771
|
+
allowedOrigins: Object.freeze([...opts.trustedMerchantIdentity.allowedOrigins]),
|
|
772
|
+
}),
|
|
773
|
+
}
|
|
774
|
+
: {}),
|
|
732
775
|
};
|
|
733
776
|
const evidence = new EvidenceLog();
|
|
734
|
-
|
|
777
|
+
// Pin an English locale: amount reconciliation reads the order summary by
|
|
778
|
+
// its visible labels (Subtotal/Taxes/Total), and merchants localize by
|
|
779
|
+
// Accept-Language (observed live 2026-08-16: a Shopify checkout redirected
|
|
780
|
+
// to /es-us and rendered "Impuestos estimados", so the total never parsed
|
|
781
|
+
// and the review refused fail-closed on a perfectly good checkout).
|
|
782
|
+
// Present Web Bot Auth (RFC 9421) credentials when, and only when, an
|
|
783
|
+
// operator directory is configured to resolve them. Off by default: an
|
|
784
|
+
// unresolvable signature fails verification and is worse than none. The
|
|
785
|
+
// signature covers @authority, so it is bound to the checkout host — a
|
|
786
|
+
// cross-origin redirect simply arrives unverified, never wrongly verified.
|
|
787
|
+
const webBotAuthHeaders = webBotAuthHeadersOrNone(options.webBotAuth ?? null, options.url, Date.now() / 1000);
|
|
788
|
+
const context = await options.browser.newContext({
|
|
789
|
+
locale: 'en-US',
|
|
790
|
+
...(webBotAuthHeaders ? { extraHTTPHeaders: webBotAuthHeaders } : {}),
|
|
791
|
+
});
|
|
735
792
|
// Bound every action so a mis-detected or hidden element fails fast instead
|
|
736
793
|
// of stalling on Playwright's long default timeout.
|
|
737
794
|
context.setDefaultTimeout(6000);
|
|
@@ -745,12 +802,30 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
745
802
|
let fields = {};
|
|
746
803
|
let keepOpen = false;
|
|
747
804
|
try {
|
|
748
|
-
evidence.step('navigation', {
|
|
805
|
+
evidence.step('navigation', {
|
|
806
|
+
url: options.trustedMerchantIdentity ? exactOrigin(options.url) : options.url,
|
|
807
|
+
});
|
|
749
808
|
await page.goto(options.url, { waitUntil: 'domcontentloaded' });
|
|
750
809
|
await waitForStableDom(page);
|
|
751
|
-
evidence.step('dom-stable', {
|
|
752
|
-
|
|
753
|
-
|
|
810
|
+
evidence.step('dom-stable', {
|
|
811
|
+
url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
|
|
812
|
+
});
|
|
813
|
+
let merchantHost = new URL(page.url()).hostname;
|
|
814
|
+
const initialOriginRefusal = trustedMerchantOriginRefusal(options, page.url());
|
|
815
|
+
if (initialOriginRefusal) {
|
|
816
|
+
evidence.step('mandate-verdict', {
|
|
817
|
+
phase: 'trusted-origin',
|
|
818
|
+
ok: false,
|
|
819
|
+
reason: initialOriginRefusal,
|
|
820
|
+
});
|
|
821
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
822
|
+
return {
|
|
823
|
+
status: 'finished',
|
|
824
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, initialOriginRefusal),
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
let reviewedOrigin;
|
|
828
|
+
const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
|
|
754
829
|
merchantHost,
|
|
755
830
|
currency: options.currency ?? null,
|
|
756
831
|
});
|
|
@@ -787,7 +862,116 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
787
862
|
}
|
|
788
863
|
// Detection is read-only here. In particular, no adapter fill and no
|
|
789
864
|
// Instrument.getCredential() call can occur before an explicit approval.
|
|
790
|
-
|
|
865
|
+
let detected = await detectFields(page);
|
|
866
|
+
const shopifyPage = await isShopifyCheckoutPage(page);
|
|
867
|
+
if (shopifyPage) {
|
|
868
|
+
// Same checkout session, English presentation — the amount reader needs
|
|
869
|
+
// the English summary labels (see shopifyEnglishCheckoutUrl).
|
|
870
|
+
const englishUrl = shopifyEnglishCheckoutUrl(page.url());
|
|
871
|
+
if (englishUrl) {
|
|
872
|
+
await page.goto(englishUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
|
|
873
|
+
await waitForStableDom(page);
|
|
874
|
+
if (await isShopifyCheckoutPage(page)) {
|
|
875
|
+
evidence.step('navigation', {
|
|
876
|
+
url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
|
|
877
|
+
reason: 'shopify-locale-normalized',
|
|
878
|
+
});
|
|
879
|
+
detected = await detectFields(page);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
let adapter = selectAdapter(detected, { shopify: shopifyPage });
|
|
884
|
+
if (options.contact && adapter.prepareContact) {
|
|
885
|
+
const recordPrefill = async (contact) => {
|
|
886
|
+
const result = await adapter.prepareContact(page, contact);
|
|
887
|
+
for (const field of result.filled) {
|
|
888
|
+
evidence.step('contact-prefill', {
|
|
889
|
+
role: field.role,
|
|
890
|
+
confidence: field.confidence,
|
|
891
|
+
source: field.source,
|
|
892
|
+
frame: field.frame,
|
|
893
|
+
value: field.value,
|
|
894
|
+
ok: field.ok,
|
|
895
|
+
error: field.error,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return result;
|
|
899
|
+
};
|
|
900
|
+
let preparedContact = await recordPrefill(options.contact);
|
|
901
|
+
if (shopifyPage && options.checkoutRoute === 'guest-card') {
|
|
902
|
+
const guest = await ensureShopifyGuestCheckout(page);
|
|
903
|
+
evidence.step('note', {
|
|
904
|
+
phase: 'contact-prefill',
|
|
905
|
+
checkoutRoute: options.checkoutRoute,
|
|
906
|
+
shopifyGuestStatus: guest.status,
|
|
907
|
+
signal: guest.signal,
|
|
908
|
+
});
|
|
909
|
+
if (guest.status === 'action-required') {
|
|
910
|
+
evidence.step('outcome', {
|
|
911
|
+
outcome: 'action-required',
|
|
912
|
+
signal: guest.signal,
|
|
913
|
+
phase: 'contact-prefill',
|
|
914
|
+
});
|
|
915
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
916
|
+
return {
|
|
917
|
+
status: 'finished',
|
|
918
|
+
result: makeResult('action-required', fields, evidence, requiresAdapter, guest.detail, undefined, 'human-action-required'),
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
if (guest.status === 'transitioned') {
|
|
922
|
+
// Do not write the recognized email a second time: Shopify can open
|
|
923
|
+
// the same modal on every email input event. Only retry contact fill
|
|
924
|
+
// when the takeover interrupted it, and omit email on that retry.
|
|
925
|
+
if (!preparedContact.ok) {
|
|
926
|
+
await settle(page);
|
|
927
|
+
detected = await detectFields(page);
|
|
928
|
+
adapter = selectAdapter(detected, { shopify: true });
|
|
929
|
+
preparedContact = await recordPrefill({ ...options.contact, email: undefined });
|
|
930
|
+
}
|
|
931
|
+
const verifiedGuest = await assertShopifyGuestCheckout(page);
|
|
932
|
+
if (verifiedGuest.status === 'action-required') {
|
|
933
|
+
evidence.step('outcome', {
|
|
934
|
+
outcome: 'action-required',
|
|
935
|
+
signal: verifiedGuest.signal,
|
|
936
|
+
phase: 'contact-prefill-guest-verification',
|
|
937
|
+
});
|
|
938
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
939
|
+
return {
|
|
940
|
+
status: 'finished',
|
|
941
|
+
result: makeResult('action-required', fields, evidence, requiresAdapter, verifiedGuest.detail, undefined, 'human-action-required'),
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (!preparedContact.ok) {
|
|
947
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
948
|
+
return {
|
|
949
|
+
status: 'finished',
|
|
950
|
+
result: makeResult('failed', fields, evidence, requiresAdapter, preparedContact.detail ??
|
|
951
|
+
'Shopify contact prefill did not complete; no payment credential was requested'),
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
await settle(page);
|
|
955
|
+
const prefillHost = new URL(page.url()).hostname;
|
|
956
|
+
const trustedPrefillRefusal = trustedMerchantOriginRefusal(options, page.url());
|
|
957
|
+
if (trustedPrefillRefusal || prefillHost !== merchantHost) {
|
|
958
|
+
const reason = trustedPrefillRefusal ??
|
|
959
|
+
`merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
|
|
960
|
+
evidence.step('mandate-verdict', {
|
|
961
|
+
phase: 'contact-prefill',
|
|
962
|
+
ok: false,
|
|
963
|
+
reason,
|
|
964
|
+
});
|
|
965
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
966
|
+
return {
|
|
967
|
+
status: 'finished',
|
|
968
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reason),
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
detected = await detectFields(page);
|
|
972
|
+
adapter = selectAdapter(detected, { shopify: shopifyPage });
|
|
973
|
+
evidence.step('adapter-selected', { adapter: adapter.name, phase: 'review' });
|
|
974
|
+
}
|
|
791
975
|
fields = detected.fields;
|
|
792
976
|
evidence.step('detect', {
|
|
793
977
|
phase: 'review',
|
|
@@ -801,7 +985,47 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
801
985
|
evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
|
|
802
986
|
}
|
|
803
987
|
}
|
|
804
|
-
|
|
988
|
+
// A checkout the runner cannot put a card INTO is not reviewable. Without a
|
|
989
|
+
// detected card-number field a later pay would fill nothing and dispatch
|
|
990
|
+
// whatever control the review happened to bind (observed live 2026-08-16:
|
|
991
|
+
// a Payhip storefront SEARCH form, a FastSpring "PayPal Checkout" label,
|
|
992
|
+
// and a Shopify discount-form "Submit" all reviewed clean this way — the
|
|
993
|
+
// real card fields sat in unreachable PSP iframes or an unrendered payment
|
|
994
|
+
// section). Every adapter fills from this same detection, so a missing
|
|
995
|
+
// number field here means no pay can ever succeed: refuse while it is
|
|
996
|
+
// still free.
|
|
997
|
+
if (!fields.number) {
|
|
998
|
+
const found = Object.keys(fields);
|
|
999
|
+
const reason = `no card number field detected (roles found: ${found.length ? found.join(', ') : 'none'}) — ` +
|
|
1000
|
+
'the card form is likely inside a PSP iframe or behind a later step, so a credential cannot be entered on this page';
|
|
1001
|
+
evidence.step('detect', { phase: 'review', missingCardNumber: true, reason });
|
|
1002
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1003
|
+
return {
|
|
1004
|
+
status: 'finished',
|
|
1005
|
+
result: makeResult('failed', fields, evidence, requiresAdapter, reason, undefined, 'card-number-field-unavailable'),
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
// Contact/shipping and Shopify locale normalization may legitimately move
|
|
1009
|
+
// between the exact service and business origins authorized by UCP. Freeze
|
|
1010
|
+
// whichever declared origin is actually on-screen only after those
|
|
1011
|
+
// credential-free steps, then require that exact origin for approval,
|
|
1012
|
+
// credential fill, and submit.
|
|
1013
|
+
const reviewOriginRefusal = trustedMerchantOriginRefusal(options, page.url());
|
|
1014
|
+
if (reviewOriginRefusal) {
|
|
1015
|
+
evidence.step('mandate-verdict', {
|
|
1016
|
+
phase: 'review-origin',
|
|
1017
|
+
ok: false,
|
|
1018
|
+
reason: reviewOriginRefusal,
|
|
1019
|
+
});
|
|
1020
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1021
|
+
return {
|
|
1022
|
+
status: 'finished',
|
|
1023
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reviewOriginRefusal),
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
merchantHost = new URL(page.url()).hostname;
|
|
1027
|
+
reviewedOrigin = options.trustedMerchantIdentity ? exactOrigin(page.url()) : undefined;
|
|
1028
|
+
const facts = await readTransactionFacts(page, options, 'review');
|
|
805
1029
|
recordTransactionFacts(evidence, 'review', facts);
|
|
806
1030
|
if (!facts.ok) {
|
|
807
1031
|
evidence.step('mandate-verdict', { phase: 'review', ok: false, reason: facts.reason });
|
|
@@ -811,7 +1035,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
811
1035
|
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, facts.detail),
|
|
812
1036
|
};
|
|
813
1037
|
}
|
|
814
|
-
const verdict = checkMandate(options.
|
|
1038
|
+
const verdict = checkMandate(mandateForPage(options, page.url()), {
|
|
815
1039
|
merchantHost,
|
|
816
1040
|
amountMinor: facts.amountMinor,
|
|
817
1041
|
currency: facts.currency,
|
|
@@ -824,11 +1048,18 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
824
1048
|
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, verdict.reason),
|
|
825
1049
|
};
|
|
826
1050
|
}
|
|
827
|
-
|
|
1051
|
+
// Multi-step pages may expose the final submit control in a hidden section
|
|
1052
|
+
// before a safe Continue reveals it. Fingerprint that exact control for the
|
|
1053
|
+
// human review; the eventual click path still requires it to be usable.
|
|
1054
|
+
const submit = await findSubmit(page, { allowDeferred: true });
|
|
828
1055
|
const review = Object.freeze({
|
|
829
1056
|
id: randomUUID(),
|
|
830
|
-
|
|
1057
|
+
// A UCP continuation may contain a bearer-like path/query. The live page
|
|
1058
|
+
// stays in the process-bound store; serializable review facts expose only
|
|
1059
|
+
// the exact reviewed origin.
|
|
1060
|
+
url: reviewedOrigin ?? page.url(),
|
|
831
1061
|
merchantHost,
|
|
1062
|
+
...(reviewedOrigin ? { merchantOrigin: reviewedOrigin } : {}),
|
|
832
1063
|
amountMinor: facts.amountMinor,
|
|
833
1064
|
currency: facts.currency,
|
|
834
1065
|
mandateMaxAmountMinor: options.mandate.maxAmountMinor,
|
|
@@ -836,6 +1067,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
836
1067
|
submitTarget: submit?.desc ?? null,
|
|
837
1068
|
submitTargetFingerprint: submit ? Object.freeze({ ...submit.fingerprint }) : null,
|
|
838
1069
|
detectedRoles: Object.freeze(Object.keys(fields)),
|
|
1070
|
+
checkoutRoute: options.checkoutRoute,
|
|
839
1071
|
});
|
|
840
1072
|
evidence.step('review', {
|
|
841
1073
|
reviewId: review.id,
|
|
@@ -845,12 +1077,14 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
845
1077
|
submitTarget: review.submitTarget,
|
|
846
1078
|
submitTargetFingerprint: review.submitTargetFingerprint,
|
|
847
1079
|
detectedRoles: review.detectedRoles,
|
|
1080
|
+
checkoutRoute: review.checkoutRoute,
|
|
848
1081
|
});
|
|
849
1082
|
const checkout = Object.freeze({
|
|
850
1083
|
review,
|
|
851
1084
|
fields: Object.freeze({ ...fields }),
|
|
852
1085
|
evidence,
|
|
853
1086
|
requiresAdapter: Object.freeze([...requiresAdapter]),
|
|
1087
|
+
checkoutRoute: options.checkoutRoute,
|
|
854
1088
|
});
|
|
855
1089
|
store.put(review.id, {
|
|
856
1090
|
checkout,
|
|
@@ -902,7 +1136,17 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
902
1136
|
// the credential boundary. A changed checkout requires a fresh review.
|
|
903
1137
|
await waitForStableDom(page);
|
|
904
1138
|
const merchantHost = new URL(page.url()).hostname;
|
|
905
|
-
const
|
|
1139
|
+
const approvalOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
|
|
1140
|
+
if (approvalOriginRefusal) {
|
|
1141
|
+
evidence.step('mandate-verdict', {
|
|
1142
|
+
phase: 'approval-origin',
|
|
1143
|
+
ok: false,
|
|
1144
|
+
reason: approvalOriginRefusal,
|
|
1145
|
+
});
|
|
1146
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1147
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalOriginRefusal);
|
|
1148
|
+
}
|
|
1149
|
+
const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
|
|
906
1150
|
merchantHost,
|
|
907
1151
|
currency: options.currency ?? null,
|
|
908
1152
|
});
|
|
@@ -912,7 +1156,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
912
1156
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
913
1157
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason);
|
|
914
1158
|
}
|
|
915
|
-
const approvedFacts = await readTransactionFacts(page, options);
|
|
1159
|
+
const approvedFacts = await readTransactionFacts(page, options, 'approval');
|
|
916
1160
|
recordTransactionFacts(evidence, 'approval', approvedFacts);
|
|
917
1161
|
if (!approvedFacts.ok) {
|
|
918
1162
|
evidence.step('mandate-verdict', {
|
|
@@ -924,7 +1168,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
924
1168
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
925
1169
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvedFacts.detail);
|
|
926
1170
|
}
|
|
927
|
-
const approvalVerdict = checkMandate(options.
|
|
1171
|
+
const approvalVerdict = checkMandate(mandateForPage(options, page.url()), {
|
|
928
1172
|
merchantHost,
|
|
929
1173
|
amountMinor: approvedFacts.amountMinor,
|
|
930
1174
|
currency: approvedFacts.currency,
|
|
@@ -935,7 +1179,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
935
1179
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
936
1180
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalVerdict.reason);
|
|
937
1181
|
}
|
|
938
|
-
const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts);
|
|
1182
|
+
const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts, exactOrigin(page.url()) ?? undefined);
|
|
939
1183
|
if (changedAtApproval) {
|
|
940
1184
|
evidence.step('approval', {
|
|
941
1185
|
approved: false,
|
|
@@ -945,7 +1189,10 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
945
1189
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
946
1190
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedAtApproval);
|
|
947
1191
|
}
|
|
948
|
-
|
|
1192
|
+
// Revalidate the same future target before credential minting. This may be
|
|
1193
|
+
// hidden on a multi-step checkout; the final lookup after reveal requires
|
|
1194
|
+
// the reviewed control to be visible and usable before any click.
|
|
1195
|
+
const approvalSubmit = await findSubmit(page, { allowDeferred: true });
|
|
949
1196
|
const submitChangedAtApproval = submitTargetChangeReason(checkout.review, approvalSubmit);
|
|
950
1197
|
if (submitChangedAtApproval) {
|
|
951
1198
|
evidence.step('approval', {
|
|
@@ -967,6 +1214,34 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
967
1214
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, reason);
|
|
968
1215
|
}
|
|
969
1216
|
evidence.step('approval', { approved: true, reviewId: checkout.review.id });
|
|
1217
|
+
if (opts.mode === 'dry-run') {
|
|
1218
|
+
evidence.step('credential-skipped', {
|
|
1219
|
+
reason: 'dry-run stops before credential mint or merchant-page disclosure',
|
|
1220
|
+
});
|
|
1221
|
+
evidence.step('submit', { would: true, target: approvalSubmit?.desc ?? 'none found' });
|
|
1222
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1223
|
+
return makeResult('reviewed-dry-run', state.fields, evidence, requiresAdapter, approvalSubmit
|
|
1224
|
+
? `validated the reviewed checkout; would click ${approvalSubmit.desc}`
|
|
1225
|
+
: 'validated the reviewed checkout; no submit control detected');
|
|
1226
|
+
}
|
|
1227
|
+
if (options.checkoutRoute === 'guest-card' && (await isShopifyCheckoutPage(page))) {
|
|
1228
|
+
const guest = await assertShopifyGuestCheckout(page);
|
|
1229
|
+
evidence.step('note', {
|
|
1230
|
+
phase: 'pre-credential',
|
|
1231
|
+
checkoutRoute: options.checkoutRoute,
|
|
1232
|
+
shopifyGuestStatus: guest.status,
|
|
1233
|
+
signal: guest.signal,
|
|
1234
|
+
});
|
|
1235
|
+
if (guest.status === 'action-required') {
|
|
1236
|
+
evidence.step('outcome', {
|
|
1237
|
+
outcome: 'action-required',
|
|
1238
|
+
signal: guest.signal,
|
|
1239
|
+
phase: 'pre-credential',
|
|
1240
|
+
});
|
|
1241
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1242
|
+
return makeResult('action-required', state.fields, evidence, requiresAdapter, guest.detail, undefined, 'human-action-required');
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
970
1245
|
const credential = await opts.instrument.getCredential({
|
|
971
1246
|
merchantHost,
|
|
972
1247
|
amountMinor: approvedFacts.amountMinor,
|
|
@@ -976,17 +1251,23 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
976
1251
|
...(credential.credentialExpiresAt
|
|
977
1252
|
? { credentialExpiresAt: credential.credentialExpiresAt }
|
|
978
1253
|
: {}),
|
|
1254
|
+
...traceHandleFields(credential),
|
|
979
1255
|
});
|
|
980
1256
|
// Reveal + fill loop: fills whatever is present, then reveals the next
|
|
981
1257
|
// surface (card radio / next step) until the card number is filled.
|
|
982
1258
|
const clicked = new Set();
|
|
983
1259
|
let adapterName = null;
|
|
1260
|
+
let adapterFillOk = true;
|
|
1261
|
+
let adapterFillDetail = null;
|
|
984
1262
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
985
1263
|
// A reveal/continue action can navigate between attempts. Never expose
|
|
986
1264
|
// the credential to a host other than the one the human reviewed.
|
|
987
1265
|
const fillHost = new URL(page.url()).hostname;
|
|
988
|
-
|
|
989
|
-
|
|
1266
|
+
const fillOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
|
|
1267
|
+
if (fillOriginRefusal ||
|
|
1268
|
+
(!checkout.review.merchantOrigin && fillHost !== checkout.review.merchantHost)) {
|
|
1269
|
+
const reason = fillOriginRefusal ??
|
|
1270
|
+
`merchant changed after review: ${checkout.review.merchantHost} -> ${fillHost}`;
|
|
990
1271
|
evidence.step('mandate-verdict', {
|
|
991
1272
|
phase: 'approved-submit',
|
|
992
1273
|
ok: false,
|
|
@@ -1009,12 +1290,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1009
1290
|
evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
|
|
1010
1291
|
}
|
|
1011
1292
|
}
|
|
1012
|
-
const adapter = selectAdapter(detected
|
|
1293
|
+
const adapter = selectAdapter(detected, {
|
|
1294
|
+
shopify: await isShopifyCheckoutPage(page),
|
|
1295
|
+
});
|
|
1013
1296
|
if (adapter.name !== adapterName) {
|
|
1014
1297
|
adapterName = adapter.name;
|
|
1015
1298
|
evidence.step('adapter-selected', { adapter: adapter.name });
|
|
1016
1299
|
}
|
|
1017
1300
|
const fill = await adapter.fill(page, detected.fields, credential, opts.contact);
|
|
1301
|
+
adapterFillOk = fill.ok;
|
|
1302
|
+
adapterFillDetail = fill.detail ?? null;
|
|
1018
1303
|
for (const f of fill.filled) {
|
|
1019
1304
|
evidence.step('field-fill', {
|
|
1020
1305
|
role: f.role,
|
|
@@ -1024,6 +1309,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1024
1309
|
value: f.value,
|
|
1025
1310
|
ok: f.ok,
|
|
1026
1311
|
error: f.error,
|
|
1312
|
+
...(f.relocated ? { relocated: true } : {}),
|
|
1027
1313
|
});
|
|
1028
1314
|
}
|
|
1029
1315
|
const numberOk = fill.filled.some((f) => f.role === 'number' && f.ok);
|
|
@@ -1046,9 +1332,30 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1046
1332
|
? []
|
|
1047
1333
|
: ['expiry']),
|
|
1048
1334
|
];
|
|
1335
|
+
// Roles the adapter TRIED to fill and never landed, across every reveal
|
|
1336
|
+
// attempt. A job only exists when the field was detected, was visible, and
|
|
1337
|
+
// we held a value for it (see `add()` in adapters/generic.ts) — so a failure
|
|
1338
|
+
// here is never "the page didn't ask for it". It means the page asked, we
|
|
1339
|
+
// answered, and the element refused.
|
|
1340
|
+
//
|
|
1341
|
+
// Roles that failed on an early attempt and succeeded after a reveal are
|
|
1342
|
+
// excluded: `successfulRoles` spans all four attempts, same as above.
|
|
1343
|
+
const failedFillRoles = [
|
|
1344
|
+
...new Set(evidence
|
|
1345
|
+
.getSteps()
|
|
1346
|
+
.filter((step) => step.type === 'field-fill' && step.data.ok === false)
|
|
1347
|
+
.map((step) => String(step.data.role))),
|
|
1348
|
+
]
|
|
1349
|
+
.filter((role) => !successfulRoles.has(role))
|
|
1350
|
+
.sort();
|
|
1049
1351
|
evidence.step('fill-complete', {
|
|
1050
|
-
|
|
1352
|
+
// "Ready to submit", not "the card fields landed". Before 2026-08-17 this
|
|
1353
|
+
// read only the credential roles, so a whop.com run whose city/state/
|
|
1354
|
+
// postalCode all timed out recorded `ok: true` and clicked Get access on
|
|
1355
|
+
// a form it knew was incomplete.
|
|
1356
|
+
ok: missingCredentialRoles.length === 0 && failedFillRoles.length === 0,
|
|
1051
1357
|
missingCredentialRoles,
|
|
1358
|
+
failedFillRoles,
|
|
1052
1359
|
});
|
|
1053
1360
|
if (options.debugShotsDir) {
|
|
1054
1361
|
await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '2-filled', evidence, state.fields);
|
|
@@ -1056,7 +1363,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1056
1363
|
// Re-run the full gate after fill as well. Contact/shipping fields can
|
|
1057
1364
|
// change the total; any drift from the approved review refuses before a
|
|
1058
1365
|
// submit click and requires the caller to prepare a new review.
|
|
1059
|
-
const submitFacts = await readTransactionFacts(page, options);
|
|
1366
|
+
const submitFacts = await readTransactionFacts(page, options, 'pre-submit');
|
|
1060
1367
|
recordTransactionFacts(evidence, 'pre-submit', submitFacts);
|
|
1061
1368
|
if (!submitFacts.ok) {
|
|
1062
1369
|
evidence.step('mandate-verdict', {
|
|
@@ -1068,7 +1375,17 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1068
1375
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitFacts.detail);
|
|
1069
1376
|
}
|
|
1070
1377
|
const submitMerchantHost = new URL(page.url()).hostname;
|
|
1071
|
-
const
|
|
1378
|
+
const submitOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
|
|
1379
|
+
if (submitOriginRefusal) {
|
|
1380
|
+
evidence.step('mandate-verdict', {
|
|
1381
|
+
phase: 'pre-submit-origin',
|
|
1382
|
+
ok: false,
|
|
1383
|
+
reason: submitOriginRefusal,
|
|
1384
|
+
});
|
|
1385
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1386
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitOriginRefusal);
|
|
1387
|
+
}
|
|
1388
|
+
const verdict = checkMandate(mandateForPage(options, page.url()), {
|
|
1072
1389
|
merchantHost: submitMerchantHost,
|
|
1073
1390
|
amountMinor: submitFacts.amountMinor,
|
|
1074
1391
|
currency: submitFacts.currency,
|
|
@@ -1078,7 +1395,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1078
1395
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1079
1396
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, verdict.reason);
|
|
1080
1397
|
}
|
|
1081
|
-
const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts);
|
|
1398
|
+
const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts, exactOrigin(page.url()) ?? undefined);
|
|
1082
1399
|
if (changedBeforeSubmit) {
|
|
1083
1400
|
evidence.step('mandate-verdict', {
|
|
1084
1401
|
phase: 'pre-submit',
|
|
@@ -1099,29 +1416,89 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1099
1416
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1100
1417
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
|
|
1101
1418
|
}
|
|
1102
|
-
if (
|
|
1103
|
-
evidence.step('submit', { would: true, target: submit?.desc ?? 'none found' });
|
|
1419
|
+
if (!adapterFillOk) {
|
|
1104
1420
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1105
|
-
|
|
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');
|
|
1421
|
+
return makeResult('failed', state.fields, evidence, requiresAdapter, adapterFillDetail ?? `${adapterName ?? 'checkout'} adapter fill incomplete`);
|
|
1112
1422
|
}
|
|
1113
1423
|
if (missingCredentialRoles.length > 0) {
|
|
1114
1424
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1115
1425
|
return makeResult('failed', state.fields, evidence, requiresAdapter, `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
|
|
1116
1426
|
}
|
|
1427
|
+
// STOP BEFORE THE CLICK when any field we tried to fill refused. Submitting
|
|
1428
|
+
// a form we know is incomplete is how a PSP ends up holding a charge we
|
|
1429
|
+
// cannot then confirm or account for: the 2026-08-17 whop.com run filled the
|
|
1430
|
+
// card into Basis Theory iframes, watched city/state/postalCode time out at
|
|
1431
|
+
// 5s each, clicked Get access anyway, and could never observe an outcome.
|
|
1432
|
+
//
|
|
1433
|
+
// This refusal happens BEFORE the submit click, so nothing can be charged by
|
|
1434
|
+
// it — the safe direction, and the reason it is allowed to be strict. A
|
|
1435
|
+
// merchant whose address widget we cannot drive now fails cleanly and
|
|
1436
|
+
// retryably instead of dangerously.
|
|
1437
|
+
if (failedFillRoles.length > 0) {
|
|
1438
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1439
|
+
// Name the CAUSE per field, not just the field. The refusal is the only
|
|
1440
|
+
// artifact that survives to the operator (a v2 receipt carries no evidence
|
|
1441
|
+
// log), and "city, postalCode refused" without a reason means the next
|
|
1442
|
+
// person has to reproduce a live merchant to learn anything. Each cause
|
|
1443
|
+
// points at a different fix — see summarizeFillFailure.
|
|
1444
|
+
//
|
|
1445
|
+
// Last attempt wins: a role that failed differently across reveal passes
|
|
1446
|
+
// is best described by how it failed when we finally gave up on it.
|
|
1447
|
+
const lastFillError = (role) => {
|
|
1448
|
+
const errors = evidence
|
|
1449
|
+
.getSteps()
|
|
1450
|
+
.filter((step) => step.type === 'field-fill' && step.data.ok === false && step.data.role === role)
|
|
1451
|
+
.map((step) => (typeof step.data.error === 'string' ? step.data.error : undefined));
|
|
1452
|
+
return errors[errors.length - 1];
|
|
1453
|
+
};
|
|
1454
|
+
const reasons = failedFillRoles.map((role) => `${role} (${summarizeFillFailure(lastFillError(role))})`);
|
|
1455
|
+
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');
|
|
1456
|
+
}
|
|
1117
1457
|
if (!submit) {
|
|
1118
1458
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1119
1459
|
return makeResult('failed', state.fields, evidence, requiresAdapter, 'no submit control detected');
|
|
1120
1460
|
}
|
|
1461
|
+
// Capture the OTP poll watermark BEFORE the click. The click is what
|
|
1462
|
+
// triggers the merchant's verification email, so the watermark must precede
|
|
1463
|
+
// it — otherwise a fast OTP could arrive before we start looking (the 5s
|
|
1464
|
+
// waitForMessage skew is a second line of defence, but ordering matters).
|
|
1465
|
+
// This is just a timestamp — no PII — so it is safe to record.
|
|
1466
|
+
const otpWatermark = new Date().toISOString();
|
|
1467
|
+
evidence.step('note', { otpWatermarkCaptured: true });
|
|
1121
1468
|
// The suppressed Link lookup must settle before the click or it breaks
|
|
1122
1469
|
// Stripe's submit chain mid-flight (#5879) — see waitForLinkLookupQuiet.
|
|
1123
1470
|
const linkQuiet = await waitForLinkLookupQuiet(page);
|
|
1124
1471
|
evidence.step('note', { linkQuiet });
|
|
1472
|
+
// This is deliberately the last await before the irreversible click.
|
|
1473
|
+
// Mint-time validation is not enough: reveal/fill and Link suppression can
|
|
1474
|
+
// consume a short-lived DAVV. Refuse malformed or <60s credentials so an
|
|
1475
|
+
// expiry decline cannot masquerade as a form-fill failure.
|
|
1476
|
+
const credentialExpiresAt = credential.credentialExpiresAt;
|
|
1477
|
+
const expiryMissing = opts.instrument.kind === 'agentic-token' && credentialExpiresAt === undefined;
|
|
1478
|
+
const expiresMs = credentialExpiresAt === undefined ? Number.NaN : Date.parse(credentialExpiresAt);
|
|
1479
|
+
if (expiryMissing ||
|
|
1480
|
+
(credentialExpiresAt !== undefined &&
|
|
1481
|
+
(!Number.isFinite(expiresMs) || expiresMs - Date.now() < 60_000))) {
|
|
1482
|
+
evidence.step('credential-expiry-check', {
|
|
1483
|
+
ok: false,
|
|
1484
|
+
reason: expiryMissing ? 'missing' : 'invalid-or-expiring',
|
|
1485
|
+
...(credentialExpiresAt !== undefined ? { credentialExpiresAt } : {}),
|
|
1486
|
+
});
|
|
1487
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1488
|
+
return makeResult('failed', state.fields, evidence, requiresAdapter, 'credential expired before submit; obtain a fresh intent and re-review');
|
|
1489
|
+
}
|
|
1490
|
+
if (credentialExpiresAt !== undefined) {
|
|
1491
|
+
evidence.step('credential-expiry-check', {
|
|
1492
|
+
ok: true,
|
|
1493
|
+
credentialExpiresAt,
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
// ORDER IS LOAD-BEARING: record the click BEFORE performing it. The catch
|
|
1497
|
+
// block classifies a throw by whether this step exists — recorded means
|
|
1498
|
+
// "we may have charged" (`unverified`), absent means "retry is safe"
|
|
1499
|
+
// (`failed`). Recording after `submit.click()` would let a throw raised by
|
|
1500
|
+
// the click itself look retry-safe, which is the double-charge direction.
|
|
1501
|
+
// Pinned by "a throw AFTER the pay control was clicked reports unverified".
|
|
1125
1502
|
evidence.step('submit', { clicked: true, target: submit.desc });
|
|
1126
1503
|
await submit.click();
|
|
1127
1504
|
await settle(page);
|
|
@@ -1142,6 +1519,57 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1142
1519
|
});
|
|
1143
1520
|
observed = reconcileHeldOutcome(observed, held);
|
|
1144
1521
|
}
|
|
1522
|
+
// Agent-resolvable email OTP subroutine (SINGLE-USE). Fires only on a
|
|
1523
|
+
// 'verification-required' verdict (the merchant emailed a code to the
|
|
1524
|
+
// agent's own inbox) AND when a resolver is injected. Fills the code EXACTLY
|
|
1525
|
+
// ONCE, re-submits, and re-observes. Merchants invalidate a code on first
|
|
1526
|
+
// use, so a stale code is NEVER retried. On no resolver / timeout / missing
|
|
1527
|
+
// code field it falls through to the action-required (human) path below —
|
|
1528
|
+
// it never hangs and never re-fills credential material.
|
|
1529
|
+
if (observed.status === 'verification-required' && opts.resolveEmailOtp) {
|
|
1530
|
+
const otpDetect = await detectFields(page);
|
|
1531
|
+
const codeField = otpDetect.fields.oneTimeCode;
|
|
1532
|
+
if (!codeField) {
|
|
1533
|
+
evidence.step('note', { emailOtp: 'no one-time-code field detected' });
|
|
1534
|
+
}
|
|
1535
|
+
else {
|
|
1536
|
+
const resolution = await opts.resolveEmailOtp({
|
|
1537
|
+
after: otpWatermark,
|
|
1538
|
+
merchantHost: submitMerchantHost,
|
|
1539
|
+
});
|
|
1540
|
+
if (!resolution) {
|
|
1541
|
+
// Fail CLEAN: no code retrieved before the resolver's timeout.
|
|
1542
|
+
evidence.step('note', { emailOtp: 'not retrieved before timeout' });
|
|
1543
|
+
}
|
|
1544
|
+
else {
|
|
1545
|
+
// Fill once. maskOtp() ensures the code NEVER enters the evidence log
|
|
1546
|
+
// (receipt.ts's PAN backstop does not catch a 4-8 digit OTP). The
|
|
1547
|
+
// sender domain is a non-PII trust signal, safe to record.
|
|
1548
|
+
await page.locator(codeField.locator).fill(resolution.code);
|
|
1549
|
+
evidence.step('field-fill', {
|
|
1550
|
+
role: 'oneTimeCode',
|
|
1551
|
+
confidence: codeField.confidence,
|
|
1552
|
+
source: codeField.source,
|
|
1553
|
+
frame: codeField.frame,
|
|
1554
|
+
value: maskOtp(),
|
|
1555
|
+
ok: true,
|
|
1556
|
+
fromDomain: resolution.fromDomain,
|
|
1557
|
+
});
|
|
1558
|
+
const otpSubmit = await findSubmit(page);
|
|
1559
|
+
if (!otpSubmit) {
|
|
1560
|
+
evidence.step('note', { emailOtp: 'code filled but no submit control found' });
|
|
1561
|
+
}
|
|
1562
|
+
else {
|
|
1563
|
+
const otpLinkQuiet = await waitForLinkLookupQuiet(page);
|
|
1564
|
+
evidence.step('note', { linkQuiet: otpLinkQuiet, phase: 'post-otp' });
|
|
1565
|
+
evidence.step('submit', { clicked: true, target: otpSubmit.desc, phase: 'post-otp' });
|
|
1566
|
+
await otpSubmit.click();
|
|
1567
|
+
await settle(page);
|
|
1568
|
+
observed = await observeOutcome(page, { deadlineMs: opts.outcomeDeadlineMs });
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1145
1573
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1146
1574
|
if (options.debugShotsDir) {
|
|
1147
1575
|
await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '3-outcome', evidence, state.fields);
|
|
@@ -1167,6 +1595,20 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1167
1595
|
});
|
|
1168
1596
|
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
1597
|
}
|
|
1598
|
+
if (observed.status === 'verification-required') {
|
|
1599
|
+
// Still needing an email code after the subroutine (no resolver injected,
|
|
1600
|
+
// the code never arrived, or no code field) — hand off to a human. Mapped
|
|
1601
|
+
// to the same action-required outcome; the code is single-use so we never
|
|
1602
|
+
// retry here.
|
|
1603
|
+
evidence.step('outcome', {
|
|
1604
|
+
outcome: 'action-required',
|
|
1605
|
+
signal: observed.signal,
|
|
1606
|
+
reason: 'email verification code required but not auto-resolved',
|
|
1607
|
+
attempts: observed.attempts,
|
|
1608
|
+
elapsedMs: observed.elapsedMs,
|
|
1609
|
+
});
|
|
1610
|
+
return makeResult('action-required', state.fields, evidence, requiresAdapter, `email verification required (${observed.signal}) — a human must enter the code sent to the inbox`);
|
|
1611
|
+
}
|
|
1170
1612
|
if (observed.status === 'confirmed') {
|
|
1171
1613
|
const confirmationRef = await readConfirmationRef(page);
|
|
1172
1614
|
evidence.step('outcome', {
|
|
@@ -1178,19 +1620,35 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1178
1620
|
});
|
|
1179
1621
|
return makeResult('confirmed', state.fields, evidence, requiresAdapter, undefined, confirmationRef);
|
|
1180
1622
|
}
|
|
1623
|
+
// The pay control was clicked and the observer reached its deadline with no
|
|
1624
|
+
// definitive answer. This is NOT a failure — it is the absence of an answer,
|
|
1625
|
+
// and the charge may well have captured. Reporting it as `failed` is what
|
|
1626
|
+
// let a caller re-run the 2026-08-17 whop.com purchase and draw a second $5.
|
|
1181
1627
|
evidence.step('outcome', {
|
|
1182
|
-
outcome: '
|
|
1628
|
+
outcome: 'unverified',
|
|
1183
1629
|
reason: 'no confirmation or decline signal',
|
|
1184
1630
|
lastSeen: observed.lastSeen,
|
|
1185
1631
|
attempts: observed.attempts,
|
|
1186
1632
|
elapsedMs: observed.elapsedMs,
|
|
1187
1633
|
});
|
|
1188
|
-
return makeResult('
|
|
1189
|
-
? 'submitted but outcome unknown (page still processing at deadline)'
|
|
1190
|
-
: 'submitted but outcome unknown');
|
|
1634
|
+
return makeResult('unverified', state.fields, evidence, requiresAdapter, observed.lastSeen === 'processing'
|
|
1635
|
+
? 'submitted but outcome unknown (page still processing at deadline) — the charge may have gone through; verify with the merchant before any retry'
|
|
1636
|
+
: 'submitted but outcome unknown — the charge may have gone through; verify with the merchant before any retry');
|
|
1191
1637
|
}
|
|
1192
1638
|
catch (err) {
|
|
1193
1639
|
const detail = err.message;
|
|
1640
|
+
// A throw AFTER the pay control was clicked (browser teardown, navigation
|
|
1641
|
+
// race, evidence I/O) leaves the same open question as the deadline path: we
|
|
1642
|
+
// clicked, and we do not know what happened. It must not report `failed`
|
|
1643
|
+
// either. A throw before the click never disclosed a payable form, so it
|
|
1644
|
+
// stays a clean, retry-safe failure.
|
|
1645
|
+
const submitted = evidence
|
|
1646
|
+
.getSteps()
|
|
1647
|
+
.some((step) => step.type === 'submit' && step.data.clicked === true);
|
|
1648
|
+
if (submitted) {
|
|
1649
|
+
evidence.step('outcome', { outcome: 'unverified', error: detail });
|
|
1650
|
+
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`);
|
|
1651
|
+
}
|
|
1194
1652
|
evidence.step('outcome', { outcome: 'failed', error: detail });
|
|
1195
1653
|
return makeResult('failed', state.fields, evidence, requiresAdapter, detail);
|
|
1196
1654
|
}
|
|
@@ -1219,8 +1677,8 @@ export async function cancelPreparedCheckout(reviewId, detail = 'checkout cancel
|
|
|
1219
1677
|
// approves that exact review ID. Human-in-the-loop callers should use the
|
|
1220
1678
|
// explicit prepareCheckout()/submitApprovedCheckout() pair instead.
|
|
1221
1679
|
export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
|
|
1222
|
-
const { instrument, contact, mode, outcomeDeadlineMs, ...prepareOptions } = opts;
|
|
1223
|
-
const preparation = await prepareCheckout(prepareOptions, store);
|
|
1680
|
+
const { instrument, contact, mode, outcomeDeadlineMs, resolveEmailOtp, ...prepareOptions } = opts;
|
|
1681
|
+
const preparation = await prepareCheckout({ ...prepareOptions, contact }, store);
|
|
1224
1682
|
if (preparation.status === 'finished')
|
|
1225
1683
|
return preparation.result;
|
|
1226
1684
|
return submitApprovedCheckout(preparation.checkout.review.id, {
|
|
@@ -1229,5 +1687,6 @@ export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
|
|
|
1229
1687
|
contact,
|
|
1230
1688
|
mode,
|
|
1231
1689
|
outcomeDeadlineMs,
|
|
1690
|
+
...(resolveEmailOtp ? { resolveEmailOtp } : {}),
|
|
1232
1691
|
}, store);
|
|
1233
1692
|
}
|