@visa/cli 4.1.0-rc.154 → 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.
@@ -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.fullName ?? credential?.cardholderName;
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
- const verified = subtotalMinor != null &&
47
- shippingMinor != null &&
48
- taxMinor != null &&
49
- totalMinor != null &&
50
- subtotalMinor + shippingMinor + taxMinor - discountMinor === totalMinor;
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
- function missingRoles(filled, expected) {
374
- const successful = new Set(filled.filter((field) => field.ok).map((field) => field.role));
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 shippingFilled = await fillContactFieldMap(page, shippingFieldMap(this.detected), contact);
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 = missingRoles(shippingFilled, shippingExpected);
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
- ? { detail: `Shopify shipping prefill incomplete: missing ${shippingMissing.join(', ')}` }
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, ...missingRoles(billingFilled, billingExpected)];
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;
@@ -99,6 +99,9 @@ export function classifyCardDrawVerdictFailure(err) {
99
99
  * grant on every draw. Nothing here authorizes anything.
100
100
  */
101
101
  async function resolveCardInstrument(input) {
102
+ if (typeof input.cardTokenId === 'string' && input.cardTokenId.trim()) {
103
+ return { tokenId: input.cardTokenId.trim(), source: 'card-grant' };
104
+ }
102
105
  let credential = null;
103
106
  let readError = null;
104
107
  try {
@@ -110,9 +113,6 @@ async function resolveCardInstrument(input) {
110
113
  if (credential && typeof credential.tokenId === 'string' && credential.tokenId.trim()) {
111
114
  return credential;
112
115
  }
113
- if (typeof input.cardTokenId === 'string' && input.cardTokenId.trim()) {
114
- return { tokenId: input.cardTokenId, source: 'card-grant' };
115
- }
116
116
  throw new Error('no card instrument is available to this runtime: there is no usable credential at ' +
117
117
  `${input.credentialPath} and no activated card:vic grant token was supplied. Run ` +
118
118
  '`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait` to ' +
@@ -917,6 +917,7 @@ export function createCliCheckoutEngine(deps = {}) {
917
917
  merchant: {
918
918
  name: session.target.merchantName,
919
919
  host: new URL(session.target.merchantUrl).hostname,
920
+ url: session.target.merchantUrl,
920
921
  },
921
922
  transaction: {
922
923
  amount: session.target.transactionAmount,
@@ -925,6 +926,8 @@ export function createCliCheckoutEngine(deps = {}) {
925
926
  },
926
927
  result,
927
928
  vicConfirmation,
929
+ agentName: input.agentName,
930
+ cardLast4: input.cardLast4,
928
931
  }));
929
932
  if (report.written)
930
933
  receiptPath = report.path;
@@ -23,28 +23,36 @@ import { RECEIPT_DIR } from './receipt-dir.js';
23
23
  /**
24
24
  * The URL a receipt's charge happened at.
25
25
  *
26
- * The receipt's top-level merchant block records the HOST only, on purpose: a
27
- * full checkout URL can carry cart/session identifiers, so it stays out of the
28
- * shareable summary. The evidence log still holds it (the executor's first
29
- * 'navigation' step is the page the run opened), so recover it from there and
30
- * fall back to the host when the evidence is absent or shaped differently.
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
31
  */
32
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
+ }
33
39
  const navigation = receipt.evidence.steps.find((step) => step?.type === 'navigation');
34
40
  const data = navigation?.data;
35
41
  const url = typeof data === 'object' && data !== null ? data.url : undefined;
36
42
  return typeof url === 'string' && url.length > 0 ? url : `https://${receipt.merchant.host}/`;
37
43
  }
38
44
  function isConfirmedCompletion(receipt) {
45
+ if (receipt.schema === 'checkout-agent-receipt/v2') {
46
+ return receipt.outcome === 'confirmed' && receipt.network.confirmation === 'APPROVED';
47
+ }
39
48
  return (receipt.outcome === 'confirmed' &&
40
49
  receipt.vicConfirmation?.posted === true &&
41
50
  receipt.vicConfirmation.transactionStatus === 'APPROVED');
42
51
  }
43
52
  /**
44
- * Parse one receipt file, or null when it is not a v1 receipt this module can
45
- * read. Deliberately permissive about everything the grouping does not touch:
46
- * the file was written by an older or newer engine and only has to carry the
47
- * fields read below.
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.
48
56
  */
49
57
  function parseReceipt(json) {
50
58
  let parsed;
@@ -57,19 +65,40 @@ function parseReceipt(json) {
57
65
  if (typeof parsed !== 'object' || parsed === null)
58
66
  return null;
59
67
  const receipt = parsed;
60
- if (receipt.schema !== 'checkout-agent-receipt/v1')
61
- return null;
62
- if (typeof receipt.recordedAt !== 'string')
63
- return null;
64
- if (typeof receipt.merchant?.host !== 'string')
65
- return null;
66
- if (typeof receipt.transaction?.amount !== 'string')
67
- return null;
68
- if (typeof receipt.transaction?.currency !== 'string')
69
- return null;
70
- if (!Array.isArray(receipt.evidence?.steps))
71
- return null;
72
- return receipt;
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;
73
102
  }
74
103
  /**
75
104
  * Merchants this device has completed a real card checkout at, newest first.
@@ -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
- const context = await options.browser.newContext();
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) {
@@ -61,6 +61,13 @@ export type CardMandateRecord = {
61
61
  * created record attempted registration. ISO 8601.
62
62
  */
63
63
  registerFailedAt?: string;
64
+ /**
65
+ * Set after an authenticated owner-scoped server read proves this mandate was
66
+ * revoked. The local file is only an execution cache; server revocation is
67
+ * canonical and permanently retires the cached mandate from selection while
68
+ * retaining its receipt history for the owner.
69
+ */
70
+ serverRevokedAt?: string;
64
71
  /**
65
72
  * A cross-host retail budget, not scoped to one `merchantHost`. The local
66
73
  * selector may attempt it at any host, while the provider/network still
@@ -127,6 +134,12 @@ export declare class MandateLedger {
127
134
  * Throws only if the mandate is unknown.
128
135
  */
129
136
  markRegisterFailed(mandateId: string, now?: Date): Promise<CardMandateRecord>;
137
+ /**
138
+ * Retire a locally cached mandate after the authenticated account API proves
139
+ * it was revoked. This is idempotent and monotonic: an owner revocation can
140
+ * never be undone by a later stale/local read.
141
+ */
142
+ markServerRevoked(mandateId: string, revokedAt: string): Promise<CardMandateRecord>;
130
143
  /**
131
144
  * ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
132
145
  *
@@ -203,6 +203,7 @@ export class MandateLedger {
203
203
  m.currencyCode.toUpperCase() === query.currencyCode.toUpperCase() &&
204
204
  !m.unhonoredAt &&
205
205
  !m.registerFailedAt &&
206
+ !m.serverRevokedAt &&
206
207
  !isExpired(m, now) &&
207
208
  hasDrawCountHeadroom(m) &&
208
209
  remainingMinor(m) >= query.amountMinor);
@@ -252,6 +253,27 @@ export class MandateLedger {
252
253
  return record;
253
254
  });
254
255
  }
256
+ /**
257
+ * Retire a locally cached mandate after the authenticated account API proves
258
+ * it was revoked. This is idempotent and monotonic: an owner revocation can
259
+ * never be undone by a later stale/local read.
260
+ */
261
+ markServerRevoked(mandateId, revokedAt) {
262
+ return this.run(async () => {
263
+ const parsed = new Date(revokedAt);
264
+ if (Number.isNaN(parsed.getTime()))
265
+ throw new Error('server revocation timestamp is invalid');
266
+ const file = await this.load();
267
+ const record = file.mandates.find((m) => m.mandateId === mandateId);
268
+ if (!record)
269
+ throw new Error(`no such mandate ${mandateId}`);
270
+ if (!record.serverRevokedAt) {
271
+ record.serverRevokedAt = parsed.toISOString();
272
+ await this.save(file);
273
+ }
274
+ return record;
275
+ });
276
+ }
255
277
  /**
256
278
  * ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
257
279
  *
@@ -1,7 +1,8 @@
1
1
  import type { EvidenceStep } from './evidence.js';
2
2
  import type { CheckoutMode, CheckoutOutcome, CheckoutResult, CredentialLifecycle, CredentialTiming } from './executor.js';
3
3
  import type { VicConfirmationReport } from './vic-confirmation.js';
4
- export type CheckoutReceipt = {
4
+ /** Published v1 artifact shape. Retained so patch releases remain readable/typable. */
5
+ export type CheckoutReceiptV1 = {
5
6
  schema: 'checkout-agent-receipt/v1';
6
7
  recordedAt: string;
7
8
  mode: CheckoutMode;
@@ -32,6 +33,42 @@ export type CheckoutReceipt = {
32
33
  snapshotSummary: string | null;
33
34
  };
34
35
  };
36
+ export type CheckoutReceiptV2 = {
37
+ schema: 'checkout-agent-receipt/v2';
38
+ recordedAt: string;
39
+ merchant: {
40
+ name: string;
41
+ host: string;
42
+ checkoutUrl: string | null;
43
+ };
44
+ transaction: {
45
+ amount: string;
46
+ amountMinor: number;
47
+ currency: string;
48
+ };
49
+ outcome: CheckoutOutcome;
50
+ agent: {
51
+ name: string;
52
+ } | null;
53
+ rail: {
54
+ type: 'card';
55
+ cardLast4: string | null;
56
+ };
57
+ network: {
58
+ confirmation: 'APPROVED' | 'DECLINED' | null;
59
+ };
60
+ /** Local reviewed-attempt identifier. Keeps filenames stable across schema versions. */
61
+ receiptId: string | null;
62
+ /** Merchant confirmation reference only; never substituted with an internal review id. */
63
+ reference: string | null;
64
+ recovery: {
65
+ required: boolean;
66
+ retrySafe: boolean;
67
+ action: string;
68
+ actions: string[];
69
+ };
70
+ };
71
+ export type CheckoutReceipt = CheckoutReceiptV1 | CheckoutReceiptV2;
35
72
  export type ReceiptWriteReport = {
36
73
  written: true;
37
74
  path: string;
@@ -46,6 +83,7 @@ export declare function buildReceipt(input: {
46
83
  merchant: {
47
84
  name: string;
48
85
  host: string;
86
+ url?: string;
49
87
  };
50
88
  transaction: {
51
89
  amount: string;
@@ -54,9 +92,11 @@ export declare function buildReceipt(input: {
54
92
  };
55
93
  result: CheckoutResult;
56
94
  vicConfirmation: VicConfirmationReport | null;
95
+ agentName?: string;
96
+ cardLast4?: string;
57
97
  /** Injectable for tests; defaults to now. */
58
98
  recordedAt?: Date;
59
- }): CheckoutReceipt;
99
+ }): CheckoutReceiptV2;
60
100
  /**
61
101
  * Defense-in-depth scrub before anything touches disk: any standalone
62
102
  * 12–19-digit run that passes Luhn is replaced — contiguous OR separated by