@visa/cli 4.1.0-rc.262 → 4.1.0-rc.263

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.
@@ -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, FilledField } from '../types.js';
5
+ import type { Contact, FillResult, FilledField, PostalAddress } from '../types.js';
6
6
  import { type CheckoutAdapter } from './generic.js';
7
7
  type ShopifySummary = {
8
8
  subtotalMinor: number | null;
@@ -12,10 +12,27 @@ type ShopifySummary = {
12
12
  totalMinor: number | null;
13
13
  currency: string | null;
14
14
  verified: boolean;
15
+ /**
16
+ * Component rows that never rendered and were counted as zero to reconcile
17
+ * the total (#8669). A summary that leans on an implied row is weaker than a
18
+ * fully rendered one: the row may simply not have been painted yet.
19
+ */
20
+ impliedZeroRows: Array<'tax' | 'shipping'>;
15
21
  };
16
22
  export declare function parseShopifySummary(text: string): ShopifySummary;
17
23
  export declare function readShopifyAmount(page: Page, requireVerified: boolean): Promise<PageAmountRead>;
18
- export declare function readStableShopifyAmount(page: Page, timeoutMs?: number): Promise<PageAmountRead>;
24
+ /** Consecutive identical reads (200 ms apart) an implied-zero summary must hold. */
25
+ export declare const IMPLIED_ZERO_STABLE_READS = 8;
26
+ export type StableShopifyAmountOptions = {
27
+ /**
28
+ * A merchant-authoritative total (minor units) the page must equal before an
29
+ * implied-zero summary is trusted: for a trusted UCP handoff this is the
30
+ * server-settled checkout total. Without it, the page must hold the same
31
+ * implied-zero summary for {@link IMPLIED_ZERO_STABLE_READS} reads.
32
+ */
33
+ expectedMinor?: number | null;
34
+ };
35
+ export declare function readStableShopifyAmount(page: Page, timeoutMs?: number, options?: StableShopifyAmountOptions): Promise<PageAmountRead>;
19
36
  export declare function isShopifyCheckoutPage(page: Page): Promise<boolean>;
20
37
  export type ShopifyCheckoutSurface = {
21
38
  signal: string;
@@ -56,6 +73,7 @@ export declare function assertShopifyGuestCheckout(page: Page): Promise<ShopifyG
56
73
  * presentation-only: swapping it keeps the same checkout session and token.
57
74
  */
58
75
  export declare function shopifyEnglishCheckoutUrl(current: string): string | null;
76
+ export declare function requiredAddressRoles(address: PostalAddress, prefix?: string): string[];
59
77
  export declare function missingContactRoles(filled: FilledField[], expected: string[]): string[];
60
78
  /**
61
79
  * The contact surface to prefill. Shipping fields when the checkout has them —
@@ -6,10 +6,22 @@ import { minorFromDecimal, pageCurrency } from '../amount.js';
6
6
  import { detectFields, } from '../detect.js';
7
7
  import { fillContactFieldMap, fillFieldMap } from './generic.js';
8
8
  const SUMMARY_LABELS = /^(Subtotal|Shipping|Estimated taxes|Taxes|Tax|Discounts?|Total)(?:\s*:?\s+((?:[A-Z]{3}\s+)?-?[$€£]?\s*\d[\d.,]*|Free))?$/i;
9
+ // A row whose value has not been computed yet. Shopify phrases it several
10
+ // ways; every one of them means "not a number", never "zero".
11
+ const PENDING_VALUE = /calculated at (?:the )?next step|enter (?:a |your )?shipping address|calculating|getting rates|pending/i;
9
12
  function valueAfterLabel(lines, index, inline) {
10
13
  if (inline)
11
14
  return inline;
12
- return lines.slice(index + 1, index + 4).find((line) => /\d|free/i.test(line)) ?? '';
15
+ for (const line of lines.slice(index + 1, index + 4)) {
16
+ // Never borrow a later row's number: the scan stops at the next label and
17
+ // a pending phrase ends it with no value (#8669: "Shipping / Calculated at
18
+ // next step / Total / $1.50" used to read shipping as $1.50).
19
+ if (SUMMARY_LABELS.test(line) || PENDING_VALUE.test(line))
20
+ return '';
21
+ if (/\d|free/i.test(line))
22
+ return line;
23
+ }
24
+ return '';
13
25
  }
14
26
  export function parseShopifySummary(text) {
15
27
  const lines = text
@@ -53,12 +65,33 @@ export function parseShopifySummary(text) {
53
65
  // checkout, value or not, and discount already defaults to 0 — only the
54
66
  // additive components force reconciliation.
55
67
  const componentLabelPresent = [...values.keys()].some((key) => key !== 'total' && key !== 'discount' && key !== 'discounts');
68
+ // Shopify omits a component row entirely when its value is zero: a tax-free
69
+ // destination renders no "Taxes" line and a free-shipping order can render no
70
+ // "Shipping" line (observed live 2026-09-02, #8669: a settled Colorado
71
+ // checkout showed Subtotal + Shipping = Total with no tax row and refused as
72
+ // unreadable). A row that is absent may only ever count as zero when the
73
+ // rows that ARE present reconcile exactly to the total; a pending
74
+ // "Calculated at next step" still parses as null and still refuses.
75
+ const rowAbsent = (...keys) => keys.every((key) => !values.has(key));
76
+ const taxAbsent = rowAbsent('estimated taxes', 'taxes', 'tax');
77
+ const shippingAbsent = rowAbsent('shipping');
78
+ const reconciles = (shipping, tax) => subtotalMinor != null &&
79
+ shipping != null &&
80
+ tax != null &&
81
+ totalMinor != null &&
82
+ subtotalMinor + shipping + tax - discountMinor === totalMinor;
83
+ // A row that rendered with a pending or unreadable value is present-and-null
84
+ // and always refuses; only a row that never rendered at all may imply zero.
85
+ const fullyReconciled = reconciles(shippingMinor, taxMinor);
86
+ const impliedZeroRows = [];
87
+ if (componentLabelPresent && !fullyReconciled) {
88
+ if (taxAbsent && reconciles(shippingMinor, 0))
89
+ impliedZeroRows.push('tax');
90
+ else if (shippingAbsent && reconciles(0, taxMinor))
91
+ impliedZeroRows.push('shipping');
92
+ }
56
93
  const verified = componentLabelPresent
57
- ? subtotalMinor != null &&
58
- shippingMinor != null &&
59
- taxMinor != null &&
60
- totalMinor != null &&
61
- subtotalMinor + shippingMinor + taxMinor - discountMinor === totalMinor
94
+ ? fullyReconciled || impliedZeroRows.length > 0
62
95
  : totalMinor != null;
63
96
  return {
64
97
  subtotalMinor,
@@ -68,6 +101,7 @@ export function parseShopifySummary(text) {
68
101
  totalMinor,
69
102
  currency: pageCurrency(totalText ?? ''),
70
103
  verified,
104
+ impliedZeroRows,
71
105
  };
72
106
  }
73
107
  export async function readShopifyAmount(page, requireVerified) {
@@ -89,24 +123,46 @@ export async function readShopifyAmount(page, requireVerified) {
89
123
  amountMinor: summary.totalMinor,
90
124
  currency: summary.currency,
91
125
  source: 'shopify-summary',
126
+ ...(summary.impliedZeroRows.length > 0 ? { impliedZeroRows: summary.impliedZeroRows } : {}),
92
127
  };
93
128
  }
94
- export async function readStableShopifyAmount(page, timeoutMs = 6_000) {
129
+ /** Consecutive identical reads (200 ms apart) an implied-zero summary must hold. */
130
+ export const IMPLIED_ZERO_STABLE_READS = 8;
131
+ export async function readStableShopifyAmount(page, timeoutMs = 6_000, options = {}) {
95
132
  const startedAt = Date.now();
96
133
  let previous = '';
97
134
  let stableReads = 0;
135
+ let sawImpliedZeroMismatch = false;
98
136
  while (Date.now() - startedAt < timeoutMs) {
99
137
  const latest = await readShopifyAmount(page, true);
100
138
  const fingerprint = JSON.stringify(latest);
101
139
  stableReads = fingerprint === previous ? stableReads + 1 : 0;
102
- if (latest.kind === 'ok' && stableReads >= 1)
103
- return latest;
140
+ if (latest.kind === 'ok') {
141
+ const implied = (latest.impliedZeroRows?.length ?? 0) > 0;
142
+ if (!implied) {
143
+ if (stableReads >= 1)
144
+ return latest;
145
+ }
146
+ else if (options.expectedMinor != null) {
147
+ // A missing tax or shipping row may be a pre-tax render (#8669). The
148
+ // merchant-authoritative total is the settle signal: accept the implied
149
+ // summary only once the page total equals it.
150
+ if (latest.amountMinor === options.expectedMinor && stableReads >= 1)
151
+ return latest;
152
+ sawImpliedZeroMismatch = true;
153
+ }
154
+ else if (stableReads >= IMPLIED_ZERO_STABLE_READS) {
155
+ return latest;
156
+ }
157
+ }
104
158
  previous = fingerprint;
105
159
  await page.waitForTimeout(200);
106
160
  }
107
161
  return {
108
162
  kind: 'unreadable',
109
- reason: 'Shopify tax and total did not settle before the review deadline',
163
+ reason: sawImpliedZeroMismatch
164
+ ? 'Shopify summary omitted a tax or shipping row and its total never matched the merchant-settled total'
165
+ : 'Shopify tax and total did not settle before the review deadline',
110
166
  };
111
167
  }
112
168
  export async function isShopifyCheckoutPage(page) {
@@ -565,7 +621,7 @@ function labelBillingEvidence(filled) {
565
621
  role: BILLING_ROLE_NAMES[field.role] ?? `billing:${field.role}`,
566
622
  }));
567
623
  }
568
- function requiredAddressRoles(address, prefix = '') {
624
+ export function requiredAddressRoles(address, prefix = '') {
569
625
  const role = (name) => `${prefix}${prefix ? name[0].toUpperCase() + name.slice(1) : name}`;
570
626
  const regionRequired = ['US', 'CA', 'AU'].includes(address.country?.toUpperCase() ?? '');
571
627
  return [
@@ -11,5 +11,7 @@ export type PageAmountRead = {
11
11
  amountMinor: number;
12
12
  currency: string | null;
13
13
  source: 'page-attr' | 'page-text' | 'shopify-summary';
14
+ /** Shopify rows counted as zero because they never rendered (#8669). */
15
+ impliedZeroRows?: Array<'tax' | 'shipping'>;
14
16
  };
15
17
  export declare function readGenericPageAmount(page: Page): Promise<PageAmountRead>;
@@ -1,5 +1,6 @@
1
1
  import { type Browser } from 'playwright-core';
2
2
  import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type CheckoutMode, type CheckoutOutcome, type CheckoutFailureCode, type CheckoutResult, type PreparedCheckoutSessionStore } from './executor.js';
3
+ import type { MandateRefusalCode } from './mandate.js';
3
4
  import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
4
5
  import { type VgsCheckoutTarget } from './vgs-live-instrument.js';
5
6
  import { ServerIntentError, serverFetchCryptogram, serverPostConfirmation } from './vgs-gateway/server-mint-client.js';
@@ -74,6 +75,8 @@ export declare class CheckoutReviewRefusedError extends Error {
74
75
  readonly code = "CHECKOUT_REVIEW_REFUSED";
75
76
  readonly checkoutOutcome: CheckoutOutcome;
76
77
  readonly failureCode?: CheckoutFailureCode;
78
+ /** Bounded mandate/trusted-identity reason (#8669); absent for other outcomes. */
79
+ readonly refusalCode?: MandateRefusalCode;
77
80
  readonly requiresAdapter: string[];
78
81
  readonly detectedRoles: string[];
79
82
  readonly receiptWrite: ReceiptWriteObservation;
@@ -161,6 +161,8 @@ export class CheckoutReviewRefusedError extends Error {
161
161
  code = 'CHECKOUT_REVIEW_REFUSED';
162
162
  checkoutOutcome;
163
163
  failureCode;
164
+ /** Bounded mandate/trusted-identity reason (#8669); absent for other outcomes. */
165
+ refusalCode;
164
166
  requiresAdapter;
165
167
  detectedRoles;
166
168
  receiptWrite;
@@ -172,6 +174,7 @@ export class CheckoutReviewRefusedError extends Error {
172
174
  this.name = 'CheckoutReviewRefusedError';
173
175
  this.checkoutOutcome = result.outcome;
174
176
  this.failureCode = result.failureCode;
177
+ this.refusalCode = result.refusalCode;
175
178
  this.requiresAdapter = [...result.requiresAdapter];
176
179
  this.detectedRoles = Object.keys(result.fields);
177
180
  this.receiptWrite = receiptWrite;
@@ -1,11 +1,12 @@
1
1
  import type { Browser, BrowserContext, Page } from 'playwright-core';
2
2
  import { type FieldMap } from './detect.js';
3
- import { type Mandate } from './mandate.js';
3
+ import { type Mandate, type MandateRefusalCode } from './mandate.js';
4
4
  import type { Instrument } from './instrument.js';
5
5
  import type { Contact, OtpResolver } from './types.js';
6
6
  import { EvidenceLog } from './evidence.js';
7
7
  import { type ObservedOutcome } from './outcome.js';
8
8
  import { type WebBotAuthConfig } from './web-bot-auth.js';
9
+ import { type NavigationRedirectEvidence } from './shopify-primary-domain.js';
9
10
  export { minorFromDecimal, pageCurrency } from './amount.js';
10
11
  export type CheckoutMode = 'dry-run' | 'submit';
11
12
  export type CheckoutRoute = 'guest-card';
@@ -53,6 +54,8 @@ export type CheckoutResult = {
53
54
  credentialLifecycle: CredentialLifecycle;
54
55
  credentialTiming: CredentialTiming;
55
56
  failureCode?: CheckoutFailureCode;
57
+ /** Bounded reason behind a `blocked-by-mandate` outcome; absent otherwise. */
58
+ refusalCode?: MandateRefusalCode;
56
59
  detail?: string;
57
60
  };
58
61
  export type PrepareCheckoutOptions = {
@@ -190,7 +193,34 @@ export declare class InMemoryPreparedCheckoutStore implements PreparedCheckoutSe
190
193
  private scheduleReaper;
191
194
  private closeState;
192
195
  }
196
+ export type TrustedOriginVerdict = Readonly<{
197
+ code: MandateRefusalCode;
198
+ reason: string;
199
+ }>;
200
+ export declare function trustedMerchantOriginVerdict(options: PrepareCheckoutOptions, pageUrl: string, expectedOrigin?: string): TrustedOriginVerdict | null;
193
201
  export declare function trustedMerchantOriginRefusal(options: PrepareCheckoutOptions, pageUrl: string, expectedOrigin?: string): string | null;
202
+ /**
203
+ * Bind the storefront a trusted UCP continuation actually lands on (#8669).
204
+ *
205
+ * The merchant published its UCP business profile at its own business origin
206
+ * and declared the permanent `*.myshopify.com` service that issued the
207
+ * continuation; the CLI verified both before minting the handoff. That is
208
+ * independently verified merchant provenance, so the review may bind the final
209
+ * page origin when, and only when: the navigation started on that declared
210
+ * myshopify origin, the final origin is plain HTTPS, and its host is the
211
+ * declared business host modulo a leading `www.` label. The redirect chain in
212
+ * between (Shopify's primary-domain hop, its shop.app bounce, #8496) carries no
213
+ * authority either way: a redirect cannot land on the merchant's own business
214
+ * domain unless the merchant controls it, and any other final host stays an
215
+ * undeclared origin. Shopify's `primary_domain_redirection` proof is recorded
216
+ * as evidence when present but is not required.
217
+ */
218
+ export declare function trustedShopifyAliasOrigin(args: {
219
+ allowedOrigins: readonly string[];
220
+ initialUrl: string;
221
+ finalUrl: string;
222
+ redirects: readonly NavigationRedirectEvidence[];
223
+ }): string | null;
194
224
  export declare function reconcileHeldOutcome(original: ObservedOutcome, held: ObservedOutcome): ObservedOutcome;
195
225
  export declare function debugShotMaskPlan(fields: FieldMap): {
196
226
  skipReason: string | null;