@visa/cli 4.1.0-rc.8 → 4.1.0-rc.80

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 (70) hide show
  1. package/README.md +178 -231
  2. package/dist/checkout-engine/adapters/generic.d.ts +23 -0
  3. package/dist/checkout-engine/adapters/generic.js +216 -0
  4. package/dist/checkout-engine/adapters/index.d.ts +8 -0
  5. package/dist/checkout-engine/adapters/index.js +21 -0
  6. package/dist/checkout-engine/adapters/shopify.d.ts +31 -0
  7. package/dist/checkout-engine/adapters/shopify.js +423 -0
  8. package/dist/checkout-engine/adapters/stripe-like.d.ts +10 -0
  9. package/dist/checkout-engine/adapters/stripe-like.js +21 -0
  10. package/dist/checkout-engine/amount.d.ts +15 -0
  11. package/dist/checkout-engine/amount.js +72 -0
  12. package/dist/checkout-engine/browser-launch.d.ts +46 -0
  13. package/dist/checkout-engine/browser-launch.js +81 -0
  14. package/dist/checkout-engine/ceremony.d.ts +64 -0
  15. package/dist/checkout-engine/ceremony.js +261 -0
  16. package/dist/checkout-engine/cli-engine.d.ts +214 -0
  17. package/dist/checkout-engine/cli-engine.js +701 -0
  18. package/dist/checkout-engine/detect.d.ts +61 -0
  19. package/dist/checkout-engine/detect.js +398 -0
  20. package/dist/checkout-engine/evidence.d.ts +25 -0
  21. package/dist/checkout-engine/evidence.js +104 -0
  22. package/dist/checkout-engine/executor.d.ts +176 -0
  23. package/dist/checkout-engine/executor.js +1322 -0
  24. package/dist/checkout-engine/hosted-approval.d.ts +142 -0
  25. package/dist/checkout-engine/hosted-approval.js +339 -0
  26. package/dist/checkout-engine/index.d.ts +6 -0
  27. package/dist/checkout-engine/index.js +8 -0
  28. package/dist/checkout-engine/inline-target.d.ts +13 -0
  29. package/dist/checkout-engine/inline-target.js +37 -0
  30. package/dist/checkout-engine/instrument.d.ts +61 -0
  31. package/dist/checkout-engine/instrument.js +87 -0
  32. package/dist/checkout-engine/live-fill-approval.d.ts +43 -0
  33. package/dist/checkout-engine/live-fill-approval.js +90 -0
  34. package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
  35. package/dist/checkout-engine/mandate/card-mandate.js +227 -0
  36. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +142 -0
  37. package/dist/checkout-engine/mandate/mandate-ledger.js +338 -0
  38. package/dist/checkout-engine/mandate.d.ts +25 -0
  39. package/dist/checkout-engine/mandate.js +100 -0
  40. package/dist/checkout-engine/outcome.d.ts +30 -0
  41. package/dist/checkout-engine/outcome.js +225 -0
  42. package/dist/checkout-engine/owner-only-file.d.ts +19 -0
  43. package/dist/checkout-engine/owner-only-file.js +41 -0
  44. package/dist/checkout-engine/package.json +3 -0
  45. package/dist/checkout-engine/receipt.d.ts +81 -0
  46. package/dist/checkout-engine/receipt.js +109 -0
  47. package/dist/checkout-engine/repo-env.d.ts +11 -0
  48. package/dist/checkout-engine/repo-env.js +23 -0
  49. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  50. package/dist/checkout-engine/trace-handles.js +12 -0
  51. package/dist/checkout-engine/types.d.ts +44 -0
  52. package/dist/checkout-engine/types.js +2 -0
  53. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +74 -0
  54. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +248 -0
  55. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +82 -0
  56. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +180 -0
  57. package/dist/checkout-engine/vgs-live-instrument.d.ts +170 -0
  58. package/dist/checkout-engine/vgs-live-instrument.js +293 -0
  59. package/dist/checkout-engine/vic-confirmation.d.ts +34 -0
  60. package/dist/checkout-engine/vic-confirmation.js +39 -0
  61. package/dist/cli.js +442 -433
  62. package/dist/mcp-server/index.js +360 -170
  63. package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -0
  64. package/dist/skills/pair-visa-agent/SKILL.md +447 -0
  65. package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
  66. package/install.ps1 +3 -41
  67. package/install.sh +3 -35
  68. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  69. package/package.json +16 -12
  70. package/server.json +3 -3
@@ -0,0 +1,423 @@
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
+ const verified = subtotalMinor != null &&
47
+ shippingMinor != null &&
48
+ taxMinor != null &&
49
+ totalMinor != null &&
50
+ subtotalMinor + shippingMinor + taxMinor - discountMinor === totalMinor;
51
+ return {
52
+ subtotalMinor,
53
+ shippingMinor,
54
+ taxMinor,
55
+ discountMinor,
56
+ totalMinor,
57
+ currency: pageCurrency(totalText ?? ''),
58
+ verified,
59
+ };
60
+ }
61
+ export async function readShopifyAmount(page, requireVerified) {
62
+ const text = await page.evaluate(() => document.body?.innerText ?? '').catch(() => '');
63
+ const summary = parseShopifySummary(text);
64
+ if (summary.totalMinor == null) {
65
+ return /\btotal\b/i.test(text)
66
+ ? { kind: 'unreadable', reason: 'Shopify total is present but unreadable' }
67
+ : { kind: 'none' };
68
+ }
69
+ if (requireVerified && !summary.verified) {
70
+ return {
71
+ kind: 'unreadable',
72
+ reason: 'Shopify tax and total do not form a complete, reconciled summary',
73
+ };
74
+ }
75
+ return {
76
+ kind: 'ok',
77
+ amountMinor: summary.totalMinor,
78
+ currency: summary.currency,
79
+ source: 'shopify-summary',
80
+ };
81
+ }
82
+ export async function readStableShopifyAmount(page, timeoutMs = 6_000) {
83
+ const startedAt = Date.now();
84
+ let previous = '';
85
+ let stableReads = 0;
86
+ while (Date.now() - startedAt < timeoutMs) {
87
+ const latest = await readShopifyAmount(page, true);
88
+ const fingerprint = JSON.stringify(latest);
89
+ stableReads = fingerprint === previous ? stableReads + 1 : 0;
90
+ if (latest.kind === 'ok' && stableReads >= 1)
91
+ return latest;
92
+ previous = fingerprint;
93
+ await page.waitForTimeout(200);
94
+ }
95
+ return {
96
+ kind: 'unreadable',
97
+ reason: 'Shopify tax and total did not settle before the review deadline',
98
+ };
99
+ }
100
+ export async function isShopifyCheckoutPage(page) {
101
+ return page
102
+ .evaluate(() => {
103
+ const payButton = document.querySelector('#checkout-pay-button');
104
+ const shopifyAddress = document.querySelector('[name^="checkout[shipping_address]"], [name^="checkout[billing_address]"]');
105
+ const shippingAddress = document.querySelector('[autocomplete~="shipping"][autocomplete~="address-line1"]');
106
+ return (Boolean(payButton) ||
107
+ Boolean(shopifyAddress) ||
108
+ (Boolean(window.Shopify) &&
109
+ (/\/checkouts?\//i.test(window.location.pathname) || Boolean(shippingAddress))));
110
+ })
111
+ .catch(() => false);
112
+ }
113
+ export async function detectShopifyChallenge(page) {
114
+ const visibleDialog = await page
115
+ .evaluate(() => Array.from(document.querySelectorAll('dialog, [role="dialog"], [aria-modal="true"]')).some((element) => {
116
+ const style = getComputedStyle(element);
117
+ if (element.offsetParent === null ||
118
+ style.display === 'none' ||
119
+ style.visibility === 'hidden') {
120
+ return false;
121
+ }
122
+ const text = element.innerText ?? '';
123
+ return (/\bshop pay\b/i.test(text) &&
124
+ /enter (?:the )?(?:verification|one[- ]?time)?\s*code|code (?:was |has been )?sent/i.test(text));
125
+ }))
126
+ .catch(() => false);
127
+ if (visibleDialog)
128
+ return { signal: 'body:shop-pay-code' };
129
+ const frames = page.locator('iframe');
130
+ for (let index = 0; index < (await frames.count().catch(() => 0)); index += 1) {
131
+ const frame = frames.nth(index);
132
+ if (!(await frame.isVisible().catch(() => false)))
133
+ continue;
134
+ const src = (await frame.getAttribute('src').catch(() => '')) ?? '';
135
+ if (!/shop\.app\/accounts\/login/i.test(src))
136
+ continue;
137
+ const accessibleName = [
138
+ await frame.getAttribute('title').catch(() => ''),
139
+ await frame.getAttribute('aria-label').catch(() => ''),
140
+ await frame.getAttribute('name').catch(() => ''),
141
+ ].join(' ');
142
+ if (/verification|one[- ]?time|code/i.test(accessibleName)) {
143
+ return { signal: 'frame:shop-pay-code' };
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+ const BILLING_ROLES = new Set([
149
+ 'name',
150
+ 'nameFirst',
151
+ 'nameLast',
152
+ 'addressLine1',
153
+ 'addressLine2',
154
+ 'city',
155
+ 'state',
156
+ 'postalCode',
157
+ 'country',
158
+ ]);
159
+ const CONTACT_ROLES = new Set([
160
+ 'email',
161
+ 'phone',
162
+ 'name',
163
+ 'nameFirst',
164
+ 'nameLast',
165
+ 'addressLine1',
166
+ 'addressLine2',
167
+ 'city',
168
+ 'state',
169
+ 'postalCode',
170
+ 'country',
171
+ ]);
172
+ const PAYMENT_ROLES = new Set([
173
+ 'number',
174
+ 'cvc',
175
+ 'name',
176
+ 'expCombined',
177
+ 'expMonth',
178
+ 'expYear',
179
+ ]);
180
+ const BILLING_ROLE_NAMES = {
181
+ name: 'billingName',
182
+ nameFirst: 'billingNameFirst',
183
+ nameLast: 'billingNameLast',
184
+ addressLine1: 'billingAddressLine1',
185
+ addressLine2: 'billingAddressLine2',
186
+ city: 'billingCity',
187
+ state: 'billingState',
188
+ postalCode: 'billingPostalCode',
189
+ country: 'billingCountry',
190
+ };
191
+ function candidateText(candidate) {
192
+ const meta = candidate.meta;
193
+ return [meta.name, meta.id, meta.autocomplete, meta.labelText, meta.placeholder, meta.ariaLabel]
194
+ .join(' ')
195
+ .toLowerCase();
196
+ }
197
+ function isShopifyCandidate(candidate) {
198
+ const text = candidateText(candidate);
199
+ return (/(?:^|[^a-z])shopify(?:[^a-z]|$)/i.test(text) ||
200
+ /checkout\[(?:shipping|billing)_address\]/i.test(text));
201
+ }
202
+ function isBillingCandidate(candidate) {
203
+ return /billing[_ -]?address|billingaddress|(?:^|[^a-z])billing(?:[^a-z]|$)/i.test(candidateText(candidate));
204
+ }
205
+ function isPaymentCandidate(candidate) {
206
+ return /cc-|cardholder|card holder|payment/i.test(candidateText(candidate));
207
+ }
208
+ function entryFromCandidate(candidate) {
209
+ if (!candidate.source)
210
+ return null;
211
+ return {
212
+ locator: `[data-ca-id="${candidate.meta.idx}"]`,
213
+ confidence: candidate.confidence,
214
+ source: candidate.source,
215
+ frame: candidate.frame,
216
+ tag: candidate.meta.tag,
217
+ inputType: candidate.meta.type,
218
+ // The billing control is revealed before these entries are filled.
219
+ visible: true,
220
+ options: candidate.meta.options.length ? candidate.meta.options : undefined,
221
+ maxlength: candidate.meta.maxlength ? Number.parseInt(candidate.meta.maxlength, 10) : undefined,
222
+ };
223
+ }
224
+ function billingFieldMap(detected) {
225
+ const fields = {};
226
+ for (const candidate of detected.candidates) {
227
+ const role = candidate.role;
228
+ if (!role || !BILLING_ROLES.has(role) || !isBillingCandidate(candidate))
229
+ continue;
230
+ const entry = entryFromCandidate(candidate);
231
+ if (!entry)
232
+ continue;
233
+ const existing = fields[role];
234
+ if (!existing || entry.confidence > existing.confidence)
235
+ fields[role] = entry;
236
+ }
237
+ return fields;
238
+ }
239
+ function shippingFieldMap(detected) {
240
+ const fields = {};
241
+ for (const candidate of detected.candidates) {
242
+ const role = candidate.role;
243
+ if (!role ||
244
+ !CONTACT_ROLES.has(role) ||
245
+ !candidate.meta.visible ||
246
+ isBillingCandidate(candidate) ||
247
+ isPaymentCandidate(candidate))
248
+ continue;
249
+ const entry = entryFromCandidate(candidate);
250
+ if (!entry)
251
+ continue;
252
+ const existing = fields[role];
253
+ if (!existing || entry.confidence > existing.confidence)
254
+ fields[role] = entry;
255
+ }
256
+ return fields;
257
+ }
258
+ function paymentFieldMap(detected) {
259
+ const fields = {};
260
+ for (const candidate of detected.candidates) {
261
+ const role = candidate.role;
262
+ if (!role || !PAYMENT_ROLES.has(role) || isBillingCandidate(candidate))
263
+ continue;
264
+ if (role === 'name' && !isPaymentCandidate(candidate))
265
+ continue;
266
+ const entry = entryFromCandidate(candidate);
267
+ if (!entry)
268
+ continue;
269
+ const existing = fields[role];
270
+ if (!existing || entry.confidence > existing.confidence)
271
+ fields[role] = entry;
272
+ }
273
+ return fields;
274
+ }
275
+ async function clickFirstVisible(locators) {
276
+ for (const locator of locators) {
277
+ const first = locator.first();
278
+ if ((await first.count().catch(() => 0)) < 1)
279
+ continue;
280
+ if (!(await first.isVisible().catch(() => false)))
281
+ continue;
282
+ await first.click();
283
+ return true;
284
+ }
285
+ return false;
286
+ }
287
+ async function hasVisibleBillingField(page) {
288
+ 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]');
289
+ for (let index = 0; index < (await fields.count()); index += 1) {
290
+ if (await fields
291
+ .nth(index)
292
+ .isVisible()
293
+ .catch(() => false))
294
+ return true;
295
+ }
296
+ return false;
297
+ }
298
+ async function waitForVisibleBillingField(page) {
299
+ if (await hasVisibleBillingField(page))
300
+ return true;
301
+ return page
302
+ .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) => {
303
+ const style = window.getComputedStyle(element);
304
+ return (style.display !== 'none' &&
305
+ style.visibility !== 'hidden' &&
306
+ element.getClientRects().length > 0);
307
+ }), undefined, { timeout: 2_000 })
308
+ .then(() => true)
309
+ .catch(() => false);
310
+ }
311
+ async function revealSeparateBilling(page) {
312
+ if (await hasVisibleBillingField(page))
313
+ return true;
314
+ const roleRadio = page
315
+ .getByRole('radio', { name: /different billing|use a different billing/i })
316
+ .first();
317
+ if ((await roleRadio.count().catch(() => 0)) > 0 &&
318
+ (await roleRadio.isVisible().catch(() => false))) {
319
+ await roleRadio.check();
320
+ return waitForVisibleBillingField(page);
321
+ }
322
+ if (await clickFirstVisible([
323
+ page.locator('input[name="billing_address_selector"][value="billing_address"]'),
324
+ page.locator('input[type="radio"][name*="billing" i][value*="different" i], input[type="radio"][name*="billing" i][value*="billing" i]'),
325
+ ])) {
326
+ return waitForVisibleBillingField(page);
327
+ }
328
+ const sameAsShipping = page
329
+ .getByRole('checkbox', { name: /same as shipping|use shipping address/i })
330
+ .first();
331
+ if ((await sameAsShipping.count().catch(() => 0)) > 0 &&
332
+ (await sameAsShipping.isVisible().catch(() => false))) {
333
+ if (await sameAsShipping.isChecked().catch(() => false))
334
+ await sameAsShipping.uncheck();
335
+ return waitForVisibleBillingField(page);
336
+ }
337
+ return false;
338
+ }
339
+ function billingContact(address) {
340
+ return {
341
+ firstName: address.firstName,
342
+ lastName: address.lastName,
343
+ fullName: address.fullName,
344
+ addressLine1: address.addressLine1,
345
+ addressLine2: address.addressLine2,
346
+ city: address.city,
347
+ state: address.state,
348
+ postalCode: address.postalCode,
349
+ country: address.country,
350
+ };
351
+ }
352
+ function labelBillingEvidence(filled) {
353
+ return filled.map((field) => ({
354
+ ...field,
355
+ role: BILLING_ROLE_NAMES[field.role] ?? `billing:${field.role}`,
356
+ }));
357
+ }
358
+ function requiredAddressRoles(address, prefix = '') {
359
+ const role = (name) => `${prefix}${prefix ? name[0].toUpperCase() + name.slice(1) : name}`;
360
+ const regionRequired = ['US', 'CA', 'AU'].includes(address.country?.toUpperCase() ?? '');
361
+ return [
362
+ ...(address.firstName ? [role('nameFirst')] : []),
363
+ ...(address.lastName ? [role('nameLast')] : []),
364
+ ...(!address.firstName && !address.lastName && address.fullName ? [role('name')] : []),
365
+ ...(address.addressLine1 ? [role('addressLine1')] : []),
366
+ ...(address.addressLine2 ? [role('addressLine2')] : []),
367
+ ...(address.city ? [role('city')] : []),
368
+ ...(address.state && regionRequired ? [role('state')] : []),
369
+ ...(address.postalCode ? [role('postalCode')] : []),
370
+ ...(address.country ? [role('country')] : []),
371
+ ];
372
+ }
373
+ function missingRoles(filled, expected) {
374
+ const successful = new Set(filled.filter((field) => field.ok).map((field) => field.role));
375
+ return expected.filter((role) => !successful.has(role));
376
+ }
377
+ export class ShopifyAdapter {
378
+ detected;
379
+ name = 'shopify';
380
+ constructor(detected) {
381
+ this.detected = detected;
382
+ }
383
+ matches(detected) {
384
+ return detected.candidates.some(isShopifyCandidate);
385
+ }
386
+ async prepareContact(page, contact) {
387
+ const shippingFilled = await fillContactFieldMap(page, shippingFieldMap(this.detected), contact);
388
+ const shippingExpected = [...requiredAddressRoles(contact), ...(contact.phone ? ['phone'] : [])];
389
+ const shippingMissing = missingRoles(shippingFilled, shippingExpected);
390
+ if (!contact.billingAddress) {
391
+ return {
392
+ ok: shippingMissing.length === 0,
393
+ filled: shippingFilled,
394
+ ...(shippingMissing.length
395
+ ? { detail: `Shopify shipping prefill incomplete: missing ${shippingMissing.join(', ')}` }
396
+ : {}),
397
+ };
398
+ }
399
+ if (!(await revealSeparateBilling(page))) {
400
+ return {
401
+ ok: false,
402
+ filled: shippingFilled,
403
+ detail: 'Shopify billing address fields did not become visible during prefill',
404
+ };
405
+ }
406
+ const billingFields = billingFieldMap(await detectFields(page));
407
+ const billingFilled = labelBillingEvidence(await fillContactFieldMap(page, billingFields, billingContact(contact.billingAddress)));
408
+ const billingExpected = requiredAddressRoles(contact.billingAddress, 'billing');
409
+ const missing = [...shippingMissing, ...missingRoles(billingFilled, billingExpected)];
410
+ return {
411
+ ok: missing.length === 0,
412
+ filled: [...shippingFilled, ...billingFilled],
413
+ ...(missing.length
414
+ ? { detail: `Shopify contact prefill incomplete: missing ${missing.join(', ')}` }
415
+ : {}),
416
+ };
417
+ }
418
+ async fill(page, _fields, credential, _contact) {
419
+ const filled = await fillFieldMap(page, paymentFieldMap(this.detected), credential, {});
420
+ const numberOk = filled.some((field) => field.role === 'number' && field.ok);
421
+ return { ok: numberOk, filled };
422
+ }
423
+ }
@@ -0,0 +1,10 @@
1
+ import type { Page } from 'playwright-core';
2
+ import type { DetectResult, FieldMap } from '../detect.js';
3
+ import type { CardCredential } from '../instrument.js';
4
+ import type { Contact, FillResult } from '../types.js';
5
+ import { type CheckoutAdapter } from './generic.js';
6
+ export declare class StripeLikeAdapter implements CheckoutAdapter {
7
+ name: string;
8
+ matches(detected: DetectResult): boolean;
9
+ fill(page: Page, fields: FieldMap, credential: CardCredential, contact: Contact): Promise<FillResult>;
10
+ }
@@ -0,0 +1,21 @@
1
+ // Stripe-like adapter. Matches checkouts whose card fields live inside a
2
+ // same-origin iframe laid out like Stripe Elements (a card-number frame, an
3
+ // expiry frame, a cvc frame, or one combined frame). It fills through
4
+ // Playwright frameLocator. For M0 only same-origin test iframes are fillable;
5
+ // a real cross-origin PSP iframe is detected upstream and reported as
6
+ // requiresAdapter instead.
7
+ import { fillFieldMap } from './generic.js';
8
+ export class StripeLikeAdapter {
9
+ name = 'stripe-like';
10
+ matches(detected) {
11
+ // We take this adapter when the card number was found inside a frame.
12
+ return Boolean(detected.fields.number?.frame);
13
+ }
14
+ async fill(page, fields, credential, contact) {
15
+ const filled = await fillFieldMap(page, fields, credential, contact);
16
+ return {
17
+ ok: filled.some((f) => f.role === 'number' && f.ok),
18
+ filled,
19
+ };
20
+ }
21
+ }
@@ -0,0 +1,15 @@
1
+ import type { Page } from 'playwright-core';
2
+ export declare function minorFromDecimal(text: string): number | null;
3
+ export declare function pageCurrency(text: string): string | null;
4
+ export type PageAmountRead = {
5
+ kind: 'none';
6
+ } | {
7
+ kind: 'unreadable';
8
+ reason?: string;
9
+ } | {
10
+ kind: 'ok';
11
+ amountMinor: number;
12
+ currency: string | null;
13
+ source: 'page-attr' | 'page-text' | 'shopify-summary';
14
+ };
15
+ export declare function readGenericPageAmount(page: Page): Promise<PageAmountRead>;
@@ -0,0 +1,72 @@
1
+ // Convert a human decimal like "$50.00" to integer minor units without
2
+ // floats. Fail closed on separator ambiguity. Only 2-decimal currencies are
3
+ // supported (matching pageCurrency's allowlist).
4
+ export function minorFromDecimal(text) {
5
+ const match = text.match(/\d[\d.,]*/);
6
+ if (!match)
7
+ return null;
8
+ const token = match[0].replace(/[.,]+$/, '');
9
+ if (/^\d+$/.test(token))
10
+ return Number.parseInt(token, 10) * 100;
11
+ if (/^\d{1,3}(,\d{3})+\.\d{2}$/.test(token)) {
12
+ const [whole, fraction] = token.replace(/,/g, '').split('.');
13
+ return Number.parseInt(whole, 10) * 100 + Number.parseInt(fraction, 10);
14
+ }
15
+ if (/^\d{1,3}(\.\d{3})+,\d{2}$/.test(token)) {
16
+ const [whole, fraction] = token.replace(/\./g, '').split(',');
17
+ return Number.parseInt(whole, 10) * 100 + Number.parseInt(fraction, 10);
18
+ }
19
+ if (/^\d+\.\d{1,2}$/.test(token)) {
20
+ const [whole, fraction] = token.split('.');
21
+ return Number.parseInt(whole, 10) * 100 + Number.parseInt(fraction.padEnd(2, '0'), 10);
22
+ }
23
+ if (/^\d+,\d{1,2}$/.test(token)) {
24
+ const [whole, fraction] = token.split(',');
25
+ return Number.parseInt(whole, 10) * 100 + Number.parseInt(fraction.padEnd(2, '0'), 10);
26
+ }
27
+ return null;
28
+ }
29
+ const ISO_CURRENCIES = ['USD', 'EUR', 'GBP', 'CAD', 'AUD', 'CHF', 'NZD'];
30
+ export function pageCurrency(text) {
31
+ const iso = text.match(/\b([A-Z]{3})\b/);
32
+ if (iso && ISO_CURRENCIES.includes(iso[1]))
33
+ return iso[1];
34
+ if (text.includes('€'))
35
+ return 'EUR';
36
+ if (text.includes('£'))
37
+ return 'GBP';
38
+ return null;
39
+ }
40
+ export async function readGenericPageAmount(page) {
41
+ const explicitLocator = page.locator('[data-total-minor]').first();
42
+ if ((await explicitLocator.count().catch(() => 0)) > 0) {
43
+ const explicit = await explicitLocator.getAttribute('data-total-minor').catch(() => null);
44
+ if (explicit && /^\d+$/.test(explicit)) {
45
+ const text = (await explicitLocator.textContent().catch(() => null)) ?? '';
46
+ return {
47
+ kind: 'ok',
48
+ amountMinor: Number.parseInt(explicit, 10),
49
+ currency: pageCurrency(text),
50
+ source: 'page-attr',
51
+ };
52
+ }
53
+ }
54
+ const totalLocator = page
55
+ .locator('#order-total, .order-total, [data-testid="order-total"]')
56
+ .first();
57
+ if ((await totalLocator.count().catch(() => 0)) > 0) {
58
+ const totalText = await totalLocator.textContent().catch(() => null);
59
+ if (totalText && /\d/.test(totalText)) {
60
+ const amountMinor = minorFromDecimal(totalText);
61
+ if (amountMinor == null)
62
+ return { kind: 'unreadable' };
63
+ return {
64
+ kind: 'ok',
65
+ amountMinor,
66
+ currency: pageCurrency(totalText),
67
+ source: 'page-text',
68
+ };
69
+ }
70
+ }
71
+ return { kind: 'none' };
72
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Browser provisioning for the checkout engine.
3
+ *
4
+ * @purpose The engine depends on `playwright-core` (NOT `playwright`) so that
5
+ * installing @visa/cli never triggers a postinstall Chromium download. A real
6
+ * browser is resolved lazily, on the first checkout, in this order:
7
+ *
8
+ * 1. VISA_CHECKOUT_BROWSER — explicit executable path override (deterministic
9
+ * for CI / pinned environments; wins over everything). If it is set but
10
+ * FAILS to launch, we ABORT loudly rather than fall through — a pinned
11
+ * money flow must not run in a browser the operator did not choose.
12
+ * 2. channel: 'chrome' — a system-installed Google Chrome (no download).
13
+ * 3. channel: 'msedge' — a system-installed Microsoft Edge (no download).
14
+ * 4. default chromium — a playwright-managed browser, IF one was already
15
+ * installed via `npx playwright-core install chromium` (still no download
16
+ * here — it only USES an existing one; launch throws if absent).
17
+ *
18
+ * If none launch, we throw one actionable error listing every attempt and the
19
+ * three ways to fix it. We never silently download hundreds of MB mid-command;
20
+ * provisioning stays an explicit user choice (install Chrome, run the documented
21
+ * `playwright-core install`, or point VISA_CHECKOUT_BROWSER at a binary).
22
+ *
23
+ * The install command is `playwright-core`, NOT `playwright`: this package no
24
+ * longer depends on the full `playwright`, so `npx playwright …` would fetch an
25
+ * unrelated latest `playwright` and can install a browser revision that does not
26
+ * match the pinned `playwright-core` — the very launch this is meant to fix.
27
+ * `npx playwright-core install` uses the installed, version-matched package.
28
+ */
29
+ import { type Browser, type LaunchOptions } from 'playwright-core';
30
+ export type LaunchCheckoutBrowserDeps = {
31
+ env?: NodeJS.ProcessEnv;
32
+ /** Injectable launcher (default: playwright-core chromium.launch) — tests
33
+ * pass a fake to assert the fallback order without a real browser. */
34
+ launch?: (opts: LaunchOptions) => Promise<Browser>;
35
+ log?: (msg: string) => void;
36
+ };
37
+ type Attempt = {
38
+ label: string;
39
+ opts: LaunchOptions;
40
+ override?: boolean;
41
+ };
42
+ /** Build the ordered launch attempts for the current environment. Exported for
43
+ * unit testing the resolution order without launching anything. */
44
+ export declare function buildLaunchAttempts(env: NodeJS.ProcessEnv): Attempt[];
45
+ export declare function launchCheckoutBrowser(deps?: LaunchCheckoutBrowserDeps): Promise<Browser>;
46
+ export {};
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Browser provisioning for the checkout engine.
3
+ *
4
+ * @purpose The engine depends on `playwright-core` (NOT `playwright`) so that
5
+ * installing @visa/cli never triggers a postinstall Chromium download. A real
6
+ * browser is resolved lazily, on the first checkout, in this order:
7
+ *
8
+ * 1. VISA_CHECKOUT_BROWSER — explicit executable path override (deterministic
9
+ * for CI / pinned environments; wins over everything). If it is set but
10
+ * FAILS to launch, we ABORT loudly rather than fall through — a pinned
11
+ * money flow must not run in a browser the operator did not choose.
12
+ * 2. channel: 'chrome' — a system-installed Google Chrome (no download).
13
+ * 3. channel: 'msedge' — a system-installed Microsoft Edge (no download).
14
+ * 4. default chromium — a playwright-managed browser, IF one was already
15
+ * installed via `npx playwright-core install chromium` (still no download
16
+ * here — it only USES an existing one; launch throws if absent).
17
+ *
18
+ * If none launch, we throw one actionable error listing every attempt and the
19
+ * three ways to fix it. We never silently download hundreds of MB mid-command;
20
+ * provisioning stays an explicit user choice (install Chrome, run the documented
21
+ * `playwright-core install`, or point VISA_CHECKOUT_BROWSER at a binary).
22
+ *
23
+ * The install command is `playwright-core`, NOT `playwright`: this package no
24
+ * longer depends on the full `playwright`, so `npx playwright …` would fetch an
25
+ * unrelated latest `playwright` and can install a browser revision that does not
26
+ * match the pinned `playwright-core` — the very launch this is meant to fix.
27
+ * `npx playwright-core install` uses the installed, version-matched package.
28
+ */
29
+ import { chromium } from 'playwright-core';
30
+ const HEADFUL = { headless: false };
31
+ /** Build the ordered launch attempts for the current environment. Exported for
32
+ * unit testing the resolution order without launching anything. */
33
+ export function buildLaunchAttempts(env) {
34
+ const attempts = [];
35
+ const override = env.VISA_CHECKOUT_BROWSER?.trim();
36
+ if (override) {
37
+ attempts.push({
38
+ label: `VISA_CHECKOUT_BROWSER (${override})`,
39
+ opts: { ...HEADFUL, executablePath: override },
40
+ override: true,
41
+ });
42
+ }
43
+ attempts.push({
44
+ label: 'system Chrome (channel=chrome)',
45
+ opts: { ...HEADFUL, channel: 'chrome' },
46
+ });
47
+ attempts.push({ label: 'system Edge (channel=msedge)', opts: { ...HEADFUL, channel: 'msedge' } });
48
+ attempts.push({ label: 'playwright-managed chromium', opts: { ...HEADFUL } });
49
+ return attempts;
50
+ }
51
+ export async function launchCheckoutBrowser(deps = {}) {
52
+ const env = deps.env ?? process.env;
53
+ const launch = deps.launch ?? ((opts) => chromium.launch(opts));
54
+ const log = deps.log ?? (() => { });
55
+ const errors = [];
56
+ for (const attempt of buildLaunchAttempts(env)) {
57
+ try {
58
+ const browser = await launch(attempt.opts);
59
+ log(`checkout browser: launched via ${attempt.label}`);
60
+ return browser;
61
+ }
62
+ catch (err) {
63
+ const first = err.message.split('\n')[0];
64
+ if (attempt.override) {
65
+ // An explicit VISA_CHECKOUT_BROWSER is a deterministic, pinned choice
66
+ // (CI / a locked-down operator env). If it fails to launch, ABORT loudly
67
+ // rather than silently substituting a system browser the operator did
68
+ // not pin — this is a money flow. Unsetting the var opts back into the
69
+ // system-browser fallback below.
70
+ throw new Error(`VISA_CHECKOUT_BROWSER="${env.VISA_CHECKOUT_BROWSER?.trim()}" failed to launch: ${first}. ` +
71
+ 'Fix the path/binary, or unset VISA_CHECKOUT_BROWSER to allow a system-browser ' +
72
+ 'fallback — refusing to substitute an unpinned browser for a checkout.');
73
+ }
74
+ errors.push(`${attempt.label}: ${first}`);
75
+ }
76
+ }
77
+ throw new Error('Could not launch a browser for checkout. Tried — ' +
78
+ errors.join(' | ') +
79
+ '. Install Google Chrome, or run `npx playwright-core install chromium` ' +
80
+ '(matches the pinned version), or set VISA_CHECKOUT_BROWSER to a browser executable path.');
81
+ }