@visa/cli 4.1.0-rc.42 → 4.1.0-rc.43
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/dist/checkout-engine/adapters/generic.d.ts +4 -0
- package/dist/checkout-engine/adapters/generic.js +28 -13
- package/dist/checkout-engine/adapters/index.d.ts +1 -0
- package/dist/checkout-engine/adapters/index.js +6 -2
- package/dist/checkout-engine/adapters/shopify.d.ts +31 -0
- package/dist/checkout-engine/adapters/shopify.js +423 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/cli-engine.js +1 -0
- package/dist/checkout-engine/detect.d.ts +1 -1
- package/dist/checkout-engine/detect.js +6 -0
- package/dist/checkout-engine/evidence.d.ts +1 -1
- package/dist/checkout-engine/executor.d.ts +2 -2
- package/dist/checkout-engine/executor.js +111 -95
- package/dist/checkout-engine/instrument.d.ts +6 -0
- package/dist/checkout-engine/run-live-fill.js +1 -0
- 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 +7 -2
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +2 -0
- package/dist/checkout-engine/vgs-live-instrument.d.ts +2 -0
- package/dist/checkout-engine/vgs-live-instrument.js +11 -7
- package/dist/cli.js +272 -261
- package/dist/mcp-server/index.js +205 -194
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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
|
-
|
|
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
|
|
106
|
-
const
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
+
}
|