@visa/cli 4.1.0-rc.153 → 4.1.0-rc.155
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 +1 -0
- package/dist/checkout-engine/adapters/generic.d.ts +10 -0
- package/dist/checkout-engine/adapters/generic.js +15 -3
- package/dist/checkout-engine/adapters/shopify.d.ts +25 -1
- package/dist/checkout-engine/adapters/shopify.js +103 -12
- package/dist/checkout-engine/cli-engine.d.ts +4 -0
- package/dist/checkout-engine/cli-engine.js +7 -6
- package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
- package/dist/checkout-engine/confirmed-merchants.js +165 -0
- package/dist/checkout-engine/executor.js +40 -2
- package/dist/checkout-engine/index.d.ts +3 -0
- package/dist/checkout-engine/index.js +3 -0
- package/dist/checkout-engine/known-merchants.d.ts +10 -0
- package/dist/checkout-engine/known-merchants.js +38 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +13 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +22 -0
- 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 +30 -14
- package/dist/cli.js +351 -349
- package/dist/mcp-server/index.js +264 -264
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -180,6 +180,7 @@ is required so the CLI never guesses which agent can spend.
|
|
|
180
180
|
| `wallet_directory_pay` | Directory find + pay in one call, same policy path |
|
|
181
181
|
| `wallet_history` | Journaled payment receipts |
|
|
182
182
|
| `wallet_fund` | Funding instructions for the wallet address |
|
|
183
|
+
| `checkout_merchants` | Read-only: merchants where your card has completed real checkouts, from this device's receipts |
|
|
183
184
|
| `get_status` | Account and wallet state summary |
|
|
184
185
|
| `feedback` | Submit feedback on a tool result |
|
|
185
186
|
| `reset` | Clear local auth state and credentials |
|
|
@@ -10,6 +10,16 @@ export interface CheckoutAdapter {
|
|
|
10
10
|
}
|
|
11
11
|
export declare function resolveLocator(page: Page, entry: FieldEntry): Locator;
|
|
12
12
|
export declare function scrubFillErrorMessage(message: string, value: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* The contact record and the page rarely agree on name shape: the record may
|
|
15
|
+
* carry fullName while the page wants first/last inputs, or vice versa. Derive
|
|
16
|
+
* the missing shape so either page can be filled from either record.
|
|
17
|
+
*/
|
|
18
|
+
export declare function contactNameShapes(contact: Contact, cardholderName?: string): {
|
|
19
|
+
fullName?: string;
|
|
20
|
+
first?: string;
|
|
21
|
+
last?: string;
|
|
22
|
+
};
|
|
13
23
|
export declare function fillContactFieldMap(page: Page, fields: FieldMap, contact: Contact, opts?: {
|
|
14
24
|
fillTimeoutMs?: number;
|
|
15
25
|
}): Promise<FilledField[]>;
|
|
@@ -97,12 +97,24 @@ 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
|
+
* The contact record and the page rarely agree on name shape: the record may
|
|
102
|
+
* carry fullName while the page wants first/last inputs, or vice versa. Derive
|
|
103
|
+
* the missing shape so either page can be filled from either record.
|
|
104
|
+
*/
|
|
105
|
+
export function contactNameShapes(contact, cardholderName) {
|
|
106
|
+
const fullName = contact.fullName ??
|
|
107
|
+
(contact.firstName && contact.lastName
|
|
108
|
+
? `${contact.firstName} ${contact.lastName}`
|
|
109
|
+
: cardholderName);
|
|
110
|
+
const first = contact.firstName ?? (fullName?.split(/\s+/)[0] || undefined);
|
|
111
|
+
const last = contact.lastName ?? (fullName?.split(/\s+/).slice(1).join(' ') || undefined);
|
|
112
|
+
return { fullName, first, last };
|
|
113
|
+
}
|
|
100
114
|
async function fillFields(page, fields, credential, contact, opts = {}) {
|
|
101
115
|
const fillTimeoutMs = opts.fillTimeoutMs ?? DEFAULT_FILL_TIMEOUT_MS;
|
|
102
116
|
const filled = [];
|
|
103
|
-
const fullName = contact
|
|
104
|
-
const first = contact.firstName ?? fullName?.split(/\s+/)[0];
|
|
105
|
-
const last = contact.lastName ?? fullName?.split(/\s+/).slice(1).join(' ');
|
|
117
|
+
const { fullName, first, last } = contactNameShapes(contact, credential?.cardholderName);
|
|
106
118
|
// Order matters a little: contact/name before card is harmless, but we fill
|
|
107
119
|
// card fields explicitly per role so order is not load-bearing.
|
|
108
120
|
const jobs = [];
|
|
@@ -2,7 +2,7 @@ import type { Page } from 'playwright-core';
|
|
|
2
2
|
import { type PageAmountRead } from '../amount.js';
|
|
3
3
|
import { type DetectResult, type FieldMap } from '../detect.js';
|
|
4
4
|
import type { CardCredential } from '../instrument.js';
|
|
5
|
-
import type { Contact, FillResult } from '../types.js';
|
|
5
|
+
import type { Contact, FillResult, FilledField } from '../types.js';
|
|
6
6
|
import { type CheckoutAdapter } from './generic.js';
|
|
7
7
|
type ShopifySummary = {
|
|
8
8
|
subtotalMinor: number | null;
|
|
@@ -20,6 +20,30 @@ export declare function isShopifyCheckoutPage(page: Page): Promise<boolean>;
|
|
|
20
20
|
export declare function detectShopifyChallenge(page: Page): Promise<{
|
|
21
21
|
signal: string;
|
|
22
22
|
} | null>;
|
|
23
|
+
/**
|
|
24
|
+
* The en-US variant of a localized Shopify checkout URL, or null when it is
|
|
25
|
+
* already English (or not locale-suffixed). Shopify renders the checkout in
|
|
26
|
+
* the URL's trailing locale segment, and amount reconciliation reads the
|
|
27
|
+
* order summary by its ENGLISH labels — a store whose primary market is not
|
|
28
|
+
* English serves /checkouts/cn/<token>/<locale> and the total never parses
|
|
29
|
+
* (observed live 2026-08-16: /es-us rendered "Precio total" and the review
|
|
30
|
+
* refused fail-closed on a good checkout). The locale segment is
|
|
31
|
+
* presentation-only: swapping it keeps the same checkout session and token.
|
|
32
|
+
*/
|
|
33
|
+
export declare function shopifyEnglishCheckoutUrl(current: string): string | null;
|
|
34
|
+
export declare function missingContactRoles(filled: FilledField[], expected: string[]): string[];
|
|
35
|
+
/**
|
|
36
|
+
* The contact surface to prefill. Shipping fields when the checkout has them —
|
|
37
|
+
* but a digital-goods (no-shipping) Shopify checkout renders exactly one
|
|
38
|
+
* address block and marks every field autocomplete="billing ..." (observed
|
|
39
|
+
* live 2026-08-16: all 18 candidates billing-classified, so the shipping map
|
|
40
|
+
* came back empty and the prefill reported every role missing). That billing
|
|
41
|
+
* block IS the primary contact surface, under its base roles.
|
|
42
|
+
*/
|
|
43
|
+
export declare function contactPrefillFieldMap(detected: DetectResult): {
|
|
44
|
+
fields: FieldMap;
|
|
45
|
+
surface: 'shipping' | 'billing-only';
|
|
46
|
+
};
|
|
23
47
|
export declare class ShopifyAdapter implements CheckoutAdapter {
|
|
24
48
|
private readonly detected;
|
|
25
49
|
name: string;
|
|
@@ -43,11 +43,23 @@ export function parseShopifySummary(text) {
|
|
|
43
43
|
const discountMinor = parse(uniqueValue('discount', 'discounts')) ?? 0;
|
|
44
44
|
const totalText = uniqueValue('total');
|
|
45
45
|
const totalMinor = parse(totalText);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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;
|
|
51
63
|
return {
|
|
52
64
|
subtotalMinor,
|
|
53
65
|
shippingMinor,
|
|
@@ -145,6 +157,30 @@ export async function detectShopifyChallenge(page) {
|
|
|
145
157
|
}
|
|
146
158
|
return null;
|
|
147
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
|
+
}
|
|
148
184
|
const BILLING_ROLES = new Set([
|
|
149
185
|
'name',
|
|
150
186
|
'nameFirst',
|
|
@@ -370,10 +406,62 @@ function requiredAddressRoles(address, prefix = '') {
|
|
|
370
406
|
...(address.country ? [role('country')] : []),
|
|
371
407
|
];
|
|
372
408
|
}
|
|
373
|
-
|
|
374
|
-
|
|
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)));
|
|
375
432
|
return expected.filter((role) => !successful.has(role));
|
|
376
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
|
+
}
|
|
377
465
|
export class ShopifyAdapter {
|
|
378
466
|
detected;
|
|
379
467
|
name = 'shopify';
|
|
@@ -384,15 +472,18 @@ export class ShopifyAdapter {
|
|
|
384
472
|
return detected.candidates.some(isShopifyCandidate);
|
|
385
473
|
}
|
|
386
474
|
async prepareContact(page, contact) {
|
|
387
|
-
const
|
|
475
|
+
const { fields: contactFields, surface } = contactPrefillFieldMap(this.detected);
|
|
476
|
+
const shippingFilled = await fillContactFieldMap(page, contactFields, contact);
|
|
388
477
|
const shippingExpected = [...requiredAddressRoles(contact), ...(contact.phone ? ['phone'] : [])];
|
|
389
|
-
const shippingMissing =
|
|
390
|
-
if (!contact.billingAddress) {
|
|
478
|
+
const shippingMissing = missingContactRoles(shippingFilled, shippingExpected);
|
|
479
|
+
if (surface === 'billing-only' || !contact.billingAddress) {
|
|
391
480
|
return {
|
|
392
481
|
ok: shippingMissing.length === 0,
|
|
393
482
|
filled: shippingFilled,
|
|
394
483
|
...(shippingMissing.length
|
|
395
|
-
? {
|
|
484
|
+
? {
|
|
485
|
+
detail: `Shopify ${surface === 'billing-only' ? 'contact' : 'shipping'} prefill incomplete: missing ${shippingMissing.join(', ')}`,
|
|
486
|
+
}
|
|
396
487
|
: {}),
|
|
397
488
|
};
|
|
398
489
|
}
|
|
@@ -406,7 +497,7 @@ export class ShopifyAdapter {
|
|
|
406
497
|
const billingFields = billingFieldMap(await detectFields(page));
|
|
407
498
|
const billingFilled = labelBillingEvidence(await fillContactFieldMap(page, billingFields, billingContact(contact.billingAddress)));
|
|
408
499
|
const billingExpected = requiredAddressRoles(contact.billingAddress, 'billing');
|
|
409
|
-
const missing = [...shippingMissing, ...
|
|
500
|
+
const missing = [...shippingMissing, ...missingContactRoles(billingFilled, billingExpected)];
|
|
410
501
|
return {
|
|
411
502
|
ok: missing.length === 0,
|
|
412
503
|
filled: [...shippingFilled, ...billingFilled],
|
|
@@ -34,6 +34,10 @@ export type CliReviewInput = {
|
|
|
34
34
|
cardTokenId?: string;
|
|
35
35
|
/** Exact request-key identity selected by the caller. */
|
|
36
36
|
agentJkt?: string;
|
|
37
|
+
/** Owner-facing selected-agent label for the compact local receipt. */
|
|
38
|
+
agentName?: string;
|
|
39
|
+
/** Safe display suffix derived from the selected card grant label. */
|
|
40
|
+
cardLast4?: string;
|
|
37
41
|
contact: Contact;
|
|
38
42
|
approvalBaseUrl: string;
|
|
39
43
|
merchantName?: string;
|
|
@@ -10,10 +10,9 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Every browser/network primitive is injectable (CliEngineDeps) so the session/
|
|
12
12
|
// timer/store lifecycle is unit-testable without launching Chromium.
|
|
13
|
-
import { homedir } from 'node:os';
|
|
14
|
-
import { join } from 'node:path';
|
|
15
13
|
import { readFile } from 'node:fs/promises';
|
|
16
14
|
import { launchCheckoutBrowser } from './browser-launch.js';
|
|
15
|
+
import { RECEIPT_DIR } from './receipt-dir.js';
|
|
17
16
|
import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
18
17
|
import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval, } from './hosted-approval.js';
|
|
19
18
|
import { VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-instrument.js';
|
|
@@ -100,6 +99,9 @@ export function classifyCardDrawVerdictFailure(err) {
|
|
|
100
99
|
* grant on every draw. Nothing here authorizes anything.
|
|
101
100
|
*/
|
|
102
101
|
async function resolveCardInstrument(input) {
|
|
102
|
+
if (typeof input.cardTokenId === 'string' && input.cardTokenId.trim()) {
|
|
103
|
+
return { tokenId: input.cardTokenId.trim(), source: 'card-grant' };
|
|
104
|
+
}
|
|
103
105
|
let credential = null;
|
|
104
106
|
let readError = null;
|
|
105
107
|
try {
|
|
@@ -111,9 +113,6 @@ async function resolveCardInstrument(input) {
|
|
|
111
113
|
if (credential && typeof credential.tokenId === 'string' && credential.tokenId.trim()) {
|
|
112
114
|
return credential;
|
|
113
115
|
}
|
|
114
|
-
if (typeof input.cardTokenId === 'string' && input.cardTokenId.trim()) {
|
|
115
|
-
return { tokenId: input.cardTokenId, source: 'card-grant' };
|
|
116
|
-
}
|
|
117
116
|
throw new Error('no card instrument is available to this runtime: there is no usable credential at ' +
|
|
118
117
|
`${input.credentialPath} and no activated card:vic grant token was supplied. Run ` +
|
|
119
118
|
'`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait` to ' +
|
|
@@ -147,7 +146,6 @@ function failedPay(detail) {
|
|
|
147
146
|
credentialDisclosed: false,
|
|
148
147
|
};
|
|
149
148
|
}
|
|
150
|
-
const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
|
|
151
149
|
// Must match the prepared-checkout store TTL so a session and its store entry
|
|
152
150
|
// expire together — an abandoned review can't leak the browser + state.
|
|
153
151
|
const PREPARED_TTL_MS = 5 * 60 * 1000;
|
|
@@ -919,6 +917,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
919
917
|
merchant: {
|
|
920
918
|
name: session.target.merchantName,
|
|
921
919
|
host: new URL(session.target.merchantUrl).hostname,
|
|
920
|
+
url: session.target.merchantUrl,
|
|
922
921
|
},
|
|
923
922
|
transaction: {
|
|
924
923
|
amount: session.target.transactionAmount,
|
|
@@ -927,6 +926,8 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
927
926
|
},
|
|
928
927
|
result,
|
|
929
928
|
vicConfirmation,
|
|
929
|
+
agentName: input.agentName,
|
|
930
|
+
cardLast4: input.cardLast4,
|
|
930
931
|
}));
|
|
931
932
|
if (report.written)
|
|
932
933
|
receiptPath = report.path;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** One confirmed charge, in the receipt's own terms. */
|
|
2
|
+
export type ConfirmedCharge = {
|
|
3
|
+
recordedAt: string;
|
|
4
|
+
amount: string;
|
|
5
|
+
currency: string;
|
|
6
|
+
/** Receipt file basename, so an operator can open the evidence log. */
|
|
7
|
+
receiptFile: string;
|
|
8
|
+
};
|
|
9
|
+
export type ConfirmedMerchant = {
|
|
10
|
+
/** The checkout page the card went through. */
|
|
11
|
+
url: string;
|
|
12
|
+
host: string;
|
|
13
|
+
/**
|
|
14
|
+
* Curated human identity (known-merchants.ts), present only when someone has
|
|
15
|
+
* identified who is behind this checkout URL. Hosted payment links carry an
|
|
16
|
+
* opaque path on the PSP's host, so without this a row names nobody.
|
|
17
|
+
*/
|
|
18
|
+
name?: string;
|
|
19
|
+
category?: string;
|
|
20
|
+
website?: string;
|
|
21
|
+
confirmedCount: number;
|
|
22
|
+
lastConfirmedAt: string;
|
|
23
|
+
charges: ConfirmedCharge[];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Merchants this device has completed a real card checkout at, newest first.
|
|
27
|
+
*
|
|
28
|
+
* Never throws: a missing directory (nothing has ever been checked out here)
|
|
29
|
+
* and an unreadable one both read as an empty registry.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readConfirmedMerchants(receiptDir?: string): Promise<ConfirmedMerchant[]>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// "Where can I use my card?", the merchant registry derived from this
|
|
2
|
+
// device's checkout receipts.
|
|
3
|
+
//
|
|
4
|
+
// Receipts (receipt.ts) are the only local record that a real merchant
|
|
5
|
+
// checkout completed on this box. This module reads them back and answers one
|
|
6
|
+
// question: which checkout pages has this card actually gone through?
|
|
7
|
+
//
|
|
8
|
+
// A merchant only counts when BOTH halves agree. The engine's own outcome must
|
|
9
|
+
// be 'confirmed' (the merchant showed a definitive success) AND the VIC
|
|
10
|
+
// confirmation must have posted APPROVED (the network side of the same
|
|
11
|
+
// purchase). Either half alone is a claim, not a completion: a 'confirmed' with
|
|
12
|
+
// no posted confirmation is exactly the reconciliation case receipt.ts flags,
|
|
13
|
+
// and a posted DECLINED is a completed report of a failure.
|
|
14
|
+
//
|
|
15
|
+
// Reading is best-effort by construction. The receipts directory is an operator
|
|
16
|
+
// artifact that anything on the box can touch, so an unreadable or malformed
|
|
17
|
+
// file is skipped rather than failing the whole read. One bad file must not
|
|
18
|
+
// hide every merchant behind it.
|
|
19
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { KNOWN_MERCHANT_IDENTITIES } from './known-merchants.js';
|
|
22
|
+
import { RECEIPT_DIR } from './receipt-dir.js';
|
|
23
|
+
/**
|
|
24
|
+
* The URL a receipt's charge happened at.
|
|
25
|
+
*
|
|
26
|
+
* A compact v2 receipt carries an exact checkout URL only when it is already in
|
|
27
|
+
* the curated identity map; otherwise it carries the host. Legacy v1 receipts
|
|
28
|
+
* keep the URL in the evidence log (the executor's first 'navigation' step is
|
|
29
|
+
* the page the run opened), so recover it there and fall back to the host when
|
|
30
|
+
* that evidence is absent or shaped differently.
|
|
31
|
+
*/
|
|
32
|
+
function checkoutUrlOf(receipt) {
|
|
33
|
+
if (receipt.schema === 'checkout-agent-receipt/v2') {
|
|
34
|
+
const checkoutUrl = receipt.merchant.checkoutUrl;
|
|
35
|
+
return checkoutUrl && KNOWN_MERCHANT_IDENTITIES[checkoutUrl]
|
|
36
|
+
? checkoutUrl
|
|
37
|
+
: `https://${receipt.merchant.host}/`;
|
|
38
|
+
}
|
|
39
|
+
const navigation = receipt.evidence.steps.find((step) => step?.type === 'navigation');
|
|
40
|
+
const data = navigation?.data;
|
|
41
|
+
const url = typeof data === 'object' && data !== null ? data.url : undefined;
|
|
42
|
+
return typeof url === 'string' && url.length > 0 ? url : `https://${receipt.merchant.host}/`;
|
|
43
|
+
}
|
|
44
|
+
function isConfirmedCompletion(receipt) {
|
|
45
|
+
if (receipt.schema === 'checkout-agent-receipt/v2') {
|
|
46
|
+
return receipt.outcome === 'confirmed' && receipt.network.confirmation === 'APPROVED';
|
|
47
|
+
}
|
|
48
|
+
return (receipt.outcome === 'confirmed' &&
|
|
49
|
+
receipt.vicConfirmation?.posted === true &&
|
|
50
|
+
receipt.vicConfirmation.transactionStatus === 'APPROVED');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parse one supported receipt file, or null when its fields cannot safely feed
|
|
54
|
+
* the registry. Deliberately permissive about everything the grouping does not
|
|
55
|
+
* touch: older and newer engines only have to carry the fields read below.
|
|
56
|
+
*/
|
|
57
|
+
function parseReceipt(json) {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(json);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
66
|
+
return null;
|
|
67
|
+
const receipt = parsed;
|
|
68
|
+
if (receipt.schema === 'checkout-agent-receipt/v1') {
|
|
69
|
+
const v1 = receipt;
|
|
70
|
+
if (typeof v1.recordedAt !== 'string')
|
|
71
|
+
return null;
|
|
72
|
+
if (typeof v1.merchant?.host !== 'string')
|
|
73
|
+
return null;
|
|
74
|
+
if (typeof v1.transaction?.amount !== 'string')
|
|
75
|
+
return null;
|
|
76
|
+
if (typeof v1.transaction?.currency !== 'string')
|
|
77
|
+
return null;
|
|
78
|
+
if (!Array.isArray(v1.evidence?.steps))
|
|
79
|
+
return null;
|
|
80
|
+
return v1;
|
|
81
|
+
}
|
|
82
|
+
if (receipt.schema === 'checkout-agent-receipt/v2') {
|
|
83
|
+
const v2 = receipt;
|
|
84
|
+
if (typeof v2.recordedAt !== 'string')
|
|
85
|
+
return null;
|
|
86
|
+
if (typeof v2.merchant?.host !== 'string')
|
|
87
|
+
return null;
|
|
88
|
+
if (v2.merchant.checkoutUrl !== null && typeof v2.merchant.checkoutUrl !== 'string')
|
|
89
|
+
return null;
|
|
90
|
+
if (typeof v2.transaction?.amount !== 'string')
|
|
91
|
+
return null;
|
|
92
|
+
if (typeof v2.transaction?.currency !== 'string')
|
|
93
|
+
return null;
|
|
94
|
+
if (v2.network?.confirmation !== null &&
|
|
95
|
+
v2.network?.confirmation !== 'APPROVED' &&
|
|
96
|
+
v2.network?.confirmation !== 'DECLINED') {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return v2;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Merchants this device has completed a real card checkout at, newest first.
|
|
105
|
+
*
|
|
106
|
+
* Never throws: a missing directory (nothing has ever been checked out here)
|
|
107
|
+
* and an unreadable one both read as an empty registry.
|
|
108
|
+
*/
|
|
109
|
+
export async function readConfirmedMerchants(receiptDir = RECEIPT_DIR) {
|
|
110
|
+
let names;
|
|
111
|
+
try {
|
|
112
|
+
names = await readdir(receiptDir);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const byUrl = new Map();
|
|
118
|
+
for (const name of names) {
|
|
119
|
+
if (!name.endsWith('.json'))
|
|
120
|
+
continue;
|
|
121
|
+
let raw;
|
|
122
|
+
try {
|
|
123
|
+
raw = await readFile(join(receiptDir, name), 'utf8');
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const receipt = parseReceipt(raw);
|
|
129
|
+
if (!receipt || !isConfirmedCompletion(receipt))
|
|
130
|
+
continue;
|
|
131
|
+
const url = checkoutUrlOf(receipt);
|
|
132
|
+
const charge = {
|
|
133
|
+
recordedAt: receipt.recordedAt,
|
|
134
|
+
amount: receipt.transaction.amount,
|
|
135
|
+
currency: receipt.transaction.currency,
|
|
136
|
+
receiptFile: name,
|
|
137
|
+
};
|
|
138
|
+
const existing = byUrl.get(url);
|
|
139
|
+
if (existing) {
|
|
140
|
+
existing.confirmedCount += 1;
|
|
141
|
+
existing.charges.push(charge);
|
|
142
|
+
if (charge.recordedAt > existing.lastConfirmedAt) {
|
|
143
|
+
existing.lastConfirmedAt = charge.recordedAt;
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const identity = KNOWN_MERCHANT_IDENTITIES[url];
|
|
148
|
+
byUrl.set(url, {
|
|
149
|
+
url,
|
|
150
|
+
host: receipt.merchant.host,
|
|
151
|
+
// Conditional spread on purpose: an unidentified merchant carries no
|
|
152
|
+
// identity keys at all, rather than keys holding undefined.
|
|
153
|
+
...(identity ?? {}),
|
|
154
|
+
confirmedCount: 1,
|
|
155
|
+
lastConfirmedAt: charge.recordedAt,
|
|
156
|
+
charges: [charge],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const merchants = [...byUrl.values()];
|
|
160
|
+
for (const merchant of merchants) {
|
|
161
|
+
merchant.charges.sort((left, right) => (left.recordedAt < right.recordedAt ? 1 : -1));
|
|
162
|
+
}
|
|
163
|
+
merchants.sort((left, right) => (left.lastConfirmedAt < right.lastConfirmedAt ? 1 : -1));
|
|
164
|
+
return merchants;
|
|
165
|
+
}
|
|
@@ -24,7 +24,7 @@ import { observeOutcome } from './outcome.js';
|
|
|
24
24
|
import { selectAdapter } from './adapters/index.js';
|
|
25
25
|
import { traceHandleFields } from './trace-handles.js';
|
|
26
26
|
import { readGenericPageAmount } from './amount.js';
|
|
27
|
-
import { detectShopifyChallenge, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, } from './adapters/shopify.js';
|
|
27
|
+
import { detectShopifyChallenge, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, shopifyEnglishCheckoutUrl, } from './adapters/shopify.js';
|
|
28
28
|
export { minorFromDecimal, pageCurrency } from './amount.js';
|
|
29
29
|
const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
|
|
30
30
|
const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
|
|
@@ -674,7 +674,12 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
674
674
|
mandate: { ...opts.mandate },
|
|
675
675
|
};
|
|
676
676
|
const evidence = new EvidenceLog();
|
|
677
|
-
|
|
677
|
+
// Pin an English locale: amount reconciliation reads the order summary by
|
|
678
|
+
// its visible labels (Subtotal/Taxes/Total), and merchants localize by
|
|
679
|
+
// Accept-Language (observed live 2026-08-16: a Shopify checkout redirected
|
|
680
|
+
// to /es-us and rendered "Impuestos estimados", so the total never parsed
|
|
681
|
+
// and the review refused fail-closed on a perfectly good checkout).
|
|
682
|
+
const context = await options.browser.newContext({ locale: 'en-US' });
|
|
678
683
|
// Bound every action so a mis-detected or hidden element fails fast instead
|
|
679
684
|
// of stalling on Playwright's long default timeout.
|
|
680
685
|
context.setDefaultTimeout(6000);
|
|
@@ -732,6 +737,19 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
732
737
|
// Instrument.getCredential() call can occur before an explicit approval.
|
|
733
738
|
let detected = await detectFields(page);
|
|
734
739
|
const shopifyPage = await isShopifyCheckoutPage(page);
|
|
740
|
+
if (shopifyPage) {
|
|
741
|
+
// Same checkout session, English presentation — the amount reader needs
|
|
742
|
+
// the English summary labels (see shopifyEnglishCheckoutUrl).
|
|
743
|
+
const englishUrl = shopifyEnglishCheckoutUrl(page.url());
|
|
744
|
+
if (englishUrl) {
|
|
745
|
+
await page.goto(englishUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
|
|
746
|
+
await waitForStableDom(page);
|
|
747
|
+
if (await isShopifyCheckoutPage(page)) {
|
|
748
|
+
evidence.step('navigation', { url: page.url(), reason: 'shopify-locale-normalized' });
|
|
749
|
+
detected = await detectFields(page);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
735
753
|
let adapter = selectAdapter(detected, { shopify: shopifyPage });
|
|
736
754
|
if (options.contact && adapter.prepareContact) {
|
|
737
755
|
const preparedContact = await adapter.prepareContact(page, options.contact);
|
|
@@ -799,6 +817,26 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
799
817
|
evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
|
|
800
818
|
}
|
|
801
819
|
}
|
|
820
|
+
// A checkout the runner cannot put a card INTO is not reviewable. Without a
|
|
821
|
+
// detected card-number field a later pay would fill nothing and dispatch
|
|
822
|
+
// whatever control the review happened to bind (observed live 2026-08-16:
|
|
823
|
+
// a Payhip storefront SEARCH form, a FastSpring "PayPal Checkout" label,
|
|
824
|
+
// and a Shopify discount-form "Submit" all reviewed clean this way — the
|
|
825
|
+
// real card fields sat in unreachable PSP iframes or an unrendered payment
|
|
826
|
+
// section). Every adapter fills from this same detection, so a missing
|
|
827
|
+
// number field here means no pay can ever succeed: refuse while it is
|
|
828
|
+
// still free.
|
|
829
|
+
if (!fields.number) {
|
|
830
|
+
const found = Object.keys(fields);
|
|
831
|
+
const reason = `no card number field detected (roles found: ${found.length ? found.join(', ') : 'none'}) — ` +
|
|
832
|
+
'the card form is likely inside a PSP iframe or behind a later step, so a credential cannot be entered on this page';
|
|
833
|
+
evidence.step('detect', { phase: 'review', missingCardNumber: true, reason });
|
|
834
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
835
|
+
return {
|
|
836
|
+
status: 'finished',
|
|
837
|
+
result: makeResult('failed', fields, evidence, requiresAdapter, reason),
|
|
838
|
+
};
|
|
839
|
+
}
|
|
802
840
|
const facts = await readTransactionFacts(page, options, 'review');
|
|
803
841
|
recordTransactionFacts(evidence, 'review', facts);
|
|
804
842
|
if (!facts.ok) {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, } from './cli-engine.js';
|
|
2
2
|
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
3
3
|
export type { CheckoutResult, CheckoutReview, CheckoutOutcome } from './executor.js';
|
|
4
|
+
export { readConfirmedMerchants, type ConfirmedMerchant, type ConfirmedCharge, } from './confirmed-merchants.js';
|
|
5
|
+
export { KNOWN_MERCHANT_IDENTITIES, type MerchantIdentity } from './known-merchants.js';
|
|
6
|
+
export { RECEIPT_DIR } from './receipt-dir.js';
|
|
4
7
|
export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
|
|
5
8
|
export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, type CreateCardMandateInput, type CreateCardMandateDeps, type CardMandateFacts, type DrawFromMandateInput, type DrawFromMandateDeps, type DrawResult, type CardMandateMerchant, } from './mandate/card-mandate.js';
|
|
6
9
|
export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, type CardMandateRecord, type CardMandateLedgerFile, type CardMandateDraw, type CardMandateReservation, } from './mandate/mandate-ledger.js';
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// primitives are re-exported for direct/embedded use.
|
|
4
4
|
export { createCliCheckoutEngine, } from './cli-engine.js';
|
|
5
5
|
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
6
|
+
export { readConfirmedMerchants, } from './confirmed-merchants.js';
|
|
7
|
+
export { KNOWN_MERCHANT_IDENTITIES } from './known-merchants.js';
|
|
8
|
+
export { RECEIPT_DIR } from './receipt-dir.js';
|
|
6
9
|
export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
|
|
7
10
|
export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, } from './mandate/card-mandate.js';
|
|
8
11
|
export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, } from './mandate/mandate-ledger.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type MerchantIdentity = {
|
|
2
|
+
/** Human-recognizable merchant name. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** What the merchant is, e.g. "charity (animal rescue)". */
|
|
5
|
+
category: string;
|
|
6
|
+
/** The merchant's own site, when known — not the checkout URL. */
|
|
7
|
+
website?: string;
|
|
8
|
+
};
|
|
9
|
+
/** Checkout URL → who is actually behind it. Confirmed live 2026-07/2026-08. */
|
|
10
|
+
export declare const KNOWN_MERCHANT_IDENTITIES: Readonly<Record<string, MerchantIdentity>>;
|