@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
@@ -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');
@@ -73,7 +74,136 @@ export function scrubFillErrorMessage(message, value) {
73
74
  scrubbed = scrubbed.split(value).join('<redacted>');
74
75
  return scrubbed;
75
76
  }
77
+ /**
78
+ * A fill failure in a few words, for a message a human reads.
79
+ *
80
+ * Playwright's error is a multi-line call log — useful in the evidence file,
81
+ * unreadable in a refusal message and in the receipt an operator opens a week
82
+ * later. The refusal names WHICH fields refused; without this it never says
83
+ * WHY, so diagnosing a merchant we cannot drive means either reproducing it or
84
+ * reading someone's evidence JSON. Each cause maps to a different fix:
85
+ *
86
+ * not editable — the input exists but is readonly/disabled at fill time
87
+ * (a custom widget owning the value, or a not-yet-ready
88
+ * form). Typing will not help; the field needs an adapter
89
+ * or a longer wait.
90
+ * not a text field — a non-input element pretending to be one. Needs an
91
+ * adapter that drives the widget.
92
+ * not visible /
93
+ * detached — a re-render race. The reveal loop is the lever.
94
+ *
95
+ * Input is already scrubbed by scrubFillErrorMessage; this only ever shortens.
96
+ */
97
+ export function summarizeFillFailure(error) {
98
+ if (!error)
99
+ return 'no reason recorded';
100
+ if (/not an? <input>|not.*\[contenteditable\]/i.test(error))
101
+ return 'not a text field';
102
+ if (/element is not visible/i.test(error))
103
+ return 'not visible';
104
+ if (/not attached to the DOM|detached/i.test(error))
105
+ return 'detached from the page';
106
+ if (/element is not enabled/i.test(error))
107
+ return 'disabled';
108
+ if (/not editable/i.test(error)) {
109
+ const timeout = /Timeout (\d+)ms exceeded/i.exec(error);
110
+ return timeout
111
+ ? `not editable within ${Math.round(Number(timeout[1]) / 1000)}s`
112
+ : 'not editable';
113
+ }
114
+ if (/Timeout (\d+)ms exceeded/i.test(error)) {
115
+ const timeout = /Timeout (\d+)ms exceeded/i.exec(error);
116
+ return `timed out after ${Math.round(Number(timeout[1]) / 1000)}s`;
117
+ }
118
+ // Unrecognized: the first line, bounded. Better a clipped real message than a
119
+ // confident wrong summary.
120
+ return error.split('\n')[0].slice(0, 120);
121
+ }
76
122
  const DEFAULT_FILL_TIMEOUT_MS = 5000;
123
+ /**
124
+ * How many times one fill pass may re-detect after a locator goes stale.
125
+ *
126
+ * One re-mount usually invalidates a whole block of fields at once, so a single
127
+ * pass normally recovers all of them. The cap is here so a page that re-mounts
128
+ * on EVERY keystroke degrades to the old timeout behaviour instead of looping.
129
+ */
130
+ const MAX_FILL_REDETECTS = 3;
131
+ /**
132
+ * True when a detected field's locator no longer resolves to anything.
133
+ *
134
+ * `data-ca-id` is stamped onto the live nodes during detection, so it is an
135
+ * identity that dies with the node. A merchant that re-mounts part of its form
136
+ * mid-fill (address autocomplete is the common one) replaces those nodes with
137
+ * fresh, unstamped ones — the old locator then matches NOTHING and the fill
138
+ * waits out its entire timeout on an element that can never appear.
139
+ */
140
+ async function locatorIsMissing(page, entry) {
141
+ try {
142
+ return (await resolveLocator(page, entry).count()) === 0;
143
+ }
144
+ catch {
145
+ // A frame that went away, a detached context: let the fill run and report
146
+ // the real error rather than inventing a diagnosis here.
147
+ return false;
148
+ }
149
+ }
150
+ /**
151
+ * Contact and address roles — plain inputs whose value can be read straight
152
+ * back. The credential roles are deliberately absent: they sit in PSP iframes
153
+ * that tokenize and reformat what they are given, so a readback there compares
154
+ * against a value the merchant never promised to keep, and they are not what an
155
+ * address re-mount disturbs anyway.
156
+ */
157
+ const READBACK_ROLES = [
158
+ 'name',
159
+ 'nameFirst',
160
+ 'nameLast',
161
+ 'email',
162
+ 'phone',
163
+ 'addressLine1',
164
+ 'addressLine2',
165
+ 'city',
166
+ 'state',
167
+ 'postalCode',
168
+ 'country',
169
+ ];
170
+ /**
171
+ * Downgrade any field whose value did not survive to the end of the pass.
172
+ *
173
+ * Only runs when a re-mount was actually observed, and only for the roles
174
+ * above. A page that rebuilds its address block on EVERY edit erases the field
175
+ * we just filled when we fill the NEXT one, so a fill can report success over a
176
+ * box that is empty by the time anyone submits it. Recovering from a re-mount
177
+ * must not buy that recovery with a false claim: without this, the engine would
178
+ * hand the pre-submit gate a clean bill of health for an empty form — and the
179
+ * gate would let it through. Before the recovery existed these fields failed
180
+ * loudly instead, and a wrong "filled" is worse than an honest refusal.
181
+ */
182
+ async function downgradeFieldsThatDidNotHold(page, fields, filled) {
183
+ for (const record of filled) {
184
+ if (!record.ok)
185
+ continue;
186
+ if (!READBACK_ROLES.includes(record.role))
187
+ continue;
188
+ const entry = fields[record.role];
189
+ if (!entry)
190
+ continue;
191
+ let held;
192
+ try {
193
+ const loc = resolveLocator(page, entry);
194
+ held = (await loc.count()) > 0 && (await loc.inputValue()).trim() !== '';
195
+ }
196
+ catch {
197
+ // Unreadable is not evidence of empty. Leave the record alone rather than
198
+ // failing a field that is probably fine.
199
+ continue;
200
+ }
201
+ if (held)
202
+ continue;
203
+ record.ok = false;
204
+ record.error = 'value did not survive a re-render of the form';
205
+ }
206
+ }
77
207
  async function fillOne(page, role, entry, value, displayValue, fillTimeoutMs) {
78
208
  const base = {
79
209
  role,
@@ -97,66 +227,77 @@ async function fillOne(page, role, entry, value, displayValue, fillTimeoutMs) {
97
227
  return { ...base, ok: false, error: scrubFillErrorMessage(err.message, value) };
98
228
  }
99
229
  }
100
- // The shared fill routine used by every adapter. Takes an already-detected
101
- // FieldMap so the executor controls when detection runs.
102
- export async function fillFieldMap(page, fields, credential, contact, opts = {}) {
230
+ /**
231
+ * The contact record and the page rarely agree on name shape: the record may
232
+ * carry fullName while the page wants first/last inputs, or vice versa. Derive
233
+ * the missing shape so either page can be filled from either record.
234
+ */
235
+ export function contactNameShapes(contact, cardholderName) {
236
+ const fullName = contact.fullName ??
237
+ (contact.firstName && contact.lastName
238
+ ? `${contact.firstName} ${contact.lastName}`
239
+ : cardholderName);
240
+ const first = contact.firstName ?? (fullName?.split(/\s+/)[0] || undefined);
241
+ const last = contact.lastName ?? (fullName?.split(/\s+/).slice(1).join(' ') || undefined);
242
+ return { fullName, first, last };
243
+ }
244
+ async function fillFields(page, fields, credential, contact, opts = {}) {
103
245
  const fillTimeoutMs = opts.fillTimeoutMs ?? DEFAULT_FILL_TIMEOUT_MS;
246
+ const detect = opts.detect ?? detectFields;
104
247
  const filled = [];
105
- const first = contact.firstName ?? credential.cardholderName.split(/\s+/)[0] ?? credential.cardholderName;
106
- const last = contact.lastName ?? credential.cardholderName.split(/\s+/).slice(1).join(' ') ?? '';
248
+ const { fullName, first, last } = contactNameShapes(contact, credential?.cardholderName);
107
249
  // Order matters a little: contact/name before card is harmless, but we fill
108
250
  // card fields explicitly per role so order is not load-bearing.
251
+ //
252
+ // A job records the ROLE it fills, never a captured entry: the entry is
253
+ // resolved from the live map at the moment the job runs, because a re-mount
254
+ // part-way through this pass can replace the element a role points at.
255
+ // `value` is derived from that same entry, since whether a role is a <select>
256
+ // (and which options it offers) is a property of the element we end up with.
109
257
  const jobs = [];
110
- const add = (role, entry, value, display) => {
111
- if (!entry)
112
- return;
113
- // Skip fields that are not currently visible (e.g. a collapsed accordion
114
- // panel or a not-yet-reached step). The executor reveals them and re-fills.
115
- if (entry.visible === false)
258
+ const add = (role, detected, value, display) => {
259
+ // Gate on the role having been detected at all. Roles absent from the
260
+ // original map stay out of scope even if a later pass would find them.
261
+ if (!detected)
116
262
  return;
117
- jobs.push({
118
- role,
119
- entry,
120
- run: async () => fillOne(page, role, entry, value(), display(), fillTimeoutMs),
121
- });
263
+ jobs.push({ role, value, display });
122
264
  };
123
- add('number', fields.number, () => credential.pan, () => maskPan(credential.pan));
124
- add('cvc', fields.cvc, () => credential.cvc, () => maskCvc(credential.cvc));
125
- add('name', fields.name, () => credential.cardholderName, () => redactContact('name', credential.cardholderName));
126
- add('nameFirst', fields.nameFirst, () => first, () => redactContact('nameFirst', first));
127
- add('nameLast', fields.nameLast, () => last, () => redactContact('nameLast', last));
265
+ if (credential) {
266
+ add('number', fields.number, () => credential.pan, () => maskPan(credential.pan));
267
+ add('cvc', fields.cvc, () => credential.cvc, () => maskCvc(credential.cvc));
268
+ }
269
+ if (fullName)
270
+ add('name', fields.name, () => fullName, () => redactContact('name', fullName));
271
+ if (first)
272
+ add('nameFirst', fields.nameFirst, () => first, () => redactContact('nameFirst', first));
273
+ if (last)
274
+ add('nameLast', fields.nameLast, () => last, () => redactContact('nameLast', last));
128
275
  // Expiry display values are always redacted: the expiry is part of the
129
276
  // keyable credential (DPAN + expiry + DAVV) and never enters the log.
130
- if (fields.expCombined) {
131
- const e = fields.expCombined;
132
- const v = expCombinedValue(e, credential.expMonth, credential.expYear);
133
- add('expCombined', e, () => v, () => maskExpiry());
134
- }
135
- if (fields.expMonth) {
136
- const e = fields.expMonth;
137
- const value = e.tag === 'select'
277
+ if (credential && fields.expCombined) {
278
+ add('expCombined', fields.expCombined, (e) => expCombinedValue(e, credential.expMonth, credential.expYear), () => maskExpiry());
279
+ }
280
+ if (credential && fields.expMonth) {
281
+ add('expMonth', fields.expMonth, (e) => e.tag === 'select'
138
282
  ? (monthOptionValue(e.options ?? [], credential.expMonth) ?? pad2(credential.expMonth))
139
- : pad2(credential.expMonth);
140
- add('expMonth', e, () => value, () => maskExpiry());
141
- }
142
- if (fields.expYear) {
143
- const e = fields.expYear;
144
- let value;
145
- if (e.tag === 'select') {
146
- value = yearOptionValue(e.options ?? [], credential.expYear) ?? String(credential.expYear);
147
- }
148
- else if ((e.maxlength ?? 0) === 2) {
149
- value = pad2(credential.expYear % 100);
150
- }
151
- else {
152
- value = String(credential.expYear);
153
- }
154
- add('expYear', e, () => value, () => maskExpiry());
283
+ : pad2(credential.expMonth), () => maskExpiry());
284
+ }
285
+ if (credential && fields.expYear) {
286
+ add('expYear', fields.expYear, (e) => {
287
+ if (e.tag === 'select') {
288
+ return yearOptionValue(e.options ?? [], credential.expYear) ?? String(credential.expYear);
289
+ }
290
+ if ((e.maxlength ?? 0) === 2)
291
+ return pad2(credential.expYear % 100);
292
+ return String(credential.expYear);
293
+ }, () => maskExpiry());
155
294
  }
156
295
  // Contact / shipping. Display values are always redacted: these are PII and
157
296
  // the evidence log is built to be persistable.
158
297
  if (contact.email)
159
298
  add('email', fields.email, () => contact.email, () => redactContact('email', contact.email));
299
+ if (contact.phone)
300
+ add('phone', fields.phone, () => contact.phone, () => redactContact('phone', contact.phone));
160
301
  if (contact.addressLine1)
161
302
  add('addressLine1', fields.addressLine1, () => contact.addressLine1, () => redactContact('addressLine1', contact.addressLine1));
162
303
  if (contact.addressLine2)
@@ -166,25 +307,202 @@ export async function fillFieldMap(page, fields, credential, contact, opts = {})
166
307
  if (contact.postalCode)
167
308
  add('postalCode', fields.postalCode, () => contact.postalCode, () => redactContact('postalCode', contact.postalCode));
168
309
  if (fields.state && contact.state) {
169
- const e = fields.state;
170
310
  const wanted = contact.state;
171
- const value = e.tag === 'select' ? (matchOption(e.options ?? [], [wanted]) ?? wanted) : wanted;
172
- add('state', e, () => value, () => redactContact('state', value));
311
+ add('state', fields.state, (e) => (e.tag === 'select' ? (matchOption(e.options ?? [], [wanted]) ?? wanted) : wanted), (value) => redactContact('state', value));
173
312
  }
174
313
  if (fields.country && contact.country) {
175
- const e = fields.country;
176
- const value = e.tag === 'select'
177
- ? (matchOption(e.options ?? [], [contact.country]) ?? contact.country)
178
- : contact.country;
179
- add('country', e, () => value, () => redactContact('country', value));
314
+ const wanted = contact.country;
315
+ add('country', fields.country, (e) => (e.tag === 'select' ? (matchOption(e.options ?? [], [wanted]) ?? wanted) : wanted), (value) => redactContact('country', value));
180
316
  }
181
- for (const j of jobs) {
182
- const r = await j.run();
183
- if (r)
184
- filled.push(r);
317
+ // The map is re-read per job, and a re-detect REPLACES it wholesale rather
318
+ // than patching the one entry that went stale: a detection pass re-stamps
319
+ // every element from scratch, so an id handed out by the previous pass can
320
+ // afterwards name a different element. Adopting the fresh map for every
321
+ // remaining job is what keeps a recovery from filling the wrong box.
322
+ let current = fields;
323
+ let redetects = 0;
324
+ for (const job of jobs) {
325
+ let entry = current[job.role];
326
+ // Skip fields that are not currently visible (e.g. a collapsed accordion
327
+ // panel or a not-yet-reached step). The executor reveals them and re-fills.
328
+ if (!entry || entry.visible === false)
329
+ continue;
330
+ let relocated = false;
331
+ if (redetects < MAX_FILL_REDETECTS && (await locatorIsMissing(page, entry))) {
332
+ redetects++;
333
+ const fresh = await detect(page).then((r) => r.fields, () => null);
334
+ if (fresh) {
335
+ current = fresh;
336
+ const next = current[job.role];
337
+ if (next && next.visible !== false) {
338
+ entry = next;
339
+ relocated = true;
340
+ }
341
+ // If the role is gone from the fresh map, deliberately keep the stale
342
+ // entry and let the fill fail on it. Skipping would leave NO record of
343
+ // the field, and the pre-submit gate reads "never attempted" as
344
+ // "nothing to worry about" — which is how an incomplete form gets
345
+ // submitted. A recorded failure refuses; a silent skip does not.
346
+ }
347
+ }
348
+ const value = job.value(entry);
349
+ const result = await fillOne(page, job.role, entry, value, job.display(value), fillTimeoutMs);
350
+ filled.push(relocated ? { ...result, relocated: true } : result);
185
351
  }
352
+ // Only when a re-mount was actually seen: everywhere else the page never
353
+ // moved under us and the readback would be pure cost.
354
+ if (redetects > 0)
355
+ await downgradeFieldsThatDidNotHold(page, current, filled);
186
356
  return filled;
187
357
  }
358
+ // Credential-free contact prefill for merchants that must calculate shipping,
359
+ // tax, and the final total before a human reviews the payment.
360
+ export async function fillContactFieldMap(page, fields, contact, opts = {}) {
361
+ return fillFields(page, fields, null, contact, opts);
362
+ }
363
+ // The shared post-approval fill routine used by every adapter.
364
+ export async function fillFieldMap(page, fields, credential, contact, opts = {}) {
365
+ return fillFields(page, fields, credential, contact, opts);
366
+ }
367
+ /** Option text that identifies a card-paying choice, most specific first. */
368
+ const CARD_OPTION_PATTERNS = [
369
+ /^\s*visa\s*$/i,
370
+ /credit\s*card|card\s*payment/i,
371
+ /^\s*(mastercard|master\s*card)\s*$/i,
372
+ /\bcard\b/i,
373
+ ];
374
+ /**
375
+ * Attribute selectors for a card-number input that survive a panel re-render,
376
+ * tried in order. The originally detected locator is tried first so a page that
377
+ * does NOT re-render keeps its higher-confidence match.
378
+ */
379
+ const CARD_NUMBER_FALLBACK_SELECTORS = [
380
+ 'input[autocomplete="cc-number"]',
381
+ 'input[name*="creditcardnumber" i]',
382
+ 'input[name*="cardnumber" i]',
383
+ 'input[id*="cardnumber" i]',
384
+ 'input[name*="cc-number" i]',
385
+ ];
386
+ /** First selector that resolves to a visible input, or null if none do. */
387
+ async function firstVisibleCardNumberLocator(page, detectedLocator, timeoutMs) {
388
+ for (const selector of [detectedLocator, ...CARD_NUMBER_FALLBACK_SELECTORS]) {
389
+ try {
390
+ await page
391
+ .locator(selector)
392
+ .first()
393
+ .waitFor({ state: 'visible', timeout: Math.max(500, Math.floor(timeoutMs / 3)) });
394
+ return selector;
395
+ }
396
+ catch {
397
+ continue;
398
+ }
399
+ }
400
+ return null;
401
+ }
402
+ /** Card-credential roles that share the panel a payment select unfolds. */
403
+ const CARD_GROUP_ROLES = ['number', 'cvc', 'expCombined', 'expMonth', 'expYear'];
404
+ /**
405
+ * Re-detect and adopt fresh entries for every card field after the panel is
406
+ * unfolded. Injected for tests; the executor's own detector is used in
407
+ * production.
408
+ */
409
+ export async function refreshCardGroupFromPage(page, fields, detect = detectFields) {
410
+ let fresh;
411
+ try {
412
+ fresh = (await detect(page)).fields;
413
+ }
414
+ catch {
415
+ return [];
416
+ }
417
+ const adopted = [];
418
+ for (const role of CARD_GROUP_ROLES) {
419
+ const next = fresh[role];
420
+ if (!next || next.visible === false)
421
+ continue;
422
+ const current = fields[role];
423
+ // Only ever replace an entry we could not have filled anyway. A field that
424
+ // is already visible was detected against the live DOM and keeps its
425
+ // higher-confidence match.
426
+ if (current && current.visible !== false)
427
+ continue;
428
+ fields[role] = next;
429
+ adopted.push(role);
430
+ }
431
+ return adopted;
432
+ }
433
+ /**
434
+ * Reveal card fields that a checkout keeps collapsed until a payment method is
435
+ * chosen.
436
+ *
437
+ * `fillFields` skips any entry with `visible === false`, so a card-number input
438
+ * sitting inside a folded panel is never even attempted — the generic adapter
439
+ * then reports `ok: false` ("fill incomplete") without having typed anything.
440
+ * That is the correct default: filling an invisible input is how a credential
441
+ * gets typed into the wrong place. But a payment-method `<select>` guarding the
442
+ * card panel is common enough to be worth handling, and the recovery is a
443
+ * single deterministic interaction rather than a guess.
444
+ *
445
+ * We only ever SELECT a card option — never a wallet, bank transfer, or
446
+ * anything else — and we only act when the card field is already detected but
447
+ * hidden. If nothing changes, the caller proceeds exactly as before and still
448
+ * fails closed.
449
+ *
450
+ * Mutates `fields.number.visible` on success so the subsequent fill attempts
451
+ * the field it just revealed.
452
+ */
453
+ export async function revealCollapsedCardSection(page, fields, opts = {}) {
454
+ const number = fields.number;
455
+ if (!number || number.visible !== false)
456
+ return { revealed: false, via: null };
457
+ const timeoutMs = opts.timeoutMs ?? 5_000;
458
+ const selects = page.locator('select');
459
+ const count = await selects.count().catch(() => 0);
460
+ for (let i = 0; i < Math.min(count, 12); i++) {
461
+ const select = selects.nth(i);
462
+ // Read option labels through the locator API rather than page.evaluate.
463
+ // A bundled build rewrites the function passed to evaluate() and the
464
+ // injected helper is not defined in page scope, so it throws at runtime —
465
+ // silently, once a catch treats it as "this select didn't match". Staying
466
+ // on the locator API keeps this working in source and bundled alike.
467
+ let labels;
468
+ try {
469
+ labels = await select.locator('option').allTextContents();
470
+ }
471
+ catch {
472
+ continue;
473
+ }
474
+ for (const pattern of CARD_OPTION_PATTERNS) {
475
+ const label = labels.map((l) => l.trim()).find((l) => l && pattern.test(l));
476
+ if (!label)
477
+ continue;
478
+ try {
479
+ await select.selectOption({ label }, { timeout: timeoutMs });
480
+ }
481
+ catch {
482
+ continue;
483
+ }
484
+ // Re-acquire the field instead of waiting on the detected locator.
485
+ // Unfolding the panel typically re-renders it, and the detector's
486
+ // synthetic `data-ca-id` attribute does not survive that — waiting on the
487
+ // old locator times out even though the field is now on screen and
488
+ // fillable. Stable attribute selectors survive the re-render.
489
+ const revealedLocator = await firstVisibleCardNumberLocator(page, number.locator, timeoutMs);
490
+ if (!revealedLocator)
491
+ continue;
492
+ number.locator = revealedLocator;
493
+ number.visible = true;
494
+ // The number is not alone in that panel: cvc and expiry were re-rendered
495
+ // with it and still carry stale, invisible entries. Filling only the
496
+ // number would trade "adapter fill incomplete" for "credential fill
497
+ // incomplete: missing cvc, expiry" — still a failed purchase, still after
498
+ // a credential was minted. Re-detect and adopt fresh entries for the
499
+ // whole card group.
500
+ await refreshCardGroupFromPage(page, fields);
501
+ return { revealed: true, via: label };
502
+ }
503
+ }
504
+ return { revealed: false, via: null };
505
+ }
188
506
  export class GenericAdapter {
189
507
  name = 'generic';
190
508
  matches(_detected) {
@@ -192,10 +510,17 @@ export class GenericAdapter {
192
510
  return true;
193
511
  }
194
512
  async fill(page, fields, credential, contact) {
513
+ const reveal = await revealCollapsedCardSection(page, fields);
195
514
  const filled = await fillFieldMap(page, fields, credential, contact);
515
+ const ok = filled.some((f) => f.role === 'number' && f.ok);
196
516
  return {
197
- ok: filled.some((f) => f.role === 'number' && f.ok),
517
+ ok,
198
518
  filled,
519
+ ...(ok || !reveal.revealed
520
+ ? {}
521
+ : {
522
+ detail: `revealed the card section via "${reveal.via}" but the number field still did not fill`,
523
+ }),
199
524
  };
200
525
  }
201
526
  }
@@ -2,6 +2,9 @@ import type { DetectResult } from '../detect.js';
2
2
  import type { CheckoutAdapter } from './generic.js';
3
3
  export type { CheckoutAdapter } from './generic.js';
4
4
  export { GenericAdapter } from './generic.js';
5
+ export { ShopifyAdapter } from './shopify.js';
5
6
  export { StripeLikeAdapter } from './stripe-like.js';
6
7
  export { fillFieldMap, resolveLocator } from './generic.js';
7
- export declare function selectAdapter(detected: DetectResult): CheckoutAdapter;
8
+ export declare function selectAdapter(detected: DetectResult, pageKind?: {
9
+ shopify?: boolean;
10
+ }): CheckoutAdapter;
@@ -2,14 +2,21 @@
2
2
  // the universal fallback and always matches last. Selection is a pure
3
3
  // function of an already-run detection — adapters never re-detect.
4
4
  import { GenericAdapter } from './generic.js';
5
+ import { ShopifyAdapter } from './shopify.js';
5
6
  import { StripeLikeAdapter } from './stripe-like.js';
6
7
  export { GenericAdapter } from './generic.js';
8
+ export { ShopifyAdapter } from './shopify.js';
7
9
  export { StripeLikeAdapter } from './stripe-like.js';
8
10
  export { fillFieldMap, resolveLocator } from './generic.js';
9
- const SPECIFIC = [new StripeLikeAdapter()];
10
11
  const FALLBACK = new GenericAdapter();
11
- export function selectAdapter(detected) {
12
- for (const a of SPECIFIC) {
12
+ export function selectAdapter(detected, pageKind = {}) {
13
+ // Shopify owns the outer checkout form and can still delegate card fields to
14
+ // an iframe, so it gets first crack and reuses fillFieldMap for both.
15
+ const shopify = new ShopifyAdapter(detected);
16
+ if (pageKind.shopify === true)
17
+ return shopify;
18
+ const specific = [shopify, new StripeLikeAdapter()];
19
+ for (const a of specific) {
13
20
  if (a.matches(detected))
14
21
  return a;
15
22
  }
@@ -0,0 +1,80 @@
1
+ import type { Page } from 'playwright-core';
2
+ import { type PageAmountRead } from '../amount.js';
3
+ import { type DetectResult, type FieldMap } from '../detect.js';
4
+ import type { CardCredential } from '../instrument.js';
5
+ import type { Contact, FillResult, FilledField } from '../types.js';
6
+ import { type CheckoutAdapter } from './generic.js';
7
+ type ShopifySummary = {
8
+ subtotalMinor: number | null;
9
+ shippingMinor: number | null;
10
+ taxMinor: number | null;
11
+ discountMinor: number;
12
+ totalMinor: number | null;
13
+ currency: string | null;
14
+ verified: boolean;
15
+ };
16
+ export declare function parseShopifySummary(text: string): ShopifySummary;
17
+ export declare function readShopifyAmount(page: Page, requireVerified: boolean): Promise<PageAmountRead>;
18
+ export declare function readStableShopifyAmount(page: Page, timeoutMs?: number): Promise<PageAmountRead>;
19
+ export declare function isShopifyCheckoutPage(page: Page): Promise<boolean>;
20
+ export type ShopifyCheckoutSurface = {
21
+ signal: string;
22
+ subtype: 'recognition' | 'authentication' | 'wallet';
23
+ };
24
+ export type ShopifyGuestCheckoutResult = {
25
+ status: 'guest-ready';
26
+ signal: 'guest-card-form';
27
+ } | {
28
+ status: 'transitioned';
29
+ signal: string;
30
+ } | {
31
+ status: 'action-required';
32
+ signal: string;
33
+ detail: string;
34
+ };
35
+ export declare function detectShopifyCheckoutSurface(page: Page): Promise<ShopifyCheckoutSurface | null>;
36
+ /**
37
+ * Resolve Shopify's reversible Shop-wallet takeover before review. The helper
38
+ * only clicks an exact guest action or a scoped accessible close control inside
39
+ * a positively identified Shop surface. It never solves authentication, uses a
40
+ * Shop-saved payment method, force-clicks, or mutates the DOM.
41
+ */
42
+ export declare function ensureShopifyGuestCheckout(page: Page, options?: {
43
+ timeoutMs?: number;
44
+ stableMs?: number;
45
+ }): Promise<ShopifyGuestCheckoutResult>;
46
+ /** Read-only assertion for the irreversible credential boundary. */
47
+ export declare function assertShopifyGuestCheckout(page: Page): Promise<ShopifyGuestCheckoutResult>;
48
+ /**
49
+ * The en-US variant of a localized Shopify checkout URL, or null when it is
50
+ * already English (or not locale-suffixed). Shopify renders the checkout in
51
+ * the URL's trailing locale segment, and amount reconciliation reads the
52
+ * order summary by its ENGLISH labels — a store whose primary market is not
53
+ * English serves /checkouts/cn/<token>/<locale> and the total never parses
54
+ * (observed live 2026-08-16: /es-us rendered "Precio total" and the review
55
+ * refused fail-closed on a good checkout). The locale segment is
56
+ * presentation-only: swapping it keeps the same checkout session and token.
57
+ */
58
+ export declare function shopifyEnglishCheckoutUrl(current: string): string | null;
59
+ export declare function missingContactRoles(filled: FilledField[], expected: string[]): string[];
60
+ /**
61
+ * The contact surface to prefill. Shipping fields when the checkout has them —
62
+ * but a digital-goods (no-shipping) Shopify checkout renders exactly one
63
+ * address block and marks every field autocomplete="billing ..." (observed
64
+ * live 2026-08-16: all 18 candidates billing-classified, so the shipping map
65
+ * came back empty and the prefill reported every role missing). That billing
66
+ * block IS the primary contact surface, under its base roles.
67
+ */
68
+ export declare function contactPrefillFieldMap(detected: DetectResult): {
69
+ fields: FieldMap;
70
+ surface: 'shipping' | 'billing-only';
71
+ };
72
+ export declare class ShopifyAdapter implements CheckoutAdapter {
73
+ private readonly detected;
74
+ name: string;
75
+ constructor(detected: DetectResult);
76
+ matches(detected: DetectResult): boolean;
77
+ prepareContact(page: Page, contact: Contact): Promise<FillResult>;
78
+ fill(page: Page, _fields: FieldMap, credential: CardCredential, _contact: Contact): Promise<FillResult>;
79
+ }
80
+ export {};