@visa/cli 4.1.0-rc.42 → 4.1.0-rc.44

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.
@@ -5,10 +5,14 @@ import type { Contact, FillResult, FilledField } from '../types.js';
5
5
  export interface CheckoutAdapter {
6
6
  name: string;
7
7
  matches(detected: DetectResult): boolean;
8
+ prepareContact?: (page: Page, contact: Contact) => Promise<FillResult>;
8
9
  fill(page: Page, fields: FieldMap, credential: CardCredential, contact: Contact): Promise<FillResult>;
9
10
  }
10
11
  export declare function resolveLocator(page: Page, entry: FieldEntry): Locator;
11
12
  export declare function scrubFillErrorMessage(message: string, value: string): string;
13
+ export declare function fillContactFieldMap(page: Page, fields: FieldMap, contact: Contact, opts?: {
14
+ fillTimeoutMs?: number;
15
+ }): Promise<FilledField[]>;
12
16
  export declare function fillFieldMap(page: Page, fields: FieldMap, credential: CardCredential, contact: Contact, opts?: {
13
17
  fillTimeoutMs?: number;
14
18
  }): Promise<FilledField[]>;
@@ -97,13 +97,12 @@ async function fillOne(page, role, entry, value, displayValue, fillTimeoutMs) {
97
97
  return { ...base, ok: false, error: scrubFillErrorMessage(err.message, value) };
98
98
  }
99
99
  }
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 = {}) {
100
+ async function fillFields(page, fields, credential, contact, opts = {}) {
103
101
  const fillTimeoutMs = opts.fillTimeoutMs ?? DEFAULT_FILL_TIMEOUT_MS;
104
102
  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(' ') ?? '';
103
+ const fullName = contact.fullName ?? credential?.cardholderName;
104
+ const first = contact.firstName ?? fullName?.split(/\s+/)[0];
105
+ const last = contact.lastName ?? fullName?.split(/\s+/).slice(1).join(' ');
107
106
  // Order matters a little: contact/name before card is harmless, but we fill
108
107
  // card fields explicitly per role so order is not load-bearing.
109
108
  const jobs = [];
@@ -120,26 +119,31 @@ export async function fillFieldMap(page, fields, credential, contact, opts = {})
120
119
  run: async () => fillOne(page, role, entry, value(), display(), fillTimeoutMs),
121
120
  });
122
121
  };
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));
122
+ if (credential) {
123
+ add('number', fields.number, () => credential.pan, () => maskPan(credential.pan));
124
+ add('cvc', fields.cvc, () => credential.cvc, () => maskCvc(credential.cvc));
125
+ }
126
+ if (fullName)
127
+ add('name', fields.name, () => fullName, () => redactContact('name', fullName));
128
+ if (first)
129
+ add('nameFirst', fields.nameFirst, () => first, () => redactContact('nameFirst', first));
130
+ if (last)
131
+ add('nameLast', fields.nameLast, () => last, () => redactContact('nameLast', last));
128
132
  // Expiry display values are always redacted: the expiry is part of the
129
133
  // keyable credential (DPAN + expiry + DAVV) and never enters the log.
130
- if (fields.expCombined) {
134
+ if (credential && fields.expCombined) {
131
135
  const e = fields.expCombined;
132
136
  const v = expCombinedValue(e, credential.expMonth, credential.expYear);
133
137
  add('expCombined', e, () => v, () => maskExpiry());
134
138
  }
135
- if (fields.expMonth) {
139
+ if (credential && fields.expMonth) {
136
140
  const e = fields.expMonth;
137
141
  const value = e.tag === 'select'
138
142
  ? (monthOptionValue(e.options ?? [], credential.expMonth) ?? pad2(credential.expMonth))
139
143
  : pad2(credential.expMonth);
140
144
  add('expMonth', e, () => value, () => maskExpiry());
141
145
  }
142
- if (fields.expYear) {
146
+ if (credential && fields.expYear) {
143
147
  const e = fields.expYear;
144
148
  let value;
145
149
  if (e.tag === 'select') {
@@ -157,6 +161,8 @@ export async function fillFieldMap(page, fields, credential, contact, opts = {})
157
161
  // the evidence log is built to be persistable.
158
162
  if (contact.email)
159
163
  add('email', fields.email, () => contact.email, () => redactContact('email', contact.email));
164
+ if (contact.phone)
165
+ add('phone', fields.phone, () => contact.phone, () => redactContact('phone', contact.phone));
160
166
  if (contact.addressLine1)
161
167
  add('addressLine1', fields.addressLine1, () => contact.addressLine1, () => redactContact('addressLine1', contact.addressLine1));
162
168
  if (contact.addressLine2)
@@ -185,6 +191,15 @@ export async function fillFieldMap(page, fields, credential, contact, opts = {})
185
191
  }
186
192
  return filled;
187
193
  }
194
+ // Credential-free contact prefill for merchants that must calculate shipping,
195
+ // tax, and the final total before a human reviews the payment.
196
+ export async function fillContactFieldMap(page, fields, contact, opts = {}) {
197
+ return fillFields(page, fields, null, contact, opts);
198
+ }
199
+ // The shared post-approval fill routine used by every adapter.
200
+ export async function fillFieldMap(page, fields, credential, contact, opts = {}) {
201
+ return fillFields(page, fields, credential, contact, opts);
202
+ }
188
203
  export class GenericAdapter {
189
204
  name = 'generic';
190
205
  matches(_detected) {
@@ -2,6 +2,7 @@ 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
8
  export declare function selectAdapter(detected: DetectResult): CheckoutAdapter;
@@ -2,14 +2,18 @@
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
12
  export function selectAdapter(detected) {
12
- for (const a of SPECIFIC) {
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 specific = [new ShopifyAdapter(detected), new StripeLikeAdapter()];
16
+ for (const a of specific) {
13
17
  if (a.matches(detected))
14
18
  return a;
15
19
  }
@@ -0,0 +1,31 @@
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 } 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 declare function detectShopifyChallenge(page: Page): Promise<{
21
+ signal: string;
22
+ } | null>;
23
+ export declare class ShopifyAdapter implements CheckoutAdapter {
24
+ private readonly detected;
25
+ name: string;
26
+ constructor(detected: DetectResult);
27
+ matches(detected: DetectResult): boolean;
28
+ prepareContact(page: Page, contact: Contact): Promise<FillResult>;
29
+ fill(page: Page, _fields: FieldMap, credential: CardCredential, _contact: Contact): Promise<FillResult>;
30
+ }
31
+ export {};
@@ -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,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
+ }