@visa/cli 4.1.0-rc.26 → 4.1.0-rc.260

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +202 -46
  2. package/dist/checkout-engine/adapters/generic.d.ts +69 -0
  3. package/dist/checkout-engine/adapters/generic.js +383 -58
  4. package/dist/checkout-engine/adapters/index.d.ts +4 -1
  5. package/dist/checkout-engine/adapters/index.js +10 -3
  6. package/dist/checkout-engine/adapters/shopify.d.ts +80 -0
  7. package/dist/checkout-engine/adapters/shopify.js +688 -0
  8. package/dist/checkout-engine/amount.d.ts +15 -0
  9. package/dist/checkout-engine/amount.js +72 -0
  10. package/dist/checkout-engine/browser-launch.d.ts +9 -4
  11. package/dist/checkout-engine/browser-launch.js +19 -4
  12. package/dist/checkout-engine/browserbase-browser.d.ts +24 -0
  13. package/dist/checkout-engine/browserbase-browser.js +186 -0
  14. package/dist/checkout-engine/cli-engine.d.ts +206 -32
  15. package/dist/checkout-engine/cli-engine.js +772 -216
  16. package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
  17. package/dist/checkout-engine/confirmed-merchants.js +165 -0
  18. package/dist/checkout-engine/detect.d.ts +1 -1
  19. package/dist/checkout-engine/detect.js +6 -0
  20. package/dist/checkout-engine/evidence.d.ts +1 -1
  21. package/dist/checkout-engine/executor.d.ts +61 -3
  22. package/dist/checkout-engine/executor.js +550 -144
  23. package/dist/checkout-engine/hosted-approval.d.ts +69 -9
  24. package/dist/checkout-engine/hosted-approval.js +211 -21
  25. package/dist/checkout-engine/index.d.ts +6 -2
  26. package/dist/checkout-engine/index.js +5 -1
  27. package/dist/checkout-engine/instrument.d.ts +6 -0
  28. package/dist/checkout-engine/known-merchants.d.ts +10 -0
  29. package/dist/checkout-engine/known-merchants.js +38 -0
  30. package/dist/checkout-engine/live-fill-approval.d.ts +5 -11
  31. package/dist/checkout-engine/live-fill-approval.js +20 -34
  32. package/dist/checkout-engine/mandate/card-mandate.d.ts +6 -2
  33. package/dist/checkout-engine/mandate/card-mandate.js +10 -5
  34. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +63 -23
  35. package/dist/checkout-engine/mandate/mandate-ledger.js +124 -17
  36. package/dist/checkout-engine/receipt-dir.d.ts +6 -0
  37. package/dist/checkout-engine/receipt-dir.js +8 -0
  38. package/dist/checkout-engine/receipt.d.ts +56 -2
  39. package/dist/checkout-engine/receipt.js +55 -16
  40. package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
  41. package/dist/checkout-engine/shopify-primary-domain.js +96 -0
  42. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  43. package/dist/checkout-engine/trace-handles.js +12 -0
  44. package/dist/checkout-engine/types.d.ts +15 -2
  45. package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
  46. package/dist/checkout-engine/unresolved-charges.js +134 -0
  47. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +26 -7
  48. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +43 -3
  49. package/dist/checkout-engine/vgs-live-instrument.d.ts +11 -35
  50. package/dist/checkout-engine/vgs-live-instrument.js +14 -74
  51. package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
  52. package/dist/checkout-engine/vic-confirmation.js +9 -3
  53. package/dist/checkout-engine/web-bot-auth.d.ts +98 -0
  54. package/dist/checkout-engine/web-bot-auth.js +218 -0
  55. package/dist/cli.js +899 -387
  56. package/dist/mcp-apps/ucp-checkout.html +280 -0
  57. package/dist/mcp-server/index.js +725 -254
  58. package/dist/merchant-ucp-mcp/index.js +6 -0
  59. package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
  60. package/dist/skills/pair-visa-agent/SKILL.md +433 -318
  61. package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
  62. package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
  63. package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
  64. package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
  65. package/dist/skills/visa-ucp-shopping/SKILL.md +86 -0
  66. package/dist/subway-direct.mjs +1 -0
  67. package/install.ps1 +7 -6
  68. package/install.sh +3 -3
  69. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  70. package/package.json +31 -28
  71. package/server.json +4 -4
  72. package/dist/checkout-engine/inline-target.d.ts +0 -13
  73. package/dist/checkout-engine/inline-target.js +0 -37
  74. package/dist/checkout-engine/pay-args.d.ts +0 -14
  75. package/dist/checkout-engine/pay-args.js +0 -44
  76. package/dist/checkout-engine/pay.d.ts +0 -1
  77. package/dist/checkout-engine/pay.js +0 -13
  78. package/dist/checkout-engine/repo-env.d.ts +0 -11
  79. package/dist/checkout-engine/repo-env.js +0 -23
  80. package/dist/checkout-engine/run-live-fill.d.ts +0 -1
  81. package/dist/checkout-engine/run-live-fill.js +0 -493
  82. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
  83. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
@@ -0,0 +1,688 @@
1
+ // Shopify checkout adapter. Shopify commonly renders shipping first and keeps
2
+ // a distinct billing address collapsed behind a radio/checkbox. Detection runs
3
+ // once before adapter selection, so this adapter reuses the detected candidate
4
+ // locators, reveals the billing surface, and fills it without a second scan.
5
+ import { minorFromDecimal, pageCurrency } from '../amount.js';
6
+ import { detectFields, } from '../detect.js';
7
+ import { fillContactFieldMap, fillFieldMap } from './generic.js';
8
+ const SUMMARY_LABELS = /^(Subtotal|Shipping|Estimated taxes|Taxes|Tax|Discounts?|Total)(?:\s*:?\s+((?:[A-Z]{3}\s+)?-?[$€£]?\s*\d[\d.,]*|Free))?$/i;
9
+ function valueAfterLabel(lines, index, inline) {
10
+ if (inline)
11
+ return inline;
12
+ return lines.slice(index + 1, index + 4).find((line) => /\d|free/i.test(line)) ?? '';
13
+ }
14
+ export function parseShopifySummary(text) {
15
+ const lines = text
16
+ .split(/\n+/)
17
+ .map((line) => line.replace(/\s+/g, ' ').trim())
18
+ .filter(Boolean);
19
+ const values = new Map();
20
+ for (let index = 0; index < lines.length; index += 1) {
21
+ const match = lines[index].match(SUMMARY_LABELS);
22
+ if (!match)
23
+ continue;
24
+ const key = match[1].toLowerCase();
25
+ const value = valueAfterLabel(lines, index, match[2]);
26
+ values.set(key, [...(values.get(key) ?? []), value]);
27
+ }
28
+ const uniqueValue = (...keys) => {
29
+ const found = keys.flatMap((key) => values.get(key) ?? []).filter(Boolean);
30
+ const unique = [...new Set(found)];
31
+ return unique.length === 1 ? unique[0] : undefined;
32
+ };
33
+ const parse = (value) => {
34
+ if (!value)
35
+ return null;
36
+ if (/\bfree\b/i.test(value))
37
+ return 0;
38
+ return minorFromDecimal(value);
39
+ };
40
+ const subtotalMinor = parse(uniqueValue('subtotal'));
41
+ const shippingMinor = parse(uniqueValue('shipping'));
42
+ const taxMinor = parse(uniqueValue('estimated taxes', 'taxes', 'tax'));
43
+ const discountMinor = parse(uniqueValue('discount', 'discounts')) ?? 0;
44
+ const totalText = uniqueValue('total');
45
+ const totalMinor = parse(totalText);
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;
63
+ return {
64
+ subtotalMinor,
65
+ shippingMinor,
66
+ taxMinor,
67
+ discountMinor,
68
+ totalMinor,
69
+ currency: pageCurrency(totalText ?? ''),
70
+ verified,
71
+ };
72
+ }
73
+ export async function readShopifyAmount(page, requireVerified) {
74
+ const text = await page.evaluate(() => document.body?.innerText ?? '').catch(() => '');
75
+ const summary = parseShopifySummary(text);
76
+ if (summary.totalMinor == null) {
77
+ return /\btotal\b/i.test(text)
78
+ ? { kind: 'unreadable', reason: 'Shopify total is present but unreadable' }
79
+ : { kind: 'none' };
80
+ }
81
+ if (requireVerified && !summary.verified) {
82
+ return {
83
+ kind: 'unreadable',
84
+ reason: 'Shopify tax and total do not form a complete, reconciled summary',
85
+ };
86
+ }
87
+ return {
88
+ kind: 'ok',
89
+ amountMinor: summary.totalMinor,
90
+ currency: summary.currency,
91
+ source: 'shopify-summary',
92
+ };
93
+ }
94
+ export async function readStableShopifyAmount(page, timeoutMs = 6_000) {
95
+ const startedAt = Date.now();
96
+ let previous = '';
97
+ let stableReads = 0;
98
+ while (Date.now() - startedAt < timeoutMs) {
99
+ const latest = await readShopifyAmount(page, true);
100
+ const fingerprint = JSON.stringify(latest);
101
+ stableReads = fingerprint === previous ? stableReads + 1 : 0;
102
+ if (latest.kind === 'ok' && stableReads >= 1)
103
+ return latest;
104
+ previous = fingerprint;
105
+ await page.waitForTimeout(200);
106
+ }
107
+ return {
108
+ kind: 'unreadable',
109
+ reason: 'Shopify tax and total did not settle before the review deadline',
110
+ };
111
+ }
112
+ export async function isShopifyCheckoutPage(page) {
113
+ return page
114
+ .evaluate(() => {
115
+ const payButton = document.querySelector('#checkout-pay-button');
116
+ const shopifyAddress = document.querySelector('[name^="checkout[shipping_address]"], [name^="checkout[billing_address]"]');
117
+ const shippingAddress = document.querySelector('[autocomplete~="shipping"][autocomplete~="address-line1"]');
118
+ return (Boolean(payButton) ||
119
+ Boolean(shopifyAddress) ||
120
+ (Boolean(window.Shopify) &&
121
+ (/\/checkouts?\//i.test(window.location.pathname) || Boolean(shippingAddress))));
122
+ })
123
+ .catch(() => false);
124
+ }
125
+ const SHOP_SURFACE_SELECTOR = [
126
+ 'dialog',
127
+ '[role="dialog"]',
128
+ '[aria-modal="true"]',
129
+ '[data-shop-pay-modal]',
130
+ '[data-shopify-pay-modal]',
131
+ ].join(', ');
132
+ const SHOP_CODE_TEXT = /enter (?:the )?(?:verification|one[- ]?time)?\s*code|code (?:was |has been )?sent|verification code/i;
133
+ const SHOP_AUTH_TEXT = /sign in|log in|confirm (?:it(?:'|’)s )?you|continue with shop|shop account/i;
134
+ const SHOP_RECOGNITION_TEXT = /recognized|faster checkout|remembered|saved (?:payment|details)/i;
135
+ async function classifyBodyShopSurface(page) {
136
+ const roots = page.locator(SHOP_SURFACE_SELECTOR);
137
+ for (let index = 0; index < (await roots.count().catch(() => 0)); index += 1) {
138
+ const root = roots.nth(index);
139
+ if (!(await root.isVisible().catch(() => false)))
140
+ continue;
141
+ const [text, attrs, shopMark] = await Promise.all([
142
+ root.innerText().catch(() => ''),
143
+ Promise.all(['id', 'class', 'aria-label', 'data-testid'].map((name) => root.getAttribute(name).catch(() => ''))).then((values) => values.join(' ')),
144
+ root
145
+ .locator('img[alt="Shop" i], img[alt="Shop Pay" i], [aria-label="Shop" i]')
146
+ .count()
147
+ .then((count) => count > 0)
148
+ .catch(() => false),
149
+ ]);
150
+ // A generic dialog, generic Close button, or marketing use of "shop" is
151
+ // not enough. Positive identification requires Shop Pay copy/attributes,
152
+ // or a Shop mark paired with authentication/guest-checkout language.
153
+ const positiveShopSignal = /\bshop\s*pay\b/i.test(`${text} ${attrs}`) ||
154
+ /shop[-_ ]?(?:pay|ify-pay)/i.test(attrs) ||
155
+ (shopMark && (SHOP_AUTH_TEXT.test(text) || /checkout as guest/i.test(text)));
156
+ if (!positiveShopSignal)
157
+ continue;
158
+ const subtype = SHOP_CODE_TEXT.test(text)
159
+ ? 'authentication'
160
+ : /checkout as guest/i.test(text)
161
+ ? 'wallet'
162
+ : SHOP_AUTH_TEXT.test(text)
163
+ ? 'authentication'
164
+ : SHOP_RECOGNITION_TEXT.test(text)
165
+ ? 'recognition'
166
+ : 'wallet';
167
+ return {
168
+ root,
169
+ subtype,
170
+ signal: `body:shop-${subtype}`,
171
+ };
172
+ }
173
+ return null;
174
+ }
175
+ async function classifyFrameShopSurface(page) {
176
+ const frames = page.locator('iframe');
177
+ for (let index = 0; index < (await frames.count().catch(() => 0)); index += 1) {
178
+ const frame = frames.nth(index);
179
+ if (!(await frame.isVisible().catch(() => false)))
180
+ continue;
181
+ const src = (await frame.getAttribute('src').catch(() => '')) ?? '';
182
+ if (!/^https:\/\/(?:[^/]+\.)?shop\.app\/(?:accounts\/login|pay|checkout)\b/i.test(src))
183
+ continue;
184
+ const accessibleName = [
185
+ await frame.getAttribute('title').catch(() => ''),
186
+ await frame.getAttribute('aria-label').catch(() => ''),
187
+ await frame.getAttribute('name').catch(() => ''),
188
+ ].join(' ');
189
+ const subtype = SHOP_CODE_TEXT.test(accessibleName) ? 'authentication' : 'wallet';
190
+ return { frame, subtype, signal: `frame:shop-${subtype}` };
191
+ }
192
+ return null;
193
+ }
194
+ export async function detectShopifyCheckoutSurface(page) {
195
+ const located = (await classifyBodyShopSurface(page)) ?? (await classifyFrameShopSurface(page));
196
+ return located ? { signal: located.signal, subtype: located.subtype } : null;
197
+ }
198
+ async function locateShopifyCheckoutSurface(page) {
199
+ return (await classifyBodyShopSurface(page)) ?? (await classifyFrameShopSurface(page));
200
+ }
201
+ async function visibleGuestCardForm(page) {
202
+ const detected = await detectFields(page).catch(() => null);
203
+ return Boolean(detected?.fields.number?.visible);
204
+ }
205
+ /**
206
+ * Resolve Shopify's reversible Shop-wallet takeover before review. The helper
207
+ * only clicks an exact guest action or a scoped accessible close control inside
208
+ * a positively identified Shop surface. It never solves authentication, uses a
209
+ * Shop-saved payment method, force-clicks, or mutates the DOM.
210
+ */
211
+ export async function ensureShopifyGuestCheckout(page, options = {}) {
212
+ const timeoutMs = options.timeoutMs ?? 1_800;
213
+ const stableMs = options.stableMs ?? 650;
214
+ const startedAt = Date.now();
215
+ let guestStableSince = null;
216
+ let located = null;
217
+ while (Date.now() - startedAt < timeoutMs) {
218
+ located = await locateShopifyCheckoutSurface(page);
219
+ if (located)
220
+ break;
221
+ if (await visibleGuestCardForm(page)) {
222
+ guestStableSince ??= Date.now();
223
+ if (Date.now() - guestStableSince >= stableMs) {
224
+ return { status: 'guest-ready', signal: 'guest-card-form' };
225
+ }
226
+ }
227
+ else {
228
+ guestStableSince = null;
229
+ }
230
+ await page.waitForTimeout(75);
231
+ }
232
+ if (!located) {
233
+ return (await visibleGuestCardForm(page))
234
+ ? { status: 'guest-ready', signal: 'guest-card-form' }
235
+ : {
236
+ status: 'action-required',
237
+ signal: 'shopify:guest-card-form-unavailable',
238
+ detail: 'Shopify guest card form did not become stable before review; no payment credential was requested',
239
+ };
240
+ }
241
+ const originBefore = new URL(page.url()).origin;
242
+ let action = null;
243
+ let actionSignal = '';
244
+ if (located.root) {
245
+ const exactGuest = located.root
246
+ .getByRole('button', { name: /^checkout as guest$/i })
247
+ .or(located.root.getByRole('link', { name: /^checkout as guest$/i }))
248
+ .first();
249
+ if ((await exactGuest.count().catch(() => 0)) > 0 &&
250
+ (await exactGuest.isVisible().catch(() => false))) {
251
+ action = exactGuest;
252
+ actionSignal = `${located.signal}:checkout-as-guest`;
253
+ }
254
+ else if (located.subtype !== 'authentication' ||
255
+ !(await located.root.innerText()).match(SHOP_CODE_TEXT)) {
256
+ const scopedClose = located.root
257
+ .getByRole('button', { name: /^close(?: shop(?: pay)?)?$/i })
258
+ .first();
259
+ if ((await scopedClose.count().catch(() => 0)) > 0 &&
260
+ (await scopedClose.isVisible().catch(() => false))) {
261
+ action = scopedClose;
262
+ actionSignal = `${located.signal}:scoped-close`;
263
+ }
264
+ }
265
+ }
266
+ else if (located.frame) {
267
+ const frame = located.frame.contentFrame();
268
+ const exactGuest = frame.getByRole('button', { name: /^checkout as guest$/i }).first();
269
+ if ((await exactGuest.count().catch(() => 0)) > 0 &&
270
+ (await exactGuest.isVisible().catch(() => false))) {
271
+ action = exactGuest;
272
+ actionSignal = `${located.signal}:checkout-as-guest`;
273
+ }
274
+ }
275
+ if (!action) {
276
+ return {
277
+ status: 'action-required',
278
+ signal: located.signal,
279
+ detail: `Shopify Shop surface could not be safely exited (${located.signal}); no payment credential was requested`,
280
+ };
281
+ }
282
+ await action.click();
283
+ const surfaceGone = await page
284
+ .waitForFunction((selector) => !Array.from(document.querySelectorAll(selector)).some((element) => {
285
+ const style = getComputedStyle(element);
286
+ const text = element.innerText ?? '';
287
+ const attrs = [
288
+ element.id,
289
+ element.className,
290
+ element.getAttribute('aria-label'),
291
+ element.getAttribute('data-testid'),
292
+ ].join(' ');
293
+ const visible = element.offsetParent !== null &&
294
+ style.display !== 'none' &&
295
+ style.visibility !== 'hidden';
296
+ return visible && /\bshop\s*pay\b|shop[-_ ]?(?:pay|ify-pay)/i.test(`${text} ${attrs}`);
297
+ }), SHOP_SURFACE_SELECTOR, { timeout: 2_000 })
298
+ .then(() => true)
299
+ .catch(() => false);
300
+ const sameOrigin = new URL(page.url()).origin === originBefore;
301
+ const surfaceRemains = Boolean(await locateShopifyCheckoutSurface(page));
302
+ if (!surfaceGone || surfaceRemains || !sameOrigin || !(await visibleGuestCardForm(page))) {
303
+ const signal = !sameOrigin
304
+ ? 'shopify:guest-transition-origin-changed'
305
+ : !surfaceGone || surfaceRemains
306
+ ? 'shopify:shop-surface-remained'
307
+ : 'shopify:guest-card-form-unavailable';
308
+ return {
309
+ status: 'action-required',
310
+ signal,
311
+ detail: `Shopify guest transition could not be verified (${signal}); no payment credential was requested`,
312
+ };
313
+ }
314
+ return { status: 'transitioned', signal: actionSignal };
315
+ }
316
+ /** Read-only assertion for the irreversible credential boundary. */
317
+ export async function assertShopifyGuestCheckout(page) {
318
+ const surface = await locateShopifyCheckoutSurface(page);
319
+ if (surface) {
320
+ return {
321
+ status: 'action-required',
322
+ signal: surface.signal,
323
+ detail: `Shopify Shop surface reappeared after review (${surface.signal}); no payment credential was requested`,
324
+ };
325
+ }
326
+ return (await visibleGuestCardForm(page))
327
+ ? { status: 'guest-ready', signal: 'guest-card-form' }
328
+ : {
329
+ status: 'action-required',
330
+ signal: 'shopify:guest-card-form-unavailable',
331
+ detail: 'Shopify guest card form is no longer available after review; no payment credential was requested',
332
+ };
333
+ }
334
+ /**
335
+ * The en-US variant of a localized Shopify checkout URL, or null when it is
336
+ * already English (or not locale-suffixed). Shopify renders the checkout in
337
+ * the URL's trailing locale segment, and amount reconciliation reads the
338
+ * order summary by its ENGLISH labels — a store whose primary market is not
339
+ * English serves /checkouts/cn/<token>/<locale> and the total never parses
340
+ * (observed live 2026-08-16: /es-us rendered "Precio total" and the review
341
+ * refused fail-closed on a good checkout). The locale segment is
342
+ * presentation-only: swapping it keeps the same checkout session and token.
343
+ */
344
+ export function shopifyEnglishCheckoutUrl(current) {
345
+ let url;
346
+ try {
347
+ url = new URL(current);
348
+ }
349
+ catch {
350
+ return null;
351
+ }
352
+ const match = url.pathname.match(/^(\/checkouts\/[^?#]+\/)([a-z]{2,3}(?:-[a-z0-9]+)*)(\/?)$/i);
353
+ if (!match || match[2].toLowerCase().startsWith('en'))
354
+ return null;
355
+ url.pathname = `${match[1]}en-us${match[3]}`;
356
+ return url.toString();
357
+ }
358
+ const BILLING_ROLES = new Set([
359
+ 'name',
360
+ 'nameFirst',
361
+ 'nameLast',
362
+ 'addressLine1',
363
+ 'addressLine2',
364
+ 'city',
365
+ 'state',
366
+ 'postalCode',
367
+ 'country',
368
+ ]);
369
+ const CONTACT_ROLES = new Set([
370
+ 'email',
371
+ 'phone',
372
+ 'name',
373
+ 'nameFirst',
374
+ 'nameLast',
375
+ 'addressLine1',
376
+ 'addressLine2',
377
+ 'city',
378
+ 'state',
379
+ 'postalCode',
380
+ 'country',
381
+ ]);
382
+ const PAYMENT_ROLES = new Set([
383
+ 'number',
384
+ 'cvc',
385
+ 'name',
386
+ 'expCombined',
387
+ 'expMonth',
388
+ 'expYear',
389
+ ]);
390
+ const BILLING_ROLE_NAMES = {
391
+ name: 'billingName',
392
+ nameFirst: 'billingNameFirst',
393
+ nameLast: 'billingNameLast',
394
+ addressLine1: 'billingAddressLine1',
395
+ addressLine2: 'billingAddressLine2',
396
+ city: 'billingCity',
397
+ state: 'billingState',
398
+ postalCode: 'billingPostalCode',
399
+ country: 'billingCountry',
400
+ };
401
+ function candidateText(candidate) {
402
+ const meta = candidate.meta;
403
+ return [meta.name, meta.id, meta.autocomplete, meta.labelText, meta.placeholder, meta.ariaLabel]
404
+ .join(' ')
405
+ .toLowerCase();
406
+ }
407
+ function isShopifyCandidate(candidate) {
408
+ const text = candidateText(candidate);
409
+ return (/(?:^|[^a-z])shopify(?:[^a-z]|$)/i.test(text) ||
410
+ /checkout\[(?:shipping|billing)_address\]/i.test(text));
411
+ }
412
+ function isBillingCandidate(candidate) {
413
+ return /billing[_ -]?address|billingaddress|(?:^|[^a-z])billing(?:[^a-z]|$)/i.test(candidateText(candidate));
414
+ }
415
+ function isPaymentCandidate(candidate) {
416
+ return /cc-|cardholder|card holder|payment/i.test(candidateText(candidate));
417
+ }
418
+ function entryFromCandidate(candidate) {
419
+ if (!candidate.source)
420
+ return null;
421
+ return {
422
+ locator: `[data-ca-id="${candidate.meta.idx}"]`,
423
+ confidence: candidate.confidence,
424
+ source: candidate.source,
425
+ frame: candidate.frame,
426
+ tag: candidate.meta.tag,
427
+ inputType: candidate.meta.type,
428
+ // The billing control is revealed before these entries are filled.
429
+ visible: true,
430
+ options: candidate.meta.options.length ? candidate.meta.options : undefined,
431
+ maxlength: candidate.meta.maxlength ? Number.parseInt(candidate.meta.maxlength, 10) : undefined,
432
+ };
433
+ }
434
+ function billingFieldMap(detected) {
435
+ const fields = {};
436
+ for (const candidate of detected.candidates) {
437
+ const role = candidate.role;
438
+ if (!role || !BILLING_ROLES.has(role) || !isBillingCandidate(candidate))
439
+ continue;
440
+ const entry = entryFromCandidate(candidate);
441
+ if (!entry)
442
+ continue;
443
+ const existing = fields[role];
444
+ if (!existing || entry.confidence > existing.confidence)
445
+ fields[role] = entry;
446
+ }
447
+ return fields;
448
+ }
449
+ function shippingFieldMap(detected) {
450
+ const fields = {};
451
+ for (const candidate of detected.candidates) {
452
+ const role = candidate.role;
453
+ if (!role ||
454
+ !CONTACT_ROLES.has(role) ||
455
+ !candidate.meta.visible ||
456
+ isBillingCandidate(candidate) ||
457
+ isPaymentCandidate(candidate))
458
+ continue;
459
+ const entry = entryFromCandidate(candidate);
460
+ if (!entry)
461
+ continue;
462
+ const existing = fields[role];
463
+ if (!existing || entry.confidence > existing.confidence)
464
+ fields[role] = entry;
465
+ }
466
+ return fields;
467
+ }
468
+ function paymentFieldMap(detected) {
469
+ const fields = {};
470
+ for (const candidate of detected.candidates) {
471
+ const role = candidate.role;
472
+ if (!role || !PAYMENT_ROLES.has(role) || isBillingCandidate(candidate))
473
+ continue;
474
+ if (role === 'name' && !isPaymentCandidate(candidate))
475
+ continue;
476
+ const entry = entryFromCandidate(candidate);
477
+ if (!entry)
478
+ continue;
479
+ const existing = fields[role];
480
+ if (!existing || entry.confidence > existing.confidence)
481
+ fields[role] = entry;
482
+ }
483
+ return fields;
484
+ }
485
+ async function clickFirstVisible(locators) {
486
+ for (const locator of locators) {
487
+ const first = locator.first();
488
+ if ((await first.count().catch(() => 0)) < 1)
489
+ continue;
490
+ if (!(await first.isVisible().catch(() => false)))
491
+ continue;
492
+ await first.click();
493
+ return true;
494
+ }
495
+ return false;
496
+ }
497
+ async function hasVisibleBillingField(page) {
498
+ const fields = page.locator('input:not([type="radio"]):not([type="checkbox"])[name*="billing_address" i], select[name*="billing_address" i], textarea[name*="billing_address" i]');
499
+ for (let index = 0; index < (await fields.count()); index += 1) {
500
+ if (await fields
501
+ .nth(index)
502
+ .isVisible()
503
+ .catch(() => false))
504
+ return true;
505
+ }
506
+ return false;
507
+ }
508
+ async function waitForVisibleBillingField(page) {
509
+ if (await hasVisibleBillingField(page))
510
+ return true;
511
+ return page
512
+ .waitForFunction(() => Array.from(document.querySelectorAll('input:not([type="radio"]):not([type="checkbox"])[name*="billing_address" i], select[name*="billing_address" i], textarea[name*="billing_address" i]')).some((element) => {
513
+ const style = window.getComputedStyle(element);
514
+ return (style.display !== 'none' &&
515
+ style.visibility !== 'hidden' &&
516
+ element.getClientRects().length > 0);
517
+ }), undefined, { timeout: 2_000 })
518
+ .then(() => true)
519
+ .catch(() => false);
520
+ }
521
+ async function revealSeparateBilling(page) {
522
+ if (await hasVisibleBillingField(page))
523
+ return true;
524
+ const roleRadio = page
525
+ .getByRole('radio', { name: /different billing|use a different billing/i })
526
+ .first();
527
+ if ((await roleRadio.count().catch(() => 0)) > 0 &&
528
+ (await roleRadio.isVisible().catch(() => false))) {
529
+ await roleRadio.check();
530
+ return waitForVisibleBillingField(page);
531
+ }
532
+ if (await clickFirstVisible([
533
+ page.locator('input[name="billing_address_selector"][value="billing_address"]'),
534
+ page.locator('input[type="radio"][name*="billing" i][value*="different" i], input[type="radio"][name*="billing" i][value*="billing" i]'),
535
+ ])) {
536
+ return waitForVisibleBillingField(page);
537
+ }
538
+ const sameAsShipping = page
539
+ .getByRole('checkbox', { name: /same as shipping|use shipping address/i })
540
+ .first();
541
+ if ((await sameAsShipping.count().catch(() => 0)) > 0 &&
542
+ (await sameAsShipping.isVisible().catch(() => false))) {
543
+ if (await sameAsShipping.isChecked().catch(() => false))
544
+ await sameAsShipping.uncheck();
545
+ return waitForVisibleBillingField(page);
546
+ }
547
+ return false;
548
+ }
549
+ function billingContact(address) {
550
+ return {
551
+ firstName: address.firstName,
552
+ lastName: address.lastName,
553
+ fullName: address.fullName,
554
+ addressLine1: address.addressLine1,
555
+ addressLine2: address.addressLine2,
556
+ city: address.city,
557
+ state: address.state,
558
+ postalCode: address.postalCode,
559
+ country: address.country,
560
+ };
561
+ }
562
+ function labelBillingEvidence(filled) {
563
+ return filled.map((field) => ({
564
+ ...field,
565
+ role: BILLING_ROLE_NAMES[field.role] ?? `billing:${field.role}`,
566
+ }));
567
+ }
568
+ function requiredAddressRoles(address, prefix = '') {
569
+ const role = (name) => `${prefix}${prefix ? name[0].toUpperCase() + name.slice(1) : name}`;
570
+ const regionRequired = ['US', 'CA', 'AU'].includes(address.country?.toUpperCase() ?? '');
571
+ return [
572
+ ...(address.firstName ? [role('nameFirst')] : []),
573
+ ...(address.lastName ? [role('nameLast')] : []),
574
+ ...(!address.firstName && !address.lastName && address.fullName ? [role('name')] : []),
575
+ ...(address.addressLine1 ? [role('addressLine1')] : []),
576
+ ...(address.addressLine2 ? [role('addressLine2')] : []),
577
+ ...(address.city ? [role('city')] : []),
578
+ ...(address.state && regionRequired ? [role('state')] : []),
579
+ ...(address.postalCode ? [role('postalCode')] : []),
580
+ ...(address.country ? [role('country')] : []),
581
+ ];
582
+ }
583
+ /**
584
+ * A checkout renders ONE name shape (a single full-name input, or first/last)
585
+ * while the contact record may carry the other, so the expected role and the
586
+ * filled role can disagree while the page is completely filled. Either shape
587
+ * satisfies the name requirement; same for the billing-prefixed variants.
588
+ */
589
+ function withNameEquivalence(successful) {
590
+ const out = new Set(successful);
591
+ for (const [full, first, last] of [
592
+ ['name', 'nameFirst', 'nameLast'],
593
+ ['billingName', 'billingNameFirst', 'billingNameLast'],
594
+ ]) {
595
+ if (out.has(first) && out.has(last))
596
+ out.add(full);
597
+ if (out.has(full)) {
598
+ out.add(first);
599
+ out.add(last);
600
+ }
601
+ }
602
+ return out;
603
+ }
604
+ export function missingContactRoles(filled, expected) {
605
+ const successful = withNameEquivalence(new Set(filled.filter((field) => field.ok).map((field) => field.role)));
606
+ return expected.filter((role) => !successful.has(role));
607
+ }
608
+ function hasAddressRole(fields) {
609
+ return Boolean(fields.addressLine1 ?? fields.city ?? fields.postalCode);
610
+ }
611
+ /**
612
+ * The contact surface to prefill. Shipping fields when the checkout has them —
613
+ * but a digital-goods (no-shipping) Shopify checkout renders exactly one
614
+ * address block and marks every field autocomplete="billing ..." (observed
615
+ * live 2026-08-16: all 18 candidates billing-classified, so the shipping map
616
+ * came back empty and the prefill reported every role missing). That billing
617
+ * block IS the primary contact surface, under its base roles.
618
+ */
619
+ export function contactPrefillFieldMap(detected) {
620
+ const shipping = shippingFieldMap(detected);
621
+ if (hasAddressRole(shipping))
622
+ return { fields: shipping, surface: 'shipping' };
623
+ const fields = {};
624
+ for (const candidate of detected.candidates) {
625
+ const role = candidate.role;
626
+ if (!role || !CONTACT_ROLES.has(role) || !candidate.meta.visible)
627
+ continue;
628
+ if (isPaymentCandidate(candidate))
629
+ continue;
630
+ const entry = entryFromCandidate(candidate);
631
+ if (!entry)
632
+ continue;
633
+ const existing = fields[role];
634
+ if (!existing || entry.confidence > existing.confidence)
635
+ fields[role] = entry;
636
+ }
637
+ return { fields, surface: 'billing-only' };
638
+ }
639
+ export class ShopifyAdapter {
640
+ detected;
641
+ name = 'shopify';
642
+ constructor(detected) {
643
+ this.detected = detected;
644
+ }
645
+ matches(detected) {
646
+ return detected.candidates.some(isShopifyCandidate);
647
+ }
648
+ async prepareContact(page, contact) {
649
+ const { fields: contactFields, surface } = contactPrefillFieldMap(this.detected);
650
+ const shippingFilled = await fillContactFieldMap(page, contactFields, contact);
651
+ const shippingExpected = [...requiredAddressRoles(contact), ...(contact.phone ? ['phone'] : [])];
652
+ const shippingMissing = missingContactRoles(shippingFilled, shippingExpected);
653
+ if (surface === 'billing-only' || !contact.billingAddress) {
654
+ return {
655
+ ok: shippingMissing.length === 0,
656
+ filled: shippingFilled,
657
+ ...(shippingMissing.length
658
+ ? {
659
+ detail: `Shopify ${surface === 'billing-only' ? 'contact' : 'shipping'} prefill incomplete: missing ${shippingMissing.join(', ')}`,
660
+ }
661
+ : {}),
662
+ };
663
+ }
664
+ if (!(await revealSeparateBilling(page))) {
665
+ return {
666
+ ok: false,
667
+ filled: shippingFilled,
668
+ detail: 'Shopify billing address fields did not become visible during prefill',
669
+ };
670
+ }
671
+ const billingFields = billingFieldMap(await detectFields(page));
672
+ const billingFilled = labelBillingEvidence(await fillContactFieldMap(page, billingFields, billingContact(contact.billingAddress)));
673
+ const billingExpected = requiredAddressRoles(contact.billingAddress, 'billing');
674
+ const missing = [...shippingMissing, ...missingContactRoles(billingFilled, billingExpected)];
675
+ return {
676
+ ok: missing.length === 0,
677
+ filled: [...shippingFilled, ...billingFilled],
678
+ ...(missing.length
679
+ ? { detail: `Shopify contact prefill incomplete: missing ${missing.join(', ')}` }
680
+ : {}),
681
+ };
682
+ }
683
+ async fill(page, _fields, credential, _contact) {
684
+ const filled = await fillFieldMap(page, paymentFieldMap(this.detected), credential, {});
685
+ const numberOk = filled.some((field) => field.role === 'number' && field.ok);
686
+ return { ok: numberOk, filled };
687
+ }
688
+ }