@visa/cli 4.1.0-rc.154 → 4.1.0-rc.156
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/checkout-engine/adapters/generic.d.ts +42 -0
- package/dist/checkout-engine/adapters/generic.js +163 -4
- package/dist/checkout-engine/adapters/shopify.d.ts +25 -1
- package/dist/checkout-engine/adapters/shopify.js +103 -12
- package/dist/checkout-engine/cli-engine.d.ts +4 -0
- package/dist/checkout-engine/cli-engine.js +6 -3
- package/dist/checkout-engine/confirmed-merchants.js +51 -22
- package/dist/checkout-engine/executor.d.ts +2 -0
- package/dist/checkout-engine/executor.js +50 -2
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +13 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +22 -0
- package/dist/checkout-engine/receipt.d.ts +42 -2
- package/dist/checkout-engine/receipt.js +30 -14
- 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 +359 -355
- package/dist/mcp-server/index.js +274 -272
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -10,12 +10,54 @@ export interface CheckoutAdapter {
|
|
|
10
10
|
}
|
|
11
11
|
export declare function resolveLocator(page: Page, entry: FieldEntry): Locator;
|
|
12
12
|
export declare function scrubFillErrorMessage(message: string, value: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* The contact record and the page rarely agree on name shape: the record may
|
|
15
|
+
* carry fullName while the page wants first/last inputs, or vice versa. Derive
|
|
16
|
+
* the missing shape so either page can be filled from either record.
|
|
17
|
+
*/
|
|
18
|
+
export declare function contactNameShapes(contact: Contact, cardholderName?: string): {
|
|
19
|
+
fullName?: string;
|
|
20
|
+
first?: string;
|
|
21
|
+
last?: string;
|
|
22
|
+
};
|
|
13
23
|
export declare function fillContactFieldMap(page: Page, fields: FieldMap, contact: Contact, opts?: {
|
|
14
24
|
fillTimeoutMs?: number;
|
|
15
25
|
}): Promise<FilledField[]>;
|
|
16
26
|
export declare function fillFieldMap(page: Page, fields: FieldMap, credential: CardCredential, contact: Contact, opts?: {
|
|
17
27
|
fillTimeoutMs?: number;
|
|
18
28
|
}): Promise<FilledField[]>;
|
|
29
|
+
/**
|
|
30
|
+
* Re-detect and adopt fresh entries for every card field after the panel is
|
|
31
|
+
* unfolded. Injected for tests; the executor's own detector is used in
|
|
32
|
+
* production.
|
|
33
|
+
*/
|
|
34
|
+
export declare function refreshCardGroupFromPage(page: Page, fields: FieldMap, detect?: (page: Page) => Promise<DetectResult>): Promise<string[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Reveal card fields that a checkout keeps collapsed until a payment method is
|
|
37
|
+
* chosen.
|
|
38
|
+
*
|
|
39
|
+
* `fillFields` skips any entry with `visible === false`, so a card-number input
|
|
40
|
+
* sitting inside a folded panel is never even attempted — the generic adapter
|
|
41
|
+
* then reports `ok: false` ("fill incomplete") without having typed anything.
|
|
42
|
+
* That is the correct default: filling an invisible input is how a credential
|
|
43
|
+
* gets typed into the wrong place. But a payment-method `<select>` guarding the
|
|
44
|
+
* card panel is common enough to be worth handling, and the recovery is a
|
|
45
|
+
* single deterministic interaction rather than a guess.
|
|
46
|
+
*
|
|
47
|
+
* We only ever SELECT a card option — never a wallet, bank transfer, or
|
|
48
|
+
* anything else — and we only act when the card field is already detected but
|
|
49
|
+
* hidden. If nothing changes, the caller proceeds exactly as before and still
|
|
50
|
+
* fails closed.
|
|
51
|
+
*
|
|
52
|
+
* Mutates `fields.number.visible` on success so the subsequent fill attempts
|
|
53
|
+
* the field it just revealed.
|
|
54
|
+
*/
|
|
55
|
+
export declare function revealCollapsedCardSection(page: Page, fields: FieldMap, opts?: {
|
|
56
|
+
timeoutMs?: number;
|
|
57
|
+
}): Promise<{
|
|
58
|
+
revealed: boolean;
|
|
59
|
+
via: string | null;
|
|
60
|
+
}>;
|
|
19
61
|
export declare class GenericAdapter implements CheckoutAdapter {
|
|
20
62
|
name: string;
|
|
21
63
|
matches(_detected: DetectResult): boolean;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// <select> dropdowns, handles split vs combined expiry, two- vs four-digit
|
|
3
3
|
// years, and split first/last name. It is the fallback that should beat any
|
|
4
4
|
// well-behaved guest checkout on its own.
|
|
5
|
+
import { detectFields } from '../detect.js';
|
|
5
6
|
import { maskCvc, maskExpiry, maskPan, redactContact } from '../evidence.js';
|
|
6
7
|
function pad2(n) {
|
|
7
8
|
return String(n).padStart(2, '0');
|
|
@@ -97,12 +98,24 @@ async function fillOne(page, role, entry, value, displayValue, fillTimeoutMs) {
|
|
|
97
98
|
return { ...base, ok: false, error: scrubFillErrorMessage(err.message, value) };
|
|
98
99
|
}
|
|
99
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* The contact record and the page rarely agree on name shape: the record may
|
|
103
|
+
* carry fullName while the page wants first/last inputs, or vice versa. Derive
|
|
104
|
+
* the missing shape so either page can be filled from either record.
|
|
105
|
+
*/
|
|
106
|
+
export function contactNameShapes(contact, cardholderName) {
|
|
107
|
+
const fullName = contact.fullName ??
|
|
108
|
+
(contact.firstName && contact.lastName
|
|
109
|
+
? `${contact.firstName} ${contact.lastName}`
|
|
110
|
+
: cardholderName);
|
|
111
|
+
const first = contact.firstName ?? (fullName?.split(/\s+/)[0] || undefined);
|
|
112
|
+
const last = contact.lastName ?? (fullName?.split(/\s+/).slice(1).join(' ') || undefined);
|
|
113
|
+
return { fullName, first, last };
|
|
114
|
+
}
|
|
100
115
|
async function fillFields(page, fields, credential, contact, opts = {}) {
|
|
101
116
|
const fillTimeoutMs = opts.fillTimeoutMs ?? DEFAULT_FILL_TIMEOUT_MS;
|
|
102
117
|
const filled = [];
|
|
103
|
-
const fullName = contact
|
|
104
|
-
const first = contact.firstName ?? fullName?.split(/\s+/)[0];
|
|
105
|
-
const last = contact.lastName ?? fullName?.split(/\s+/).slice(1).join(' ');
|
|
118
|
+
const { fullName, first, last } = contactNameShapes(contact, credential?.cardholderName);
|
|
106
119
|
// Order matters a little: contact/name before card is harmless, but we fill
|
|
107
120
|
// card fields explicitly per role so order is not load-bearing.
|
|
108
121
|
const jobs = [];
|
|
@@ -200,6 +213,145 @@ export async function fillContactFieldMap(page, fields, contact, opts = {}) {
|
|
|
200
213
|
export async function fillFieldMap(page, fields, credential, contact, opts = {}) {
|
|
201
214
|
return fillFields(page, fields, credential, contact, opts);
|
|
202
215
|
}
|
|
216
|
+
/** Option text that identifies a card-paying choice, most specific first. */
|
|
217
|
+
const CARD_OPTION_PATTERNS = [
|
|
218
|
+
/^\s*visa\s*$/i,
|
|
219
|
+
/credit\s*card|card\s*payment/i,
|
|
220
|
+
/^\s*(mastercard|master\s*card)\s*$/i,
|
|
221
|
+
/\bcard\b/i,
|
|
222
|
+
];
|
|
223
|
+
/**
|
|
224
|
+
* Attribute selectors for a card-number input that survive a panel re-render,
|
|
225
|
+
* tried in order. The originally detected locator is tried first so a page that
|
|
226
|
+
* does NOT re-render keeps its higher-confidence match.
|
|
227
|
+
*/
|
|
228
|
+
const CARD_NUMBER_FALLBACK_SELECTORS = [
|
|
229
|
+
'input[autocomplete="cc-number"]',
|
|
230
|
+
'input[name*="creditcardnumber" i]',
|
|
231
|
+
'input[name*="cardnumber" i]',
|
|
232
|
+
'input[id*="cardnumber" i]',
|
|
233
|
+
'input[name*="cc-number" i]',
|
|
234
|
+
];
|
|
235
|
+
/** First selector that resolves to a visible input, or null if none do. */
|
|
236
|
+
async function firstVisibleCardNumberLocator(page, detectedLocator, timeoutMs) {
|
|
237
|
+
for (const selector of [detectedLocator, ...CARD_NUMBER_FALLBACK_SELECTORS]) {
|
|
238
|
+
try {
|
|
239
|
+
await page
|
|
240
|
+
.locator(selector)
|
|
241
|
+
.first()
|
|
242
|
+
.waitFor({ state: 'visible', timeout: Math.max(500, Math.floor(timeoutMs / 3)) });
|
|
243
|
+
return selector;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
/** Card-credential roles that share the panel a payment select unfolds. */
|
|
252
|
+
const CARD_GROUP_ROLES = ['number', 'cvc', 'expCombined', 'expMonth', 'expYear'];
|
|
253
|
+
/**
|
|
254
|
+
* Re-detect and adopt fresh entries for every card field after the panel is
|
|
255
|
+
* unfolded. Injected for tests; the executor's own detector is used in
|
|
256
|
+
* production.
|
|
257
|
+
*/
|
|
258
|
+
export async function refreshCardGroupFromPage(page, fields, detect = detectFields) {
|
|
259
|
+
let fresh;
|
|
260
|
+
try {
|
|
261
|
+
fresh = (await detect(page)).fields;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
const adopted = [];
|
|
267
|
+
for (const role of CARD_GROUP_ROLES) {
|
|
268
|
+
const next = fresh[role];
|
|
269
|
+
if (!next || next.visible === false)
|
|
270
|
+
continue;
|
|
271
|
+
const current = fields[role];
|
|
272
|
+
// Only ever replace an entry we could not have filled anyway. A field that
|
|
273
|
+
// is already visible was detected against the live DOM and keeps its
|
|
274
|
+
// higher-confidence match.
|
|
275
|
+
if (current && current.visible !== false)
|
|
276
|
+
continue;
|
|
277
|
+
fields[role] = next;
|
|
278
|
+
adopted.push(role);
|
|
279
|
+
}
|
|
280
|
+
return adopted;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Reveal card fields that a checkout keeps collapsed until a payment method is
|
|
284
|
+
* chosen.
|
|
285
|
+
*
|
|
286
|
+
* `fillFields` skips any entry with `visible === false`, so a card-number input
|
|
287
|
+
* sitting inside a folded panel is never even attempted — the generic adapter
|
|
288
|
+
* then reports `ok: false` ("fill incomplete") without having typed anything.
|
|
289
|
+
* That is the correct default: filling an invisible input is how a credential
|
|
290
|
+
* gets typed into the wrong place. But a payment-method `<select>` guarding the
|
|
291
|
+
* card panel is common enough to be worth handling, and the recovery is a
|
|
292
|
+
* single deterministic interaction rather than a guess.
|
|
293
|
+
*
|
|
294
|
+
* We only ever SELECT a card option — never a wallet, bank transfer, or
|
|
295
|
+
* anything else — and we only act when the card field is already detected but
|
|
296
|
+
* hidden. If nothing changes, the caller proceeds exactly as before and still
|
|
297
|
+
* fails closed.
|
|
298
|
+
*
|
|
299
|
+
* Mutates `fields.number.visible` on success so the subsequent fill attempts
|
|
300
|
+
* the field it just revealed.
|
|
301
|
+
*/
|
|
302
|
+
export async function revealCollapsedCardSection(page, fields, opts = {}) {
|
|
303
|
+
const number = fields.number;
|
|
304
|
+
if (!number || number.visible !== false)
|
|
305
|
+
return { revealed: false, via: null };
|
|
306
|
+
const timeoutMs = opts.timeoutMs ?? 5_000;
|
|
307
|
+
const selects = page.locator('select');
|
|
308
|
+
const count = await selects.count().catch(() => 0);
|
|
309
|
+
for (let i = 0; i < Math.min(count, 12); i++) {
|
|
310
|
+
const select = selects.nth(i);
|
|
311
|
+
// Read option labels through the locator API rather than page.evaluate.
|
|
312
|
+
// A bundled build rewrites the function passed to evaluate() and the
|
|
313
|
+
// injected helper is not defined in page scope, so it throws at runtime —
|
|
314
|
+
// silently, once a catch treats it as "this select didn't match". Staying
|
|
315
|
+
// on the locator API keeps this working in source and bundled alike.
|
|
316
|
+
let labels;
|
|
317
|
+
try {
|
|
318
|
+
labels = await select.locator('option').allTextContents();
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
for (const pattern of CARD_OPTION_PATTERNS) {
|
|
324
|
+
const label = labels.map((l) => l.trim()).find((l) => l && pattern.test(l));
|
|
325
|
+
if (!label)
|
|
326
|
+
continue;
|
|
327
|
+
try {
|
|
328
|
+
await select.selectOption({ label }, { timeout: timeoutMs });
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
// Re-acquire the field instead of waiting on the detected locator.
|
|
334
|
+
// Unfolding the panel typically re-renders it, and the detector's
|
|
335
|
+
// synthetic `data-ca-id` attribute does not survive that — waiting on the
|
|
336
|
+
// old locator times out even though the field is now on screen and
|
|
337
|
+
// fillable. Stable attribute selectors survive the re-render.
|
|
338
|
+
const revealedLocator = await firstVisibleCardNumberLocator(page, number.locator, timeoutMs);
|
|
339
|
+
if (!revealedLocator)
|
|
340
|
+
continue;
|
|
341
|
+
number.locator = revealedLocator;
|
|
342
|
+
number.visible = true;
|
|
343
|
+
// The number is not alone in that panel: cvc and expiry were re-rendered
|
|
344
|
+
// with it and still carry stale, invisible entries. Filling only the
|
|
345
|
+
// number would trade "adapter fill incomplete" for "credential fill
|
|
346
|
+
// incomplete: missing cvc, expiry" — still a failed purchase, still after
|
|
347
|
+
// a credential was minted. Re-detect and adopt fresh entries for the
|
|
348
|
+
// whole card group.
|
|
349
|
+
await refreshCardGroupFromPage(page, fields);
|
|
350
|
+
return { revealed: true, via: label };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { revealed: false, via: null };
|
|
354
|
+
}
|
|
203
355
|
export class GenericAdapter {
|
|
204
356
|
name = 'generic';
|
|
205
357
|
matches(_detected) {
|
|
@@ -207,10 +359,17 @@ export class GenericAdapter {
|
|
|
207
359
|
return true;
|
|
208
360
|
}
|
|
209
361
|
async fill(page, fields, credential, contact) {
|
|
362
|
+
const reveal = await revealCollapsedCardSection(page, fields);
|
|
210
363
|
const filled = await fillFieldMap(page, fields, credential, contact);
|
|
364
|
+
const ok = filled.some((f) => f.role === 'number' && f.ok);
|
|
211
365
|
return {
|
|
212
|
-
ok
|
|
366
|
+
ok,
|
|
213
367
|
filled,
|
|
368
|
+
...(ok || !reveal.revealed
|
|
369
|
+
? {}
|
|
370
|
+
: {
|
|
371
|
+
detail: `revealed the card section via "${reveal.via}" but the number field still did not fill`,
|
|
372
|
+
}),
|
|
214
373
|
};
|
|
215
374
|
}
|
|
216
375
|
}
|
|
@@ -2,7 +2,7 @@ import type { Page } from 'playwright-core';
|
|
|
2
2
|
import { type PageAmountRead } from '../amount.js';
|
|
3
3
|
import { type DetectResult, type FieldMap } from '../detect.js';
|
|
4
4
|
import type { CardCredential } from '../instrument.js';
|
|
5
|
-
import type { Contact, FillResult } from '../types.js';
|
|
5
|
+
import type { Contact, FillResult, FilledField } from '../types.js';
|
|
6
6
|
import { type CheckoutAdapter } from './generic.js';
|
|
7
7
|
type ShopifySummary = {
|
|
8
8
|
subtotalMinor: number | null;
|
|
@@ -20,6 +20,30 @@ export declare function isShopifyCheckoutPage(page: Page): Promise<boolean>;
|
|
|
20
20
|
export declare function detectShopifyChallenge(page: Page): Promise<{
|
|
21
21
|
signal: string;
|
|
22
22
|
} | null>;
|
|
23
|
+
/**
|
|
24
|
+
* The en-US variant of a localized Shopify checkout URL, or null when it is
|
|
25
|
+
* already English (or not locale-suffixed). Shopify renders the checkout in
|
|
26
|
+
* the URL's trailing locale segment, and amount reconciliation reads the
|
|
27
|
+
* order summary by its ENGLISH labels — a store whose primary market is not
|
|
28
|
+
* English serves /checkouts/cn/<token>/<locale> and the total never parses
|
|
29
|
+
* (observed live 2026-08-16: /es-us rendered "Precio total" and the review
|
|
30
|
+
* refused fail-closed on a good checkout). The locale segment is
|
|
31
|
+
* presentation-only: swapping it keeps the same checkout session and token.
|
|
32
|
+
*/
|
|
33
|
+
export declare function shopifyEnglishCheckoutUrl(current: string): string | null;
|
|
34
|
+
export declare function missingContactRoles(filled: FilledField[], expected: string[]): string[];
|
|
35
|
+
/**
|
|
36
|
+
* The contact surface to prefill. Shipping fields when the checkout has them —
|
|
37
|
+
* but a digital-goods (no-shipping) Shopify checkout renders exactly one
|
|
38
|
+
* address block and marks every field autocomplete="billing ..." (observed
|
|
39
|
+
* live 2026-08-16: all 18 candidates billing-classified, so the shipping map
|
|
40
|
+
* came back empty and the prefill reported every role missing). That billing
|
|
41
|
+
* block IS the primary contact surface, under its base roles.
|
|
42
|
+
*/
|
|
43
|
+
export declare function contactPrefillFieldMap(detected: DetectResult): {
|
|
44
|
+
fields: FieldMap;
|
|
45
|
+
surface: 'shipping' | 'billing-only';
|
|
46
|
+
};
|
|
23
47
|
export declare class ShopifyAdapter implements CheckoutAdapter {
|
|
24
48
|
private readonly detected;
|
|
25
49
|
name: string;
|
|
@@ -43,11 +43,23 @@ export function parseShopifySummary(text) {
|
|
|
43
43
|
const discountMinor = parse(uniqueValue('discount', 'discounts')) ?? 0;
|
|
44
44
|
const totalText = uniqueValue('total');
|
|
45
45
|
const totalMinor = parse(totalText);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
// A digital-goods checkout renders no component rows at all — just a total
|
|
47
|
+
// ("Cost summary / Total / USD $6.00", observed live 2026-08-16), so there
|
|
48
|
+
// is nothing to reconcile against and a parsed total stands on its own. The
|
|
49
|
+
// moment ANY component label parsed by SUMMARY_LABELS appears — including a
|
|
50
|
+
// pending "Calculated at next step" — the full reconciliation is required again,
|
|
51
|
+
// so a mid-render pre-tax total still refuses.
|
|
52
|
+
// The discount-code entry form puts a bare "Discount" label on every
|
|
53
|
+
// checkout, value or not, and discount already defaults to 0 — only the
|
|
54
|
+
// additive components force reconciliation.
|
|
55
|
+
const componentLabelPresent = [...values.keys()].some((key) => key !== 'total' && key !== 'discount' && key !== 'discounts');
|
|
56
|
+
const verified = componentLabelPresent
|
|
57
|
+
? subtotalMinor != null &&
|
|
58
|
+
shippingMinor != null &&
|
|
59
|
+
taxMinor != null &&
|
|
60
|
+
totalMinor != null &&
|
|
61
|
+
subtotalMinor + shippingMinor + taxMinor - discountMinor === totalMinor
|
|
62
|
+
: totalMinor != null;
|
|
51
63
|
return {
|
|
52
64
|
subtotalMinor,
|
|
53
65
|
shippingMinor,
|
|
@@ -145,6 +157,30 @@ export async function detectShopifyChallenge(page) {
|
|
|
145
157
|
}
|
|
146
158
|
return null;
|
|
147
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* The en-US variant of a localized Shopify checkout URL, or null when it is
|
|
162
|
+
* already English (or not locale-suffixed). Shopify renders the checkout in
|
|
163
|
+
* the URL's trailing locale segment, and amount reconciliation reads the
|
|
164
|
+
* order summary by its ENGLISH labels — a store whose primary market is not
|
|
165
|
+
* English serves /checkouts/cn/<token>/<locale> and the total never parses
|
|
166
|
+
* (observed live 2026-08-16: /es-us rendered "Precio total" and the review
|
|
167
|
+
* refused fail-closed on a good checkout). The locale segment is
|
|
168
|
+
* presentation-only: swapping it keeps the same checkout session and token.
|
|
169
|
+
*/
|
|
170
|
+
export function shopifyEnglishCheckoutUrl(current) {
|
|
171
|
+
let url;
|
|
172
|
+
try {
|
|
173
|
+
url = new URL(current);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const match = url.pathname.match(/^(\/checkouts\/[^?#]+\/)([a-z]{2,3}(?:-[a-z0-9]+)*)(\/?)$/i);
|
|
179
|
+
if (!match || match[2].toLowerCase().startsWith('en'))
|
|
180
|
+
return null;
|
|
181
|
+
url.pathname = `${match[1]}en-us${match[3]}`;
|
|
182
|
+
return url.toString();
|
|
183
|
+
}
|
|
148
184
|
const BILLING_ROLES = new Set([
|
|
149
185
|
'name',
|
|
150
186
|
'nameFirst',
|
|
@@ -370,10 +406,62 @@ function requiredAddressRoles(address, prefix = '') {
|
|
|
370
406
|
...(address.country ? [role('country')] : []),
|
|
371
407
|
];
|
|
372
408
|
}
|
|
373
|
-
|
|
374
|
-
|
|
409
|
+
/**
|
|
410
|
+
* A checkout renders ONE name shape (a single full-name input, or first/last)
|
|
411
|
+
* while the contact record may carry the other, so the expected role and the
|
|
412
|
+
* filled role can disagree while the page is completely filled. Either shape
|
|
413
|
+
* satisfies the name requirement; same for the billing-prefixed variants.
|
|
414
|
+
*/
|
|
415
|
+
function withNameEquivalence(successful) {
|
|
416
|
+
const out = new Set(successful);
|
|
417
|
+
for (const [full, first, last] of [
|
|
418
|
+
['name', 'nameFirst', 'nameLast'],
|
|
419
|
+
['billingName', 'billingNameFirst', 'billingNameLast'],
|
|
420
|
+
]) {
|
|
421
|
+
if (out.has(first) && out.has(last))
|
|
422
|
+
out.add(full);
|
|
423
|
+
if (out.has(full)) {
|
|
424
|
+
out.add(first);
|
|
425
|
+
out.add(last);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
export function missingContactRoles(filled, expected) {
|
|
431
|
+
const successful = withNameEquivalence(new Set(filled.filter((field) => field.ok).map((field) => field.role)));
|
|
375
432
|
return expected.filter((role) => !successful.has(role));
|
|
376
433
|
}
|
|
434
|
+
function hasAddressRole(fields) {
|
|
435
|
+
return Boolean(fields.addressLine1 ?? fields.city ?? fields.postalCode);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The contact surface to prefill. Shipping fields when the checkout has them —
|
|
439
|
+
* but a digital-goods (no-shipping) Shopify checkout renders exactly one
|
|
440
|
+
* address block and marks every field autocomplete="billing ..." (observed
|
|
441
|
+
* live 2026-08-16: all 18 candidates billing-classified, so the shipping map
|
|
442
|
+
* came back empty and the prefill reported every role missing). That billing
|
|
443
|
+
* block IS the primary contact surface, under its base roles.
|
|
444
|
+
*/
|
|
445
|
+
export function contactPrefillFieldMap(detected) {
|
|
446
|
+
const shipping = shippingFieldMap(detected);
|
|
447
|
+
if (hasAddressRole(shipping))
|
|
448
|
+
return { fields: shipping, surface: 'shipping' };
|
|
449
|
+
const fields = {};
|
|
450
|
+
for (const candidate of detected.candidates) {
|
|
451
|
+
const role = candidate.role;
|
|
452
|
+
if (!role || !CONTACT_ROLES.has(role) || !candidate.meta.visible)
|
|
453
|
+
continue;
|
|
454
|
+
if (isPaymentCandidate(candidate))
|
|
455
|
+
continue;
|
|
456
|
+
const entry = entryFromCandidate(candidate);
|
|
457
|
+
if (!entry)
|
|
458
|
+
continue;
|
|
459
|
+
const existing = fields[role];
|
|
460
|
+
if (!existing || entry.confidence > existing.confidence)
|
|
461
|
+
fields[role] = entry;
|
|
462
|
+
}
|
|
463
|
+
return { fields, surface: 'billing-only' };
|
|
464
|
+
}
|
|
377
465
|
export class ShopifyAdapter {
|
|
378
466
|
detected;
|
|
379
467
|
name = 'shopify';
|
|
@@ -384,15 +472,18 @@ export class ShopifyAdapter {
|
|
|
384
472
|
return detected.candidates.some(isShopifyCandidate);
|
|
385
473
|
}
|
|
386
474
|
async prepareContact(page, contact) {
|
|
387
|
-
const
|
|
475
|
+
const { fields: contactFields, surface } = contactPrefillFieldMap(this.detected);
|
|
476
|
+
const shippingFilled = await fillContactFieldMap(page, contactFields, contact);
|
|
388
477
|
const shippingExpected = [...requiredAddressRoles(contact), ...(contact.phone ? ['phone'] : [])];
|
|
389
|
-
const shippingMissing =
|
|
390
|
-
if (!contact.billingAddress) {
|
|
478
|
+
const shippingMissing = missingContactRoles(shippingFilled, shippingExpected);
|
|
479
|
+
if (surface === 'billing-only' || !contact.billingAddress) {
|
|
391
480
|
return {
|
|
392
481
|
ok: shippingMissing.length === 0,
|
|
393
482
|
filled: shippingFilled,
|
|
394
483
|
...(shippingMissing.length
|
|
395
|
-
? {
|
|
484
|
+
? {
|
|
485
|
+
detail: `Shopify ${surface === 'billing-only' ? 'contact' : 'shipping'} prefill incomplete: missing ${shippingMissing.join(', ')}`,
|
|
486
|
+
}
|
|
396
487
|
: {}),
|
|
397
488
|
};
|
|
398
489
|
}
|
|
@@ -406,7 +497,7 @@ export class ShopifyAdapter {
|
|
|
406
497
|
const billingFields = billingFieldMap(await detectFields(page));
|
|
407
498
|
const billingFilled = labelBillingEvidence(await fillContactFieldMap(page, billingFields, billingContact(contact.billingAddress)));
|
|
408
499
|
const billingExpected = requiredAddressRoles(contact.billingAddress, 'billing');
|
|
409
|
-
const missing = [...shippingMissing, ...
|
|
500
|
+
const missing = [...shippingMissing, ...missingContactRoles(billingFilled, billingExpected)];
|
|
410
501
|
return {
|
|
411
502
|
ok: missing.length === 0,
|
|
412
503
|
filled: [...shippingFilled, ...billingFilled],
|
|
@@ -34,6 +34,10 @@ export type CliReviewInput = {
|
|
|
34
34
|
cardTokenId?: string;
|
|
35
35
|
/** Exact request-key identity selected by the caller. */
|
|
36
36
|
agentJkt?: string;
|
|
37
|
+
/** Owner-facing selected-agent label for the compact local receipt. */
|
|
38
|
+
agentName?: string;
|
|
39
|
+
/** Safe display suffix derived from the selected card grant label. */
|
|
40
|
+
cardLast4?: string;
|
|
37
41
|
contact: Contact;
|
|
38
42
|
approvalBaseUrl: string;
|
|
39
43
|
merchantName?: string;
|
|
@@ -99,6 +99,9 @@ export function classifyCardDrawVerdictFailure(err) {
|
|
|
99
99
|
* grant on every draw. Nothing here authorizes anything.
|
|
100
100
|
*/
|
|
101
101
|
async function resolveCardInstrument(input) {
|
|
102
|
+
if (typeof input.cardTokenId === 'string' && input.cardTokenId.trim()) {
|
|
103
|
+
return { tokenId: input.cardTokenId.trim(), source: 'card-grant' };
|
|
104
|
+
}
|
|
102
105
|
let credential = null;
|
|
103
106
|
let readError = null;
|
|
104
107
|
try {
|
|
@@ -110,9 +113,6 @@ async function resolveCardInstrument(input) {
|
|
|
110
113
|
if (credential && typeof credential.tokenId === 'string' && credential.tokenId.trim()) {
|
|
111
114
|
return credential;
|
|
112
115
|
}
|
|
113
|
-
if (typeof input.cardTokenId === 'string' && input.cardTokenId.trim()) {
|
|
114
|
-
return { tokenId: input.cardTokenId, source: 'card-grant' };
|
|
115
|
-
}
|
|
116
116
|
throw new Error('no card instrument is available to this runtime: there is no usable credential at ' +
|
|
117
117
|
`${input.credentialPath} and no activated card:vic grant token was supplied. Run ` +
|
|
118
118
|
'`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait` to ' +
|
|
@@ -917,6 +917,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
917
917
|
merchant: {
|
|
918
918
|
name: session.target.merchantName,
|
|
919
919
|
host: new URL(session.target.merchantUrl).hostname,
|
|
920
|
+
url: session.target.merchantUrl,
|
|
920
921
|
},
|
|
921
922
|
transaction: {
|
|
922
923
|
amount: session.target.transactionAmount,
|
|
@@ -925,6 +926,8 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
925
926
|
},
|
|
926
927
|
result,
|
|
927
928
|
vicConfirmation,
|
|
929
|
+
agentName: input.agentName,
|
|
930
|
+
cardLast4: input.cardLast4,
|
|
928
931
|
}));
|
|
929
932
|
if (report.written)
|
|
930
933
|
receiptPath = report.path;
|
|
@@ -23,28 +23,36 @@ import { RECEIPT_DIR } from './receipt-dir.js';
|
|
|
23
23
|
/**
|
|
24
24
|
* The URL a receipt's charge happened at.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* A compact v2 receipt carries an exact checkout URL only when it is already in
|
|
27
|
+
* the curated identity map; otherwise it carries the host. Legacy v1 receipts
|
|
28
|
+
* keep the URL in the evidence log (the executor's first 'navigation' step is
|
|
29
|
+
* the page the run opened), so recover it there and fall back to the host when
|
|
30
|
+
* that evidence is absent or shaped differently.
|
|
31
31
|
*/
|
|
32
32
|
function checkoutUrlOf(receipt) {
|
|
33
|
+
if (receipt.schema === 'checkout-agent-receipt/v2') {
|
|
34
|
+
const checkoutUrl = receipt.merchant.checkoutUrl;
|
|
35
|
+
return checkoutUrl && KNOWN_MERCHANT_IDENTITIES[checkoutUrl]
|
|
36
|
+
? checkoutUrl
|
|
37
|
+
: `https://${receipt.merchant.host}/`;
|
|
38
|
+
}
|
|
33
39
|
const navigation = receipt.evidence.steps.find((step) => step?.type === 'navigation');
|
|
34
40
|
const data = navigation?.data;
|
|
35
41
|
const url = typeof data === 'object' && data !== null ? data.url : undefined;
|
|
36
42
|
return typeof url === 'string' && url.length > 0 ? url : `https://${receipt.merchant.host}/`;
|
|
37
43
|
}
|
|
38
44
|
function isConfirmedCompletion(receipt) {
|
|
45
|
+
if (receipt.schema === 'checkout-agent-receipt/v2') {
|
|
46
|
+
return receipt.outcome === 'confirmed' && receipt.network.confirmation === 'APPROVED';
|
|
47
|
+
}
|
|
39
48
|
return (receipt.outcome === 'confirmed' &&
|
|
40
49
|
receipt.vicConfirmation?.posted === true &&
|
|
41
50
|
receipt.vicConfirmation.transactionStatus === 'APPROVED');
|
|
42
51
|
}
|
|
43
52
|
/**
|
|
44
|
-
* Parse one receipt file, or null when
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* fields read below.
|
|
53
|
+
* Parse one supported receipt file, or null when its fields cannot safely feed
|
|
54
|
+
* the registry. Deliberately permissive about everything the grouping does not
|
|
55
|
+
* touch: older and newer engines only have to carry the fields read below.
|
|
48
56
|
*/
|
|
49
57
|
function parseReceipt(json) {
|
|
50
58
|
let parsed;
|
|
@@ -57,19 +65,40 @@ function parseReceipt(json) {
|
|
|
57
65
|
if (typeof parsed !== 'object' || parsed === null)
|
|
58
66
|
return null;
|
|
59
67
|
const receipt = parsed;
|
|
60
|
-
if (receipt.schema
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
if (receipt.schema === 'checkout-agent-receipt/v1') {
|
|
69
|
+
const v1 = receipt;
|
|
70
|
+
if (typeof v1.recordedAt !== 'string')
|
|
71
|
+
return null;
|
|
72
|
+
if (typeof v1.merchant?.host !== 'string')
|
|
73
|
+
return null;
|
|
74
|
+
if (typeof v1.transaction?.amount !== 'string')
|
|
75
|
+
return null;
|
|
76
|
+
if (typeof v1.transaction?.currency !== 'string')
|
|
77
|
+
return null;
|
|
78
|
+
if (!Array.isArray(v1.evidence?.steps))
|
|
79
|
+
return null;
|
|
80
|
+
return v1;
|
|
81
|
+
}
|
|
82
|
+
if (receipt.schema === 'checkout-agent-receipt/v2') {
|
|
83
|
+
const v2 = receipt;
|
|
84
|
+
if (typeof v2.recordedAt !== 'string')
|
|
85
|
+
return null;
|
|
86
|
+
if (typeof v2.merchant?.host !== 'string')
|
|
87
|
+
return null;
|
|
88
|
+
if (v2.merchant.checkoutUrl !== null && typeof v2.merchant.checkoutUrl !== 'string')
|
|
89
|
+
return null;
|
|
90
|
+
if (typeof v2.transaction?.amount !== 'string')
|
|
91
|
+
return null;
|
|
92
|
+
if (typeof v2.transaction?.currency !== 'string')
|
|
93
|
+
return null;
|
|
94
|
+
if (v2.network?.confirmation !== null &&
|
|
95
|
+
v2.network?.confirmation !== 'APPROVED' &&
|
|
96
|
+
v2.network?.confirmation !== 'DECLINED') {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return v2;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
73
102
|
}
|
|
74
103
|
/**
|
|
75
104
|
* Merchants this device has completed a real card checkout at, newest first.
|
|
@@ -5,6 +5,7 @@ import type { Instrument } from './instrument.js';
|
|
|
5
5
|
import type { Contact, OtpResolver } from './types.js';
|
|
6
6
|
import { EvidenceLog } from './evidence.js';
|
|
7
7
|
import { type ObservedOutcome } from './outcome.js';
|
|
8
|
+
import { type WebBotAuthConfig } from './web-bot-auth.js';
|
|
8
9
|
export { minorFromDecimal, pageCurrency } from './amount.js';
|
|
9
10
|
export type CheckoutMode = 'dry-run' | 'submit';
|
|
10
11
|
export type CheckoutOutcome = 'reviewed-dry-run'
|
|
@@ -35,6 +36,7 @@ export type PrepareCheckoutOptions = {
|
|
|
35
36
|
amountMinor?: number;
|
|
36
37
|
currency?: string;
|
|
37
38
|
debugShotsDir?: string;
|
|
39
|
+
webBotAuth?: WebBotAuthConfig | null;
|
|
38
40
|
};
|
|
39
41
|
export type RunCheckoutOptions = PrepareCheckoutOptions & {
|
|
40
42
|
instrument: Instrument;
|