@visa/cli 4.1.0-rc.21 → 4.1.0-rc.211
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -226
- package/dist/checkout-engine/adapters/generic.d.ts +69 -0
- package/dist/checkout-engine/adapters/generic.js +383 -58
- package/dist/checkout-engine/adapters/index.d.ts +4 -1
- package/dist/checkout-engine/adapters/index.js +10 -3
- package/dist/checkout-engine/adapters/shopify.d.ts +55 -0
- package/dist/checkout-engine/adapters/shopify.js +514 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/cli-engine.d.ts +264 -4
- package/dist/checkout-engine/cli-engine.js +803 -43
- package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
- package/dist/checkout-engine/confirmed-merchants.js +165 -0
- package/dist/checkout-engine/detect.d.ts +1 -1
- package/dist/checkout-engine/detect.js +26 -0
- package/dist/checkout-engine/evidence.d.ts +4 -1
- package/dist/checkout-engine/evidence.js +51 -6
- package/dist/checkout-engine/executor.d.ts +47 -4
- package/dist/checkout-engine/executor.js +418 -131
- package/dist/checkout-engine/hosted-approval.d.ts +124 -7
- package/dist/checkout-engine/hosted-approval.js +381 -54
- package/dist/checkout-engine/index.d.ts +8 -2
- package/dist/checkout-engine/index.js +7 -1
- package/dist/checkout-engine/instrument.d.ts +7 -0
- package/dist/checkout-engine/instrument.js +4 -0
- package/dist/checkout-engine/known-merchants.d.ts +10 -0
- package/dist/checkout-engine/known-merchants.js +38 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +5 -20
- package/dist/checkout-engine/live-fill-approval.js +20 -51
- package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
- package/dist/checkout-engine/mandate/card-mandate.js +226 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +174 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +410 -0
- package/dist/checkout-engine/outcome.d.ts +2 -2
- package/dist/checkout-engine/outcome.js +36 -1
- package/dist/checkout-engine/owner-only-file.d.ts +9 -0
- package/dist/checkout-engine/owner-only-file.js +20 -1
- package/dist/checkout-engine/receipt-dir.d.ts +6 -0
- package/dist/checkout-engine/receipt-dir.js +8 -0
- package/dist/checkout-engine/receipt.d.ts +42 -2
- package/dist/checkout-engine/receipt.js +43 -14
- package/dist/checkout-engine/trace-handles.d.ts +8 -0
- package/dist/checkout-engine/trace-handles.js +12 -0
- package/dist/checkout-engine/types.d.ts +28 -2
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +125 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +53 -1
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +78 -10
- package/dist/checkout-engine/vgs-live-instrument.d.ts +38 -35
- package/dist/checkout-engine/vgs-live-instrument.js +51 -74
- package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
- package/dist/checkout-engine/vic-confirmation.js +9 -3
- package/dist/checkout-engine/web-bot-auth.d.ts +92 -0
- package/dist/checkout-engine/web-bot-auth.js +159 -0
- package/dist/cli.js +772 -496
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +637 -175
- package/dist/skills/pair-visa-agent/RUNTIMES.md +93 -0
- package/dist/skills/pair-visa-agent/SKILL.md +479 -221
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +5 -43
- package/install.sh +5 -37
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +33 -27
- package/server.json +4 -4
- package/dist/checkout-engine/inline-target.d.ts +0 -13
- package/dist/checkout-engine/inline-target.js +0 -37
- package/dist/checkout-engine/pay-args.d.ts +0 -14
- package/dist/checkout-engine/pay-args.js +0 -44
- package/dist/checkout-engine/pay.d.ts +0 -1
- package/dist/checkout-engine/pay.js +0 -13
- package/dist/checkout-engine/repo-env.d.ts +0 -11
- package/dist/checkout-engine/repo-env.js +0 -23
- package/dist/checkout-engine/run-live-fill.d.ts +0 -1
- package/dist/checkout-engine/run-live-fill.js +0 -443
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
- package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// Shopify checkout adapter. Shopify commonly renders shipping first and keeps
|
|
2
|
+
// a distinct billing address collapsed behind a radio/checkbox. Detection runs
|
|
3
|
+
// once before adapter selection, so this adapter reuses the detected candidate
|
|
4
|
+
// locators, reveals the billing surface, and fills it without a second scan.
|
|
5
|
+
import { minorFromDecimal, pageCurrency } from '../amount.js';
|
|
6
|
+
import { detectFields, } from '../detect.js';
|
|
7
|
+
import { fillContactFieldMap, fillFieldMap } from './generic.js';
|
|
8
|
+
const SUMMARY_LABELS = /^(Subtotal|Shipping|Estimated taxes|Taxes|Tax|Discounts?|Total)(?:\s*:?\s+((?:[A-Z]{3}\s+)?-?[$€£]?\s*\d[\d.,]*|Free))?$/i;
|
|
9
|
+
function valueAfterLabel(lines, index, inline) {
|
|
10
|
+
if (inline)
|
|
11
|
+
return inline;
|
|
12
|
+
return lines.slice(index + 1, index + 4).find((line) => /\d|free/i.test(line)) ?? '';
|
|
13
|
+
}
|
|
14
|
+
export function parseShopifySummary(text) {
|
|
15
|
+
const lines = text
|
|
16
|
+
.split(/\n+/)
|
|
17
|
+
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
const values = new Map();
|
|
20
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
21
|
+
const match = lines[index].match(SUMMARY_LABELS);
|
|
22
|
+
if (!match)
|
|
23
|
+
continue;
|
|
24
|
+
const key = match[1].toLowerCase();
|
|
25
|
+
const value = valueAfterLabel(lines, index, match[2]);
|
|
26
|
+
values.set(key, [...(values.get(key) ?? []), value]);
|
|
27
|
+
}
|
|
28
|
+
const uniqueValue = (...keys) => {
|
|
29
|
+
const found = keys.flatMap((key) => values.get(key) ?? []).filter(Boolean);
|
|
30
|
+
const unique = [...new Set(found)];
|
|
31
|
+
return unique.length === 1 ? unique[0] : undefined;
|
|
32
|
+
};
|
|
33
|
+
const parse = (value) => {
|
|
34
|
+
if (!value)
|
|
35
|
+
return null;
|
|
36
|
+
if (/\bfree\b/i.test(value))
|
|
37
|
+
return 0;
|
|
38
|
+
return minorFromDecimal(value);
|
|
39
|
+
};
|
|
40
|
+
const subtotalMinor = parse(uniqueValue('subtotal'));
|
|
41
|
+
const shippingMinor = parse(uniqueValue('shipping'));
|
|
42
|
+
const taxMinor = parse(uniqueValue('estimated taxes', 'taxes', 'tax'));
|
|
43
|
+
const discountMinor = parse(uniqueValue('discount', 'discounts')) ?? 0;
|
|
44
|
+
const totalText = uniqueValue('total');
|
|
45
|
+
const totalMinor = parse(totalText);
|
|
46
|
+
// A digital-goods checkout renders no component rows at all — just a total
|
|
47
|
+
// ("Cost summary / Total / USD $6.00", observed live 2026-08-16), so there
|
|
48
|
+
// is nothing to reconcile against and a parsed total stands on its own. The
|
|
49
|
+
// moment ANY component label parsed by SUMMARY_LABELS appears — including a
|
|
50
|
+
// pending "Calculated at next step" — the full reconciliation is required again,
|
|
51
|
+
// so a mid-render pre-tax total still refuses.
|
|
52
|
+
// The discount-code entry form puts a bare "Discount" label on every
|
|
53
|
+
// checkout, value or not, and discount already defaults to 0 — only the
|
|
54
|
+
// additive components force reconciliation.
|
|
55
|
+
const componentLabelPresent = [...values.keys()].some((key) => key !== 'total' && key !== 'discount' && key !== 'discounts');
|
|
56
|
+
const verified = componentLabelPresent
|
|
57
|
+
? subtotalMinor != null &&
|
|
58
|
+
shippingMinor != null &&
|
|
59
|
+
taxMinor != null &&
|
|
60
|
+
totalMinor != null &&
|
|
61
|
+
subtotalMinor + shippingMinor + taxMinor - discountMinor === totalMinor
|
|
62
|
+
: totalMinor != null;
|
|
63
|
+
return {
|
|
64
|
+
subtotalMinor,
|
|
65
|
+
shippingMinor,
|
|
66
|
+
taxMinor,
|
|
67
|
+
discountMinor,
|
|
68
|
+
totalMinor,
|
|
69
|
+
currency: pageCurrency(totalText ?? ''),
|
|
70
|
+
verified,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export async function readShopifyAmount(page, requireVerified) {
|
|
74
|
+
const text = await page.evaluate(() => document.body?.innerText ?? '').catch(() => '');
|
|
75
|
+
const summary = parseShopifySummary(text);
|
|
76
|
+
if (summary.totalMinor == null) {
|
|
77
|
+
return /\btotal\b/i.test(text)
|
|
78
|
+
? { kind: 'unreadable', reason: 'Shopify total is present but unreadable' }
|
|
79
|
+
: { kind: 'none' };
|
|
80
|
+
}
|
|
81
|
+
if (requireVerified && !summary.verified) {
|
|
82
|
+
return {
|
|
83
|
+
kind: 'unreadable',
|
|
84
|
+
reason: 'Shopify tax and total do not form a complete, reconciled summary',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
kind: 'ok',
|
|
89
|
+
amountMinor: summary.totalMinor,
|
|
90
|
+
currency: summary.currency,
|
|
91
|
+
source: 'shopify-summary',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export async function readStableShopifyAmount(page, timeoutMs = 6_000) {
|
|
95
|
+
const startedAt = Date.now();
|
|
96
|
+
let previous = '';
|
|
97
|
+
let stableReads = 0;
|
|
98
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
99
|
+
const latest = await readShopifyAmount(page, true);
|
|
100
|
+
const fingerprint = JSON.stringify(latest);
|
|
101
|
+
stableReads = fingerprint === previous ? stableReads + 1 : 0;
|
|
102
|
+
if (latest.kind === 'ok' && stableReads >= 1)
|
|
103
|
+
return latest;
|
|
104
|
+
previous = fingerprint;
|
|
105
|
+
await page.waitForTimeout(200);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
kind: 'unreadable',
|
|
109
|
+
reason: 'Shopify tax and total did not settle before the review deadline',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export async function isShopifyCheckoutPage(page) {
|
|
113
|
+
return page
|
|
114
|
+
.evaluate(() => {
|
|
115
|
+
const payButton = document.querySelector('#checkout-pay-button');
|
|
116
|
+
const shopifyAddress = document.querySelector('[name^="checkout[shipping_address]"], [name^="checkout[billing_address]"]');
|
|
117
|
+
const shippingAddress = document.querySelector('[autocomplete~="shipping"][autocomplete~="address-line1"]');
|
|
118
|
+
return (Boolean(payButton) ||
|
|
119
|
+
Boolean(shopifyAddress) ||
|
|
120
|
+
(Boolean(window.Shopify) &&
|
|
121
|
+
(/\/checkouts?\//i.test(window.location.pathname) || Boolean(shippingAddress))));
|
|
122
|
+
})
|
|
123
|
+
.catch(() => false);
|
|
124
|
+
}
|
|
125
|
+
export async function detectShopifyChallenge(page) {
|
|
126
|
+
const visibleDialog = await page
|
|
127
|
+
.evaluate(() => Array.from(document.querySelectorAll('dialog, [role="dialog"], [aria-modal="true"]')).some((element) => {
|
|
128
|
+
const style = getComputedStyle(element);
|
|
129
|
+
if (element.offsetParent === null ||
|
|
130
|
+
style.display === 'none' ||
|
|
131
|
+
style.visibility === 'hidden') {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
const text = element.innerText ?? '';
|
|
135
|
+
return (/\bshop pay\b/i.test(text) &&
|
|
136
|
+
/enter (?:the )?(?:verification|one[- ]?time)?\s*code|code (?:was |has been )?sent/i.test(text));
|
|
137
|
+
}))
|
|
138
|
+
.catch(() => false);
|
|
139
|
+
if (visibleDialog)
|
|
140
|
+
return { signal: 'body:shop-pay-code' };
|
|
141
|
+
const frames = page.locator('iframe');
|
|
142
|
+
for (let index = 0; index < (await frames.count().catch(() => 0)); index += 1) {
|
|
143
|
+
const frame = frames.nth(index);
|
|
144
|
+
if (!(await frame.isVisible().catch(() => false)))
|
|
145
|
+
continue;
|
|
146
|
+
const src = (await frame.getAttribute('src').catch(() => '')) ?? '';
|
|
147
|
+
if (!/shop\.app\/accounts\/login/i.test(src))
|
|
148
|
+
continue;
|
|
149
|
+
const accessibleName = [
|
|
150
|
+
await frame.getAttribute('title').catch(() => ''),
|
|
151
|
+
await frame.getAttribute('aria-label').catch(() => ''),
|
|
152
|
+
await frame.getAttribute('name').catch(() => ''),
|
|
153
|
+
].join(' ');
|
|
154
|
+
if (/verification|one[- ]?time|code/i.test(accessibleName)) {
|
|
155
|
+
return { signal: 'frame:shop-pay-code' };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The en-US variant of a localized Shopify checkout URL, or null when it is
|
|
162
|
+
* already English (or not locale-suffixed). Shopify renders the checkout in
|
|
163
|
+
* the URL's trailing locale segment, and amount reconciliation reads the
|
|
164
|
+
* order summary by its ENGLISH labels — a store whose primary market is not
|
|
165
|
+
* English serves /checkouts/cn/<token>/<locale> and the total never parses
|
|
166
|
+
* (observed live 2026-08-16: /es-us rendered "Precio total" and the review
|
|
167
|
+
* refused fail-closed on a good checkout). The locale segment is
|
|
168
|
+
* presentation-only: swapping it keeps the same checkout session and token.
|
|
169
|
+
*/
|
|
170
|
+
export function shopifyEnglishCheckoutUrl(current) {
|
|
171
|
+
let url;
|
|
172
|
+
try {
|
|
173
|
+
url = new URL(current);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const match = url.pathname.match(/^(\/checkouts\/[^?#]+\/)([a-z]{2,3}(?:-[a-z0-9]+)*)(\/?)$/i);
|
|
179
|
+
if (!match || match[2].toLowerCase().startsWith('en'))
|
|
180
|
+
return null;
|
|
181
|
+
url.pathname = `${match[1]}en-us${match[3]}`;
|
|
182
|
+
return url.toString();
|
|
183
|
+
}
|
|
184
|
+
const BILLING_ROLES = new Set([
|
|
185
|
+
'name',
|
|
186
|
+
'nameFirst',
|
|
187
|
+
'nameLast',
|
|
188
|
+
'addressLine1',
|
|
189
|
+
'addressLine2',
|
|
190
|
+
'city',
|
|
191
|
+
'state',
|
|
192
|
+
'postalCode',
|
|
193
|
+
'country',
|
|
194
|
+
]);
|
|
195
|
+
const CONTACT_ROLES = new Set([
|
|
196
|
+
'email',
|
|
197
|
+
'phone',
|
|
198
|
+
'name',
|
|
199
|
+
'nameFirst',
|
|
200
|
+
'nameLast',
|
|
201
|
+
'addressLine1',
|
|
202
|
+
'addressLine2',
|
|
203
|
+
'city',
|
|
204
|
+
'state',
|
|
205
|
+
'postalCode',
|
|
206
|
+
'country',
|
|
207
|
+
]);
|
|
208
|
+
const PAYMENT_ROLES = new Set([
|
|
209
|
+
'number',
|
|
210
|
+
'cvc',
|
|
211
|
+
'name',
|
|
212
|
+
'expCombined',
|
|
213
|
+
'expMonth',
|
|
214
|
+
'expYear',
|
|
215
|
+
]);
|
|
216
|
+
const BILLING_ROLE_NAMES = {
|
|
217
|
+
name: 'billingName',
|
|
218
|
+
nameFirst: 'billingNameFirst',
|
|
219
|
+
nameLast: 'billingNameLast',
|
|
220
|
+
addressLine1: 'billingAddressLine1',
|
|
221
|
+
addressLine2: 'billingAddressLine2',
|
|
222
|
+
city: 'billingCity',
|
|
223
|
+
state: 'billingState',
|
|
224
|
+
postalCode: 'billingPostalCode',
|
|
225
|
+
country: 'billingCountry',
|
|
226
|
+
};
|
|
227
|
+
function candidateText(candidate) {
|
|
228
|
+
const meta = candidate.meta;
|
|
229
|
+
return [meta.name, meta.id, meta.autocomplete, meta.labelText, meta.placeholder, meta.ariaLabel]
|
|
230
|
+
.join(' ')
|
|
231
|
+
.toLowerCase();
|
|
232
|
+
}
|
|
233
|
+
function isShopifyCandidate(candidate) {
|
|
234
|
+
const text = candidateText(candidate);
|
|
235
|
+
return (/(?:^|[^a-z])shopify(?:[^a-z]|$)/i.test(text) ||
|
|
236
|
+
/checkout\[(?:shipping|billing)_address\]/i.test(text));
|
|
237
|
+
}
|
|
238
|
+
function isBillingCandidate(candidate) {
|
|
239
|
+
return /billing[_ -]?address|billingaddress|(?:^|[^a-z])billing(?:[^a-z]|$)/i.test(candidateText(candidate));
|
|
240
|
+
}
|
|
241
|
+
function isPaymentCandidate(candidate) {
|
|
242
|
+
return /cc-|cardholder|card holder|payment/i.test(candidateText(candidate));
|
|
243
|
+
}
|
|
244
|
+
function entryFromCandidate(candidate) {
|
|
245
|
+
if (!candidate.source)
|
|
246
|
+
return null;
|
|
247
|
+
return {
|
|
248
|
+
locator: `[data-ca-id="${candidate.meta.idx}"]`,
|
|
249
|
+
confidence: candidate.confidence,
|
|
250
|
+
source: candidate.source,
|
|
251
|
+
frame: candidate.frame,
|
|
252
|
+
tag: candidate.meta.tag,
|
|
253
|
+
inputType: candidate.meta.type,
|
|
254
|
+
// The billing control is revealed before these entries are filled.
|
|
255
|
+
visible: true,
|
|
256
|
+
options: candidate.meta.options.length ? candidate.meta.options : undefined,
|
|
257
|
+
maxlength: candidate.meta.maxlength ? Number.parseInt(candidate.meta.maxlength, 10) : undefined,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function billingFieldMap(detected) {
|
|
261
|
+
const fields = {};
|
|
262
|
+
for (const candidate of detected.candidates) {
|
|
263
|
+
const role = candidate.role;
|
|
264
|
+
if (!role || !BILLING_ROLES.has(role) || !isBillingCandidate(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
|
+
function shippingFieldMap(detected) {
|
|
276
|
+
const fields = {};
|
|
277
|
+
for (const candidate of detected.candidates) {
|
|
278
|
+
const role = candidate.role;
|
|
279
|
+
if (!role ||
|
|
280
|
+
!CONTACT_ROLES.has(role) ||
|
|
281
|
+
!candidate.meta.visible ||
|
|
282
|
+
isBillingCandidate(candidate) ||
|
|
283
|
+
isPaymentCandidate(candidate))
|
|
284
|
+
continue;
|
|
285
|
+
const entry = entryFromCandidate(candidate);
|
|
286
|
+
if (!entry)
|
|
287
|
+
continue;
|
|
288
|
+
const existing = fields[role];
|
|
289
|
+
if (!existing || entry.confidence > existing.confidence)
|
|
290
|
+
fields[role] = entry;
|
|
291
|
+
}
|
|
292
|
+
return fields;
|
|
293
|
+
}
|
|
294
|
+
function paymentFieldMap(detected) {
|
|
295
|
+
const fields = {};
|
|
296
|
+
for (const candidate of detected.candidates) {
|
|
297
|
+
const role = candidate.role;
|
|
298
|
+
if (!role || !PAYMENT_ROLES.has(role) || isBillingCandidate(candidate))
|
|
299
|
+
continue;
|
|
300
|
+
if (role === 'name' && !isPaymentCandidate(candidate))
|
|
301
|
+
continue;
|
|
302
|
+
const entry = entryFromCandidate(candidate);
|
|
303
|
+
if (!entry)
|
|
304
|
+
continue;
|
|
305
|
+
const existing = fields[role];
|
|
306
|
+
if (!existing || entry.confidence > existing.confidence)
|
|
307
|
+
fields[role] = entry;
|
|
308
|
+
}
|
|
309
|
+
return fields;
|
|
310
|
+
}
|
|
311
|
+
async function clickFirstVisible(locators) {
|
|
312
|
+
for (const locator of locators) {
|
|
313
|
+
const first = locator.first();
|
|
314
|
+
if ((await first.count().catch(() => 0)) < 1)
|
|
315
|
+
continue;
|
|
316
|
+
if (!(await first.isVisible().catch(() => false)))
|
|
317
|
+
continue;
|
|
318
|
+
await first.click();
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
async function hasVisibleBillingField(page) {
|
|
324
|
+
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]');
|
|
325
|
+
for (let index = 0; index < (await fields.count()); index += 1) {
|
|
326
|
+
if (await fields
|
|
327
|
+
.nth(index)
|
|
328
|
+
.isVisible()
|
|
329
|
+
.catch(() => false))
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
async function waitForVisibleBillingField(page) {
|
|
335
|
+
if (await hasVisibleBillingField(page))
|
|
336
|
+
return true;
|
|
337
|
+
return page
|
|
338
|
+
.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) => {
|
|
339
|
+
const style = window.getComputedStyle(element);
|
|
340
|
+
return (style.display !== 'none' &&
|
|
341
|
+
style.visibility !== 'hidden' &&
|
|
342
|
+
element.getClientRects().length > 0);
|
|
343
|
+
}), undefined, { timeout: 2_000 })
|
|
344
|
+
.then(() => true)
|
|
345
|
+
.catch(() => false);
|
|
346
|
+
}
|
|
347
|
+
async function revealSeparateBilling(page) {
|
|
348
|
+
if (await hasVisibleBillingField(page))
|
|
349
|
+
return true;
|
|
350
|
+
const roleRadio = page
|
|
351
|
+
.getByRole('radio', { name: /different billing|use a different billing/i })
|
|
352
|
+
.first();
|
|
353
|
+
if ((await roleRadio.count().catch(() => 0)) > 0 &&
|
|
354
|
+
(await roleRadio.isVisible().catch(() => false))) {
|
|
355
|
+
await roleRadio.check();
|
|
356
|
+
return waitForVisibleBillingField(page);
|
|
357
|
+
}
|
|
358
|
+
if (await clickFirstVisible([
|
|
359
|
+
page.locator('input[name="billing_address_selector"][value="billing_address"]'),
|
|
360
|
+
page.locator('input[type="radio"][name*="billing" i][value*="different" i], input[type="radio"][name*="billing" i][value*="billing" i]'),
|
|
361
|
+
])) {
|
|
362
|
+
return waitForVisibleBillingField(page);
|
|
363
|
+
}
|
|
364
|
+
const sameAsShipping = page
|
|
365
|
+
.getByRole('checkbox', { name: /same as shipping|use shipping address/i })
|
|
366
|
+
.first();
|
|
367
|
+
if ((await sameAsShipping.count().catch(() => 0)) > 0 &&
|
|
368
|
+
(await sameAsShipping.isVisible().catch(() => false))) {
|
|
369
|
+
if (await sameAsShipping.isChecked().catch(() => false))
|
|
370
|
+
await sameAsShipping.uncheck();
|
|
371
|
+
return waitForVisibleBillingField(page);
|
|
372
|
+
}
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
function billingContact(address) {
|
|
376
|
+
return {
|
|
377
|
+
firstName: address.firstName,
|
|
378
|
+
lastName: address.lastName,
|
|
379
|
+
fullName: address.fullName,
|
|
380
|
+
addressLine1: address.addressLine1,
|
|
381
|
+
addressLine2: address.addressLine2,
|
|
382
|
+
city: address.city,
|
|
383
|
+
state: address.state,
|
|
384
|
+
postalCode: address.postalCode,
|
|
385
|
+
country: address.country,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function labelBillingEvidence(filled) {
|
|
389
|
+
return filled.map((field) => ({
|
|
390
|
+
...field,
|
|
391
|
+
role: BILLING_ROLE_NAMES[field.role] ?? `billing:${field.role}`,
|
|
392
|
+
}));
|
|
393
|
+
}
|
|
394
|
+
function requiredAddressRoles(address, prefix = '') {
|
|
395
|
+
const role = (name) => `${prefix}${prefix ? name[0].toUpperCase() + name.slice(1) : name}`;
|
|
396
|
+
const regionRequired = ['US', 'CA', 'AU'].includes(address.country?.toUpperCase() ?? '');
|
|
397
|
+
return [
|
|
398
|
+
...(address.firstName ? [role('nameFirst')] : []),
|
|
399
|
+
...(address.lastName ? [role('nameLast')] : []),
|
|
400
|
+
...(!address.firstName && !address.lastName && address.fullName ? [role('name')] : []),
|
|
401
|
+
...(address.addressLine1 ? [role('addressLine1')] : []),
|
|
402
|
+
...(address.addressLine2 ? [role('addressLine2')] : []),
|
|
403
|
+
...(address.city ? [role('city')] : []),
|
|
404
|
+
...(address.state && regionRequired ? [role('state')] : []),
|
|
405
|
+
...(address.postalCode ? [role('postalCode')] : []),
|
|
406
|
+
...(address.country ? [role('country')] : []),
|
|
407
|
+
];
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* A checkout renders ONE name shape (a single full-name input, or first/last)
|
|
411
|
+
* while the contact record may carry the other, so the expected role and the
|
|
412
|
+
* filled role can disagree while the page is completely filled. Either shape
|
|
413
|
+
* satisfies the name requirement; same for the billing-prefixed variants.
|
|
414
|
+
*/
|
|
415
|
+
function withNameEquivalence(successful) {
|
|
416
|
+
const out = new Set(successful);
|
|
417
|
+
for (const [full, first, last] of [
|
|
418
|
+
['name', 'nameFirst', 'nameLast'],
|
|
419
|
+
['billingName', 'billingNameFirst', 'billingNameLast'],
|
|
420
|
+
]) {
|
|
421
|
+
if (out.has(first) && out.has(last))
|
|
422
|
+
out.add(full);
|
|
423
|
+
if (out.has(full)) {
|
|
424
|
+
out.add(first);
|
|
425
|
+
out.add(last);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
export function missingContactRoles(filled, expected) {
|
|
431
|
+
const successful = withNameEquivalence(new Set(filled.filter((field) => field.ok).map((field) => field.role)));
|
|
432
|
+
return expected.filter((role) => !successful.has(role));
|
|
433
|
+
}
|
|
434
|
+
function hasAddressRole(fields) {
|
|
435
|
+
return Boolean(fields.addressLine1 ?? fields.city ?? fields.postalCode);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The contact surface to prefill. Shipping fields when the checkout has them —
|
|
439
|
+
* but a digital-goods (no-shipping) Shopify checkout renders exactly one
|
|
440
|
+
* address block and marks every field autocomplete="billing ..." (observed
|
|
441
|
+
* live 2026-08-16: all 18 candidates billing-classified, so the shipping map
|
|
442
|
+
* came back empty and the prefill reported every role missing). That billing
|
|
443
|
+
* block IS the primary contact surface, under its base roles.
|
|
444
|
+
*/
|
|
445
|
+
export function contactPrefillFieldMap(detected) {
|
|
446
|
+
const shipping = shippingFieldMap(detected);
|
|
447
|
+
if (hasAddressRole(shipping))
|
|
448
|
+
return { fields: shipping, surface: 'shipping' };
|
|
449
|
+
const fields = {};
|
|
450
|
+
for (const candidate of detected.candidates) {
|
|
451
|
+
const role = candidate.role;
|
|
452
|
+
if (!role || !CONTACT_ROLES.has(role) || !candidate.meta.visible)
|
|
453
|
+
continue;
|
|
454
|
+
if (isPaymentCandidate(candidate))
|
|
455
|
+
continue;
|
|
456
|
+
const entry = entryFromCandidate(candidate);
|
|
457
|
+
if (!entry)
|
|
458
|
+
continue;
|
|
459
|
+
const existing = fields[role];
|
|
460
|
+
if (!existing || entry.confidence > existing.confidence)
|
|
461
|
+
fields[role] = entry;
|
|
462
|
+
}
|
|
463
|
+
return { fields, surface: 'billing-only' };
|
|
464
|
+
}
|
|
465
|
+
export class ShopifyAdapter {
|
|
466
|
+
detected;
|
|
467
|
+
name = 'shopify';
|
|
468
|
+
constructor(detected) {
|
|
469
|
+
this.detected = detected;
|
|
470
|
+
}
|
|
471
|
+
matches(detected) {
|
|
472
|
+
return detected.candidates.some(isShopifyCandidate);
|
|
473
|
+
}
|
|
474
|
+
async prepareContact(page, contact) {
|
|
475
|
+
const { fields: contactFields, surface } = contactPrefillFieldMap(this.detected);
|
|
476
|
+
const shippingFilled = await fillContactFieldMap(page, contactFields, contact);
|
|
477
|
+
const shippingExpected = [...requiredAddressRoles(contact), ...(contact.phone ? ['phone'] : [])];
|
|
478
|
+
const shippingMissing = missingContactRoles(shippingFilled, shippingExpected);
|
|
479
|
+
if (surface === 'billing-only' || !contact.billingAddress) {
|
|
480
|
+
return {
|
|
481
|
+
ok: shippingMissing.length === 0,
|
|
482
|
+
filled: shippingFilled,
|
|
483
|
+
...(shippingMissing.length
|
|
484
|
+
? {
|
|
485
|
+
detail: `Shopify ${surface === 'billing-only' ? 'contact' : 'shipping'} prefill incomplete: missing ${shippingMissing.join(', ')}`,
|
|
486
|
+
}
|
|
487
|
+
: {}),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
if (!(await revealSeparateBilling(page))) {
|
|
491
|
+
return {
|
|
492
|
+
ok: false,
|
|
493
|
+
filled: shippingFilled,
|
|
494
|
+
detail: 'Shopify billing address fields did not become visible during prefill',
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
const billingFields = billingFieldMap(await detectFields(page));
|
|
498
|
+
const billingFilled = labelBillingEvidence(await fillContactFieldMap(page, billingFields, billingContact(contact.billingAddress)));
|
|
499
|
+
const billingExpected = requiredAddressRoles(contact.billingAddress, 'billing');
|
|
500
|
+
const missing = [...shippingMissing, ...missingContactRoles(billingFilled, billingExpected)];
|
|
501
|
+
return {
|
|
502
|
+
ok: missing.length === 0,
|
|
503
|
+
filled: [...shippingFilled, ...billingFilled],
|
|
504
|
+
...(missing.length
|
|
505
|
+
? { detail: `Shopify contact prefill incomplete: missing ${missing.join(', ')}` }
|
|
506
|
+
: {}),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
async fill(page, _fields, credential, _contact) {
|
|
510
|
+
const filled = await fillFieldMap(page, paymentFieldMap(this.detected), credential, {});
|
|
511
|
+
const numberOk = filled.some((field) => field.role === 'number' && field.ok);
|
|
512
|
+
return { ok: numberOk, filled };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
@@ -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
|
+
}
|