@visa/cli 4.1.0-rc.28 → 4.1.0-rc.281

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.
Files changed (91) hide show
  1. package/README.md +258 -46
  2. package/dist/checkout-engine/adapters/generic.d.ts +69 -0
  3. package/dist/checkout-engine/adapters/generic.js +383 -58
  4. package/dist/checkout-engine/adapters/index.d.ts +4 -1
  5. package/dist/checkout-engine/adapters/index.js +10 -3
  6. package/dist/checkout-engine/adapters/shopify.d.ts +98 -0
  7. package/dist/checkout-engine/adapters/shopify.js +744 -0
  8. package/dist/checkout-engine/amount.d.ts +17 -0
  9. package/dist/checkout-engine/amount.js +72 -0
  10. package/dist/checkout-engine/browser-launch.d.ts +9 -4
  11. package/dist/checkout-engine/browser-launch.js +19 -4
  12. package/dist/checkout-engine/browserbase-browser.d.ts +24 -0
  13. package/dist/checkout-engine/browserbase-browser.js +186 -0
  14. package/dist/checkout-engine/cli-engine.d.ts +241 -32
  15. package/dist/checkout-engine/cli-engine.js +960 -222
  16. package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
  17. package/dist/checkout-engine/confirmed-merchants.js +165 -0
  18. package/dist/checkout-engine/detect.d.ts +1 -1
  19. package/dist/checkout-engine/detect.js +6 -0
  20. package/dist/checkout-engine/evidence.d.ts +1 -1
  21. package/dist/checkout-engine/executor.d.ts +92 -4
  22. package/dist/checkout-engine/executor.js +688 -157
  23. package/dist/checkout-engine/hosted-approval.d.ts +69 -9
  24. package/dist/checkout-engine/hosted-approval.js +211 -21
  25. package/dist/checkout-engine/index.d.ts +9 -3
  26. package/dist/checkout-engine/index.js +7 -2
  27. package/dist/checkout-engine/instrument.d.ts +6 -0
  28. package/dist/checkout-engine/known-merchants.d.ts +10 -0
  29. package/dist/checkout-engine/known-merchants.js +38 -0
  30. package/dist/checkout-engine/live-fill-approval.d.ts +5 -11
  31. package/dist/checkout-engine/live-fill-approval.js +20 -34
  32. package/dist/checkout-engine/mandate/card-mandate.d.ts +6 -2
  33. package/dist/checkout-engine/mandate/card-mandate.js +10 -5
  34. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +63 -23
  35. package/dist/checkout-engine/mandate/mandate-ledger.js +124 -17
  36. package/dist/checkout-engine/mandate.d.ts +8 -0
  37. package/dist/checkout-engine/mandate.js +44 -9
  38. package/dist/checkout-engine/receipt-dir.d.ts +6 -0
  39. package/dist/checkout-engine/receipt-dir.js +8 -0
  40. package/dist/checkout-engine/receipt.d.ts +56 -2
  41. package/dist/checkout-engine/receipt.js +55 -16
  42. package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
  43. package/dist/checkout-engine/shopify-primary-domain.js +96 -0
  44. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  45. package/dist/checkout-engine/trace-handles.js +12 -0
  46. package/dist/checkout-engine/types.d.ts +15 -2
  47. package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
  48. package/dist/checkout-engine/unresolved-charges.js +134 -0
  49. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +63 -7
  50. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +181 -27
  51. package/dist/checkout-engine/vgs-live-instrument.d.ts +11 -35
  52. package/dist/checkout-engine/vgs-live-instrument.js +14 -74
  53. package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
  54. package/dist/checkout-engine/vic-confirmation.js +9 -3
  55. package/dist/checkout-engine/web-bot-auth.d.ts +98 -0
  56. package/dist/checkout-engine/web-bot-auth.js +218 -0
  57. package/dist/cli.js +923 -389
  58. package/dist/mcp-apps/ucp-checkout.html +280 -0
  59. package/dist/mcp-server/index.js +750 -257
  60. package/dist/merchant-ucp-mcp/index.js +6 -0
  61. package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
  62. package/dist/skills/pair-visa-agent/SKILL.md +434 -318
  63. package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
  64. package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
  65. package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
  66. package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
  67. package/dist/skills/visa-ucp-shopping/SKILL.md +86 -0
  68. package/dist/subway-direct.mjs +1 -0
  69. package/install.ps1 +7 -6
  70. package/install.sh +3 -3
  71. package/native/bin/darwin-arm64/visa-runtime-signer +0 -0
  72. package/native/bin/darwin-x64/visa-runtime-signer +0 -0
  73. package/native/bin/linux-arm64/visa-runtime-signer +0 -0
  74. package/native/bin/linux-x64/visa-runtime-signer +0 -0
  75. package/native/bin/win32-arm64/visa-runtime-signer.exe +0 -0
  76. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  77. package/native/bin/win32-x64/visa-runtime-signer.exe +0 -0
  78. package/package.json +31 -28
  79. package/server.json +4 -4
  80. package/dist/checkout-engine/inline-target.d.ts +0 -13
  81. package/dist/checkout-engine/inline-target.js +0 -37
  82. package/dist/checkout-engine/pay-args.d.ts +0 -14
  83. package/dist/checkout-engine/pay-args.js +0 -44
  84. package/dist/checkout-engine/pay.d.ts +0 -1
  85. package/dist/checkout-engine/pay.js +0 -13
  86. package/dist/checkout-engine/repo-env.d.ts +0 -11
  87. package/dist/checkout-engine/repo-env.js +0 -23
  88. package/dist/checkout-engine/run-live-fill.d.ts +0 -1
  89. package/dist/checkout-engine/run-live-fill.js +0 -493
  90. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
  91. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
@@ -10,17 +10,17 @@
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';
14
+ import { randomBytes, randomUUID } from 'node:crypto';
16
15
  import { launchCheckoutBrowser } from './browser-launch.js';
16
+ import { RECEIPT_DIR } from './receipt-dir.js';
17
17
  import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
18
- import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
19
- import { VgsAssuranceInstrument, VgsLiveInstrument, decimalToMinor, } from './vgs-live-instrument.js';
20
- import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
21
- import { createCardMandate, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
18
+ import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval, } from './hosted-approval.js';
19
+ import { VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-instrument.js';
20
+ import { serverCreateIntent, serverReadIntentBootstrap, ServerIntentError, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
21
+ import { createCardMandate, DEFAULT_MANDATE_MAX_DRAWS, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
22
22
  import { MandateLedger } from './mandate/mandate-ledger.js';
23
- import { buildReceipt, writeReceipt as realWriteReceipt } from './receipt.js';
23
+ import { buildReceipt, writeReceipt as realWriteReceipt, } from './receipt.js';
24
24
  import { reportVicOutcome as realReportVicOutcome, } from './vic-confirmation.js';
25
25
  /**
26
26
  * A card-mandate draw failed transiently (retryable) rather than definitively.
@@ -33,6 +33,21 @@ export function isTransientDrawFailure(err) {
33
33
  const msgs = [];
34
34
  let e = err;
35
35
  for (let i = 0; i < 5 && e; i++) {
36
+ // A refusal that declared itself TERMINAL wins outright, before any message
37
+ // matching. Message text is not a safe carrier for this decision: the mint
38
+ // client interpolates provider-supplied `upstream_codes` into its message
39
+ // for diagnosability, and an identifier like `request_timeout` would match
40
+ // the unanchored `tim(e|ed)-out` alternative below and silently reclassify
41
+ // a permanent 422 as transient — skipping markUnhonored and stranding the
42
+ // mandate in the retry-forever loop this classifier exists to prevent.
43
+ // Duck-typed rather than instanceof so it survives bundling and any
44
+ // re-wrapping across package boundaries. Optional chaining rather than an
45
+ // explicit null guard: the loop condition already proved `e` truthy, so a
46
+ // `e !== null` test is dead code, and `?.` stays safe on a primitive or a
47
+ // nullish link if that guard ever changes.
48
+ if (e?.terminal === true) {
49
+ return false;
50
+ }
36
51
  if (e instanceof Error && typeof e.message === 'string')
37
52
  msgs.push(e.message);
38
53
  e = e.cause;
@@ -46,26 +61,309 @@ export function isTransientDrawFailure(err) {
46
61
  // card-decline reason) matches nothing here → the mandate is correctly disabled.
47
62
  return /\(last:\s*(?:5\d\d|429)\b|\bETIMEDOUT\b|\bECONNRESET\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|socket hang up|tim(?:e|ed)[ -]?out/i.test(msgs.join(' '));
48
63
  }
49
- const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
64
+ /**
65
+ * Refusals that are about the OUTER card GRANT's remaining capacity, not about
66
+ * this mandate and not about the card network (#7491).
67
+ *
68
+ * The atomic reserve (#7233) charges the grant's shared `agent_grant_usage`
69
+ * epoch, so an exhausted or replaced grant refuses BEFORE any credential is
70
+ * minted: no cryptogram is issued, no processor intent is created, no money
71
+ * moves. Treating that as a mandate decline burned the budget the human had
72
+ * just approved and told the caller to create another mandate — which, over the
73
+ * same exhausted grant, is guaranteed to fail identically. The honest remedy is
74
+ * a new/replacement card grant, and the mandate must stay honored so it can
75
+ * draw once the owner supplies one.
76
+ */
77
+ // Deliberately NARROW. `grant_aggregate_no_mandate` is NOT here: that refusal
78
+ // means the mandate row itself vanished between the read and the reserve, which
79
+ // is a statement about the mandate, not about the grant's capacity.
80
+ const GRANT_CAPACITY_REASONS = new Set([
81
+ 'over_grant_aggregate',
82
+ 'no_active_grant',
83
+ 'no_active_grant_or_card_scope',
84
+ 'card_grant_exhausted',
85
+ ]);
86
+ function isGrantCapacityReason(reason) {
87
+ return GRANT_CAPACITY_REASONS.has(reason);
88
+ }
89
+ /**
90
+ * A verdict refusal may be wrapped by drawFromMandate after its local
91
+ * reservation is released. Walk the cause chain so the original auth status +
92
+ * reasons still decide whether the mandate is permanently disabled.
93
+ */
94
+ // Exported for the cross-package pin in test/cli-engine.test.ts: the CLI's
95
+ // card-draw client decides the `status` this reads, so the two move together
96
+ // and a test that spans the boundary is the only one that would catch a drift.
97
+ // Not re-exported from index.ts — the public @visa/checkout-engine surface is
98
+ // the explicit allowlist there, same as isTransientDrawFailure above.
99
+ export function classifyCardDrawVerdictFailure(err) {
100
+ const transientReasons = new Set(['challenge_failed', 'challenge_malformed', 'verdict_refused']);
101
+ let current = err;
102
+ for (let i = 0; i < 5 && current; i++) {
103
+ const candidate = current;
104
+ const status = typeof candidate.status === 'number' ? candidate.status : 0;
105
+ const reasons = Array.isArray(candidate.reasons)
106
+ ? candidate.reasons.filter((reason) => typeof reason === 'string')
107
+ : [];
108
+ if (status !== 0 || reasons.length > 0) {
109
+ const transient = status === 503 ||
110
+ reasons.length === 0 ||
111
+ reasons.every((reason) => transientReasons.has(reason));
112
+ return {
113
+ transient,
114
+ // A capacity refusal is NEITHER transient nor a decline. Retrying now
115
+ // fails identically (so it is not transient), but nothing about the
116
+ // MANDATE was refused (so it is not a decline). Only a refusal that is
117
+ // exclusively about the outer grant qualifies: a mixed reason list
118
+ // still carries a real mandate/network refusal and stays a decline.
119
+ grantCapacity: !transient && reasons.length > 0 && reasons.every(isGrantCapacityReason),
120
+ reasons,
121
+ };
122
+ }
123
+ current = candidate.cause;
124
+ }
125
+ return null;
126
+ }
127
+ /**
128
+ * Resolve the card instrument for one flow. Fail-closed: an unusable legacy file
129
+ * with no grant token rethrows (never silently proceeds), and the ONLY thing the
130
+ * fallback contributes is a token id — a non-secret handle the server
131
+ * re-authorizes against the owner (`requireTokenOwnership`) and against the live
132
+ * grant on every draw. Nothing here authorizes anything.
133
+ */
134
+ async function resolveCardInstrument(input) {
135
+ if (typeof input.cardTokenId === 'string' && input.cardTokenId.trim()) {
136
+ return { tokenId: input.cardTokenId.trim(), source: 'card-grant' };
137
+ }
138
+ let credential = null;
139
+ let readError = null;
140
+ try {
141
+ credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
142
+ }
143
+ catch (err) {
144
+ readError = err;
145
+ }
146
+ if (credential && typeof credential.tokenId === 'string' && credential.tokenId.trim()) {
147
+ return credential;
148
+ }
149
+ throw new Error('no card instrument is available to this runtime: there is no usable credential at ' +
150
+ `${input.credentialPath} and no activated card:vic grant token was supplied. Run ` +
151
+ '`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait` to ' +
152
+ 'attach one, or use a pre-provisioned VIC runtime.', readError instanceof Error ? { cause: readError } : undefined);
153
+ }
154
+ /**
155
+ * A checkout was inspected successfully but cannot be reviewed safely.
156
+ *
157
+ * The explicit fields survive the CLI's copied-engine boundary structurally,
158
+ * so MCP callers do not have to parse the human-readable message.
159
+ */
160
+ export class CheckoutReviewRefusedError extends Error {
161
+ code = 'CHECKOUT_REVIEW_REFUSED';
162
+ checkoutOutcome;
163
+ failureCode;
164
+ /** Bounded mandate/trusted-identity reason (#8669); absent for other outcomes. */
165
+ refusalCode;
166
+ requiresAdapter;
167
+ detectedRoles;
168
+ receiptWrite;
169
+ detail;
170
+ constructor(result, receiptWrite) {
171
+ super(result.detail
172
+ ? `review refused: ${result.outcome} — ${result.detail}`
173
+ : `review refused: ${result.outcome}`);
174
+ this.name = 'CheckoutReviewRefusedError';
175
+ this.checkoutOutcome = result.outcome;
176
+ this.failureCode = result.failureCode;
177
+ this.refusalCode = result.refusalCode;
178
+ this.requiresAdapter = [...result.requiresAdapter];
179
+ this.detectedRoles = Object.keys(result.fields);
180
+ this.receiptWrite = receiptWrite;
181
+ this.detail = result.detail;
182
+ }
183
+ }
184
+ function payAttemptFingerprint(input) {
185
+ return JSON.stringify([
186
+ input.url,
187
+ input.checkoutRoute,
188
+ input.amount,
189
+ input.currency,
190
+ input.credentialPath,
191
+ input.cardTokenId ?? null,
192
+ input.agentJkt ?? null,
193
+ input.contact,
194
+ input.approvalBaseUrl,
195
+ input.merchantName ?? null,
196
+ input.merchantCountryCode ?? null,
197
+ input.submit,
198
+ ]);
199
+ }
200
+ function failedPay(detail) {
201
+ return {
202
+ outcome: 'failed',
203
+ confirmationRef: null,
204
+ receiptPath: null,
205
+ detail,
206
+ vicConfirmation: null,
207
+ source: null,
208
+ remainingMinor: null,
209
+ credentialIssued: false,
210
+ credentialDisclosed: false,
211
+ };
212
+ }
213
+ export class CardMandateActivationError extends Error {
214
+ facts;
215
+ code = 'CARD_MANDATE_ACTIVATION_INCOMPLETE';
216
+ constructor(message, facts) {
217
+ super(message);
218
+ this.facts = facts;
219
+ this.name = 'CardMandateActivationError';
220
+ }
221
+ }
222
+ /**
223
+ * Sort a budget-intent route failure into resume semantics. Exported for the
224
+ * regression net; the truth table is the product contract of #8470.
225
+ */
226
+ export function classifyServerIntentFailure(err) {
227
+ const { status, errorCode, retryable, bootstrapState, outcome } = err.facts;
228
+ if (bootstrapState === 'created')
229
+ return { outcome: 'not_created', resumable: true };
230
+ if (bootstrapState === 'ambiguous' || errorCode === 'budget_intent_creation_ambiguous') {
231
+ return { outcome: 'uncertain', resumable: true };
232
+ }
233
+ if (bootstrapState === 'pending' || errorCode === 'budget_intent_creation_pending') {
234
+ return { outcome: 'uncertain', resumable: true };
235
+ }
236
+ // An explicit uncertain or unknown outcome wins over retry advice: a
237
+ // retryable failure of the STATUS read says nothing about whether the
238
+ // original dispatch created the intent, so it must not read as not_created.
239
+ // A terminal refusal of the read itself (the approval credential is no
240
+ // longer accepted, or is not a budget token) is still uncertain about the
241
+ // intent but cannot be resumed under that credential.
242
+ if (outcome === 'uncertain' || outcome === 'unknown') {
243
+ const terminalRead = status === 401 || status === 403 || status === 404;
244
+ return { outcome: 'uncertain', resumable: !terminalRead };
245
+ }
246
+ if (retryable)
247
+ return { outcome: 'not_created', resumable: true };
248
+ if (status === 0 || status >= 500) {
249
+ return { outcome: 'uncertain', resumable: true };
250
+ }
251
+ // A completed 4xx refusal (claims, binding, conflict, already registered)
252
+ // proves no intent exists and no resume can change the answer.
253
+ return { outcome: 'not_created', resumable: false };
254
+ }
255
+ function mintTokenExpiryMs(mintToken, fallbackMs) {
256
+ try {
257
+ const payload = mintToken.split('.')[1];
258
+ if (!payload)
259
+ return fallbackMs;
260
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
261
+ return typeof claims.exp === 'number' && Number.isFinite(claims.exp)
262
+ ? claims.exp * 1000
263
+ : fallbackMs;
264
+ }
265
+ catch {
266
+ return fallbackMs;
267
+ }
268
+ }
269
+ function boundedReceiptWriteErrorCode(reason) {
270
+ return reason.match(/\b(?:EACCES|EEXIST|ENOSPC|ENOTDIR|EPERM|EROFS)\b/)?.[0] ?? 'UNKNOWN';
271
+ }
50
272
  // Must match the prepared-checkout store TTL so a session and its store entry
51
273
  // expire together — an abandoned review can't leak the browser + state.
52
274
  const PREPARED_TTL_MS = 5 * 60 * 1000;
53
275
  const defaultStore = new InMemoryPreparedCheckoutStore({ ttlMs: PREPARED_TTL_MS });
54
276
  const defaultSessions = new Map();
277
+ const defaultPayAttempts = new Map();
55
278
  const defaultLedger = new MandateLedger();
56
279
  export function createCliCheckoutEngine(deps = {}) {
57
280
  const store = deps.store ?? defaultStore;
58
281
  const sessions = deps.sessions ?? defaultSessions;
282
+ const payAttempts = deps.payAttempts ?? defaultPayAttempts;
59
283
  const ttlMs = deps.ttlMs ?? PREPARED_TTL_MS;
284
+ // #8470: process-bound resume handles for a budget whose intent bootstrap
285
+ // did not complete after owner approval. Holds the one-use bootstrap
286
+ // credential in memory only, for at most its own lifetime.
287
+ const pendingActivations = new Map();
288
+ function dropActivation(token) {
289
+ const pending = pendingActivations.get(token);
290
+ if (!pending)
291
+ return;
292
+ clearTimeout(pending.cleanupTimer);
293
+ pendingActivations.delete(token);
294
+ }
295
+ function parkActivation(pending) {
296
+ const token = `act_${randomBytes(18).toString('base64url')}`;
297
+ const cleanupTimer = setTimeout(() => dropActivation(token), Math.max(0, pending.expiresAtMs - now().getTime()));
298
+ cleanupTimer.unref?.();
299
+ pendingActivations.set(token, { ...pending, cleanupTimer });
300
+ return token;
301
+ }
302
+ function activationFailure(err, pending, existingToken) {
303
+ if (!(err instanceof ServerIntentError))
304
+ throw err;
305
+ const classified = classifyServerIntentFailure(err);
306
+ const resumeToken = classified.resumable
307
+ ? (existingToken ?? parkActivation(pending))
308
+ : undefined;
309
+ if (!classified.resumable && existingToken)
310
+ dropActivation(existingToken);
311
+ throw new CardMandateActivationError(err.message, {
312
+ phase: 'intent',
313
+ outcome: classified.outcome,
314
+ resumable: classified.resumable,
315
+ status: err.facts.status,
316
+ errorCode: err.facts.errorCode,
317
+ requestId: err.facts.requestId,
318
+ bootstrapState: err.facts.bootstrapState,
319
+ ...(resumeToken ? { resumeToken } : {}),
320
+ ...(resumeToken ? { resumeExpiresAt: new Date(pending.expiresAtMs).toISOString() } : {}),
321
+ });
322
+ }
60
323
  const launchBrowser = deps.launchBrowser ?? (() => launchCheckoutBrowser());
61
324
  const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
62
325
  const submitApprovedCheckout = deps.submitApprovedCheckout ?? realSubmitApprovedCheckout;
63
326
  const runHostedApproval = deps.runHostedApproval ?? realRunHostedApproval;
327
+ const claimMandatePickup = deps.claimMandatePickup ?? realClaimMandatePickup;
64
328
  const reportVicOutcome = deps.reportVicOutcome ?? realReportVicOutcome;
65
329
  const writeReceipt = deps.writeReceipt ?? realWriteReceipt;
66
330
  const ledger = deps.ledger ?? defaultLedger;
67
331
  const now = deps.now ?? (() => new Date());
68
332
  const fetchMandateCryptogram = deps.serverFetchCryptogram ?? serverFetchCryptogram;
333
+ const postServerConfirmation = deps.serverPostConfirmation ?? serverPostConfirmation;
334
+ const createObservationId = deps.createObservationId ?? randomUUID;
335
+ async function writeObservedReceipt(mode, receipt) {
336
+ let report;
337
+ try {
338
+ report = await writeReceipt(RECEIPT_DIR, receipt);
339
+ }
340
+ catch (err) {
341
+ // The real writer is fail-open already. Keep that invariant even for an
342
+ // injected writer so a filesystem or test-double failure cannot replace
343
+ // the checkout result after a charge may have completed.
344
+ report = { written: false, reason: err instanceof Error ? err.message : String(err) };
345
+ }
346
+ const event = {
347
+ event: 'checkout_receipt_write',
348
+ status: report.written ? 'written' : 'failed',
349
+ mode,
350
+ checkoutOutcome: receipt.outcome,
351
+ merchantHost: receipt.merchant.host,
352
+ observationId: receipt.observationId ?? receipt.receiptId ?? createObservationId(),
353
+ failureCode: receipt.capability?.failureCode ?? null,
354
+ errorCode: report.written ? null : boundedReceiptWriteErrorCode(report.reason),
355
+ panRedactions: report.written ? report.panRedactions : null,
356
+ };
357
+ try {
358
+ const observerResult = deps.onReceiptWrite?.(event);
359
+ if (observerResult)
360
+ void Promise.resolve(observerResult).catch(() => undefined);
361
+ }
362
+ catch {
363
+ // Observability is non-authoritative and must not affect payment state.
364
+ }
365
+ return { report, event };
366
+ }
69
367
  const cardDrawVerdict = deps.cardDrawVerdict ?? null;
70
368
  const cardMandateRegister = deps.cardMandateRegister ?? null;
71
369
  async function closeSession(reviewId) {
@@ -85,6 +383,154 @@ export function createCliCheckoutEngine(deps = {}) {
85
383
  transactionCurrencyCode: input.currency,
86
384
  };
87
385
  }
386
+ /**
387
+ * Restate the facts at the ceiling the SERVER approved, when it lowered the
388
+ * one that was requested. Nothing has been spent or reserved on a mandate this
389
+ * new, so its remaining headroom IS its ceiling — see `createCardMandate`,
390
+ * which records `spentMinor: 0` with no reservations.
391
+ *
392
+ * Only ever lowers, mirroring `applyApprovedCeiling`: the server clamps
393
+ * downward, so a higher number means an unexpected response and is ignored
394
+ * rather than reported as headroom no human approved.
395
+ */
396
+ function approvedCeilingFacts(facts, approvedCeilingMinor) {
397
+ if (approvedCeilingMinor === undefined || approvedCeilingMinor >= facts.ceilingMinor)
398
+ return {};
399
+ return { ceilingMinor: approvedCeilingMinor, remainingMinor: approvedCeilingMinor };
400
+ }
401
+ // Seed the one server-authoritative cumulative store keyed by the VGS intent
402
+ // ID (#5942). On failure the local record is marked register-failed so
403
+ // findCovering() SKIPS it — the mandate exists but is never drawn tap-free —
404
+ // and the caller reports the failure honestly. Shared by mandate-start and
405
+ // pickup-claim; both refuse before their ceremony/claim when no capability
406
+ // can register, so registerCap is always present here.
407
+ async function registerMandateOrDisable(args) {
408
+ if (!cardMandateRegister)
409
+ return { registerFailed: true, registerFailureReason: 'no_seam' };
410
+ const reg = await cardMandateRegister
411
+ .register({
412
+ authBaseUrl: args.registerCap.authBaseUrl,
413
+ agentKey: args.registerCap.agentKey,
414
+ ...(args.registerCap.runtimeCertificate
415
+ ? { runtimeCertificate: args.registerCap.runtimeCertificate }
416
+ : {}),
417
+ mandateId: args.mandateId,
418
+ mintToken: args.mintToken,
419
+ ceiling: args.ceiling,
420
+ currency: args.currency,
421
+ })
422
+ .catch((err) => ({
423
+ ok: false,
424
+ reason: err instanceof Error ? err.message : String(err),
425
+ }));
426
+ if (reg.ok) {
427
+ // ONE SPENDING LIMIT: auth just clamped the requested ceiling to the
428
+ // owner's live grant cap and told us what it committed. Adopt it, so the
429
+ // local record — which findCovering() selects on and `mandate list`
430
+ // prints — states the budget the owner actually approved. Best-effort:
431
+ // the mandate is registered and drawable either way, and auth's verdict
432
+ // remains the enforcing cap, so a failed local write must not abort the
433
+ // ceremony. It leaves the record overstating headroom, which is exactly
434
+ // the pre-existing behaviour.
435
+ //
436
+ // If that write fails the returned facts still carry the approved figure —
437
+ // telling the human the truth beats echoing a ceiling their grant refused,
438
+ // and the ledger is left exactly as overstated as it was before this
439
+ // existed. But the two then disagree, so say so out loud rather than let
440
+ // `mandate list` quietly contradict what `mandate start` just printed.
441
+ // Same posture as the mark-failed escalation below.
442
+ if (reg.approvedCeilingMinor !== undefined) {
443
+ const applied = await ledger
444
+ .applyApprovedCeiling(args.mandateId, reg.approvedCeilingMinor)
445
+ .then(() => true)
446
+ .catch(() => false);
447
+ if (!applied) {
448
+ // Rendered through minorToDecimal, never raw /100 — and only when the
449
+ // value is a sane integer, because THIS branch is also where an
450
+ // unusable value lands (applyApprovedCeiling rejects it). A warning
451
+ // must not throw on its way out. The currency is omitted deliberately:
452
+ // both mandate paths refuse anything but USD long before register, so
453
+ // it is known, and passing it would let a non-2-decimal code throw here.
454
+ const approved = Number.isSafeInteger(reg.approvedCeilingMinor) && reg.approvedCeilingMinor > 0
455
+ ? ` (${args.currency} ${minorToDecimal(reg.approvedCeilingMinor)})`
456
+ : '';
457
+ process.stderr.write(`warning: your owner's approved limit for this budget${approved} could NOT be saved ` +
458
+ `locally — 'mandate list' will overstate the remaining balance until you re-run ` +
459
+ `'mandate start'. Spending is still capped at the approved limit; a draw over it ` +
460
+ `is refused. mandateId=${args.mandateId}\n`);
461
+ }
462
+ }
463
+ return {
464
+ registerFailed: false,
465
+ ...(reg.approvedCeilingMinor !== undefined
466
+ ? { approvedCeilingMinor: reg.approvedCeilingMinor }
467
+ : {}),
468
+ };
469
+ }
470
+ // Register failed: the server `card_mandate_spend` row was never created, so
471
+ // a delegated draw against this mandate would 404 `no_mandate`. Mark it
472
+ // register-failed so findCovering() SKIPS it rather than silently selecting
473
+ // a mandate that can't be drawn. A v4 grant then refuses until a usable
474
+ // mandate exists; only a legacy credential-file runtime can still use the
475
+ // old per-purchase approval. Marking is best-effort too.
476
+ const marked = await ledger
477
+ .markRegisterFailed(args.mandateId, now())
478
+ .then(() => true)
479
+ .catch(() => false);
480
+ process.stderr.write(marked
481
+ ? `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) — this mandate ` +
482
+ `will NOT be used for draws. A v4 card grant cannot spend until a usable mandate ` +
483
+ `is registered. Re-run 'mandate start' to try again.\n`
484
+ : // Escalate: the mark write ALSO failed, so the mandate is persisted but
485
+ // NOT disabled — findCovering could still select an undrawable mandate.
486
+ // Tell the owner loudly not to rely on it and how to recover.
487
+ `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) AND the mandate ` +
488
+ `could NOT be disabled locally — do not rely on it. Run 'mandate list' and re-run ` +
489
+ `'mandate start'. mandateId=${args.mandateId}\n`);
490
+ return {
491
+ registerFailed: true,
492
+ ...(reg.reason !== undefined ? { registerFailureReason: reg.reason } : {}),
493
+ };
494
+ }
495
+ // Shared by mandate-start and resume: mint (or adopt) the ceiling intent,
496
+ // persist the owner-only ledger entry, and register server-side.
497
+ async function activateBudget(pending, createIntent) {
498
+ const facts = await createCardMandate({
499
+ agentJkt: pending.registerCap.agentJkt,
500
+ tokenId: pending.tokenId,
501
+ assuranceData: pending.assuranceData,
502
+ ceilingMinor: pending.ceilingMinor,
503
+ merchant: pending.merchant,
504
+ currencyCode: pending.currency,
505
+ expiresAt: pending.expiresAt,
506
+ maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
507
+ crossMerchant: true,
508
+ }, {
509
+ createIntent,
510
+ ledger,
511
+ approvalBaseUrl: pending.approvalBaseUrl,
512
+ now,
513
+ });
514
+ // Seed the one server-authoritative cumulative store keyed by the VGS
515
+ // intent ID. A later draw requires its PoP verdict; the budget token never
516
+ // falls back as payable authority.
517
+ const registered = await registerMandateOrDisable({
518
+ registerCap: pending.registerCap,
519
+ mandateId: facts.mandateId,
520
+ mintToken: pending.mintToken,
521
+ ceiling: pending.ceiling,
522
+ currency: pending.currency,
523
+ });
524
+ return {
525
+ ...facts,
526
+ ...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
527
+ merchantHost: new URL(pending.merchant.url).hostname,
528
+ registerFailed: registered.registerFailed,
529
+ ...(registered.registerFailureReason !== undefined
530
+ ? { registerFailureReason: registered.registerFailureReason }
531
+ : {}),
532
+ };
533
+ }
88
534
  return {
89
535
  // BUDGET step: one passkey approves a CEILING; a VGS intent is minted with
90
536
  // that ceiling as its decline threshold and the owner-only ledger records
@@ -95,6 +541,9 @@ export function createCliCheckoutEngine(deps = {}) {
95
541
  if (ceilingMinor === null || ceilingMinor <= 0) {
96
542
  throw new Error(`invalid mandate ceiling ${JSON.stringify(input.ceiling)}`);
97
543
  }
544
+ if (input.currency.toUpperCase() !== 'USD') {
545
+ throw new Error('card spend budgets currently support USD only');
546
+ }
98
547
  if (input.perTransaction !== undefined) {
99
548
  const perTxMinor = decimalToMinor(input.perTransaction);
100
549
  if (perTxMinor === null || perTxMinor <= 0 || perTxMinor > ceilingMinor) {
@@ -102,25 +551,27 @@ export function createCliCheckoutEngine(deps = {}) {
102
551
  'must be a positive amount at or under the ceiling');
103
552
  }
104
553
  }
105
- // BUDGET mandate: not tied to a merchant. The approval screen shows the
106
- // any-merchant label so the owner sees the broader scope they're granting;
107
- // the URL is a stable sentinel (advisory on the intent, ignored on draw).
108
- const anyMerchant = input.anyMerchant === true;
109
- if (!anyMerchant && !input.url) {
110
- throw new Error('a merchant url is required unless anyMerchant (budget mandate) is set');
554
+ // A budget token can bootstrap one VGS intent and its register handshake,
555
+ // but it is never payable draw authority. Refuse before the passkey
556
+ // ceremony unless this runtime can both prove the separately provisioned
557
+ // card capability and register the resulting intent server-side.
558
+ const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
559
+ if (!cardMandateRegister || !registerCap) {
560
+ throw new Error('startCardMandate requires separately provisioned card authority in this runtime; ' +
561
+ 'identity pairing alone does not grant a card mandate');
111
562
  }
112
- const merchant = anyMerchant
113
- ? {
114
- name: 'spend budget mandate',
115
- url: 'https://any-merchant.visa/budget',
116
- countryCode: input.merchantCountryCode ?? 'US',
117
- }
118
- : {
119
- name: input.merchantName ?? new URL(input.url).hostname,
120
- url: input.url,
121
- countryCode: input.merchantCountryCode ?? 'US',
122
- };
123
- const credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
563
+ // There is one budget product: eligible retail merchants under the
564
+ // provider's required Retail/5999 network category, with total,
565
+ // per-purchase, count, and time bounds stated on the approval page. The
566
+ // sentinel is provider metadata, never a user-entered merchant route.
567
+ const merchant = {
568
+ name: 'retail spend budget',
569
+ url: 'https://retail-budget.visa/budget',
570
+ countryCode: 'US',
571
+ };
572
+ // Legacy credential file OR the activated card:vic grant's token — see
573
+ // resolveCardInstrument. A v2-paired runtime only ever has the latter.
574
+ const credential = await resolveCardInstrument(input);
124
575
  // The passkey ceremony is scoped to the CEILING + merchant (not one
125
576
  // charge) — that scope is the unproven part of the spike.
126
577
  const ceilingTarget = {
@@ -132,16 +583,16 @@ export function createCliCheckoutEngine(deps = {}) {
132
583
  };
133
584
  // BUDGET mode: the ceiling target's amount IS the approved ceiling, so the
134
585
  // server mints a budget mint token bound to that ceiling — later draws pull
135
- // sub-ceiling amounts against it tap-free (the single-purchase fresh-tap
136
- // path below stays non-budget).
586
+ // sub-ceiling amounts against it tap-free.
137
587
  const assurance = await runHostedApproval({
138
588
  baseUrl: input.approvalBaseUrl,
139
589
  tokenId: credential.tokenId,
140
590
  target: ceilingTarget,
141
591
  consumerEmail: input.contact.email,
142
592
  budget: true,
143
- onApprovalUrl: deps.onApprovalUrl,
144
- ...(input.maxDraws !== undefined ? { maxDraws: input.maxDraws } : {}),
593
+ agentJkt: registerCap.agentJkt,
594
+ onApprovalUrl: input.onApprovalUrl ?? deps.onApprovalUrl,
595
+ maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
145
596
  ...(input.perTransaction !== undefined ? { perTransaction: input.perTransaction } : {}),
146
597
  ...(input.intent !== undefined ? { intent: input.intent } : {}),
147
598
  });
@@ -150,69 +601,153 @@ export function createCliCheckoutEngine(deps = {}) {
150
601
  throw new Error('the approval server issued no mint token — server-side minting requires the ' +
151
602
  'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.');
152
603
  }
153
- const expiresAt = input.expiresAt ?? new Date(now().getTime() + 24 * 60 * 60 * 1000).toISOString();
154
- const facts = await createCardMandate({
155
- tokenId: credential.tokenId,
604
+ if (!Number.isSafeInteger(assurance.validUntil) ||
605
+ assurance.validUntil <= Math.floor(now().getTime() / 1000)) {
606
+ throw new Error('the approval server issued no valid budget expiry');
607
+ }
608
+ const expiresAt = new Date(assurance.validUntil * 1000).toISOString();
609
+ const pending = {
610
+ mintToken,
156
611
  assuranceData: assurance.assuranceData,
612
+ ceiling: input.ceiling,
157
613
  ceilingMinor,
614
+ currency: input.currency,
158
615
  merchant,
159
- currencyCode: input.currency,
160
616
  expiresAt,
161
- ...(input.maxDraws !== undefined ? { maxDraws: input.maxDraws } : {}),
162
- ...(anyMerchant ? { crossMerchant: true } : {}),
617
+ tokenId: credential.tokenId,
618
+ approvalBaseUrl: input.approvalBaseUrl,
619
+ registerCap,
620
+ expiresAtMs: mintTokenExpiryMs(mintToken, now().getTime() + 10 * 60 * 1000),
621
+ };
622
+ return activateBudget(pending, (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i)).catch((err) => activationFailure(err, pending));
623
+ },
624
+ // RESUME leg (#8470): the owner already approved, but the intent bootstrap
625
+ // did not complete. Read the server's durable state under the SAME one-use
626
+ // credential: a created intent is registered as-is, nothing-dispatched is
627
+ // dispatched once, pending/ambiguous stays uncertain. No approval page, no
628
+ // passkey, and never a sibling intent.
629
+ async resumeCardMandate(input) {
630
+ const pending = pendingActivations.get(input.resumeToken);
631
+ if (!pending || pending.expiresAtMs <= now().getTime()) {
632
+ if (pending)
633
+ dropActivation(input.resumeToken);
634
+ throw new CardMandateActivationError('no resumable budget activation for this operation — its approval credential expired; start a fresh approval', {
635
+ phase: 'intent',
636
+ outcome: 'not_created',
637
+ resumable: false,
638
+ status: null,
639
+ errorCode: 'activation_resume_expired',
640
+ requestId: null,
641
+ bootstrapState: 'unknown',
642
+ });
643
+ }
644
+ let read;
645
+ try {
646
+ read = await serverReadIntentBootstrap(pending.approvalBaseUrl, pending.mintToken);
647
+ }
648
+ catch (err) {
649
+ return activationFailure(err, pending, input.resumeToken);
650
+ }
651
+ if (read.state === 'created' && read.intentId) {
652
+ const intentId = read.intentId;
653
+ const facts = await activateBudget(pending, async () => ({
654
+ intentId,
655
+ status: read.intentStatus,
656
+ }));
657
+ dropActivation(input.resumeToken);
658
+ return facts;
659
+ }
660
+ if (read.state === 'none') {
661
+ return activateBudget(pending, (i) => serverCreateIntent(pending.approvalBaseUrl, pending.mintToken, i))
662
+ .then((facts) => {
663
+ dropActivation(input.resumeToken);
664
+ return facts;
665
+ })
666
+ .catch((err) => activationFailure(err, pending, input.resumeToken));
667
+ }
668
+ throw new CardMandateActivationError(read.state === 'ambiguous'
669
+ ? 'the card network never confirmed this budget intent and its outcome cannot be verified; this approval cannot be reused'
670
+ : read.state === 'pending'
671
+ ? 'this budget intent is still being created; check again shortly'
672
+ : 'the budget activation state could not be read; check again shortly', {
673
+ phase: 'intent',
674
+ outcome: 'uncertain',
675
+ // Stays parked and queryable: a further resume only re-reads state
676
+ // and can never redispatch under this token.
677
+ resumable: true,
678
+ status: null,
679
+ errorCode: read.state === 'ambiguous'
680
+ ? 'budget_intent_creation_ambiguous'
681
+ : read.state === 'pending'
682
+ ? 'budget_intent_creation_pending'
683
+ : 'budget_intent_state_unavailable',
684
+ requestId: read.requestId,
685
+ bootstrapState: read.state,
686
+ resumeToken: input.resumeToken,
687
+ resumeExpiresAt: new Date(pending.expiresAtMs).toISOString(),
688
+ });
689
+ },
690
+ // PICKUP leg: the owner already approved the ceiling in the account panel
691
+ // and handed this runtime a single-use pickup code. Claim it, verify it was
692
+ // minted for exactly this runtime's request key + card token, then run the
693
+ // same intent → register → ledger sequence as startCardMandate. No approval
694
+ // page, no passkey, no contact profile — the human side already happened.
695
+ async claimCardMandate(input) {
696
+ const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
697
+ if (!cardMandateRegister || !registerCap) {
698
+ throw new Error('claimCardMandate requires separately provisioned card authority in this runtime; ' +
699
+ 'identity pairing alone does not grant a card mandate');
700
+ }
701
+ const credential = await resolveCardInstrument(input);
702
+ const claim = await claimMandatePickup({
703
+ baseUrl: input.approvalBaseUrl,
704
+ pickupCode: input.pickupCode,
705
+ agentJkt: registerCap.agentJkt,
706
+ ...(input.expectedHandoffId ? { expectedHandoffId: input.expectedHandoffId } : {}),
707
+ tokenId: credential.tokenId,
708
+ });
709
+ const ceilingMinor = decimalToMinor(claim.ceiling);
710
+ if (ceilingMinor === null || ceilingMinor <= 0) {
711
+ throw new Error(`the handoff carried an invalid ceiling ${JSON.stringify(claim.ceiling)}`);
712
+ }
713
+ // Same single-product bound as mandate-start (the panel initiate route
714
+ // enforces it too; a divergent store entry must not widen it here).
715
+ if (claim.currency.toUpperCase() !== 'USD') {
716
+ throw new Error('card spend budgets currently support USD only');
717
+ }
718
+ const expiresAt = new Date(claim.validUntil * 1000).toISOString();
719
+ const facts = await createCardMandate({
720
+ agentJkt: registerCap.agentJkt,
721
+ tokenId: credential.tokenId,
722
+ assuranceData: claim.assuranceData,
723
+ ceilingMinor,
724
+ merchant: claim.merchant,
725
+ currencyCode: claim.currency,
726
+ expiresAt,
727
+ maxDraws: claim.maxDraws,
728
+ crossMerchant: true,
163
729
  }, {
164
- createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i),
730
+ createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, claim.mintToken, i),
165
731
  ledger,
166
732
  approvalBaseUrl: input.approvalBaseUrl,
167
- mintToken,
168
733
  now,
169
734
  });
170
- // #5942 CONVERGE: seed the auth-side cumulative store keyed by the VGS INTENT
171
- // id (`facts.mandateId`) so a later delegated draw resolves the mandate. This
172
- // is BEST-EFFORT: a failure is logged, never fatal — the bearer mint-token
173
- // path still works, only the delegated draw needs the register row. Skipped
174
- // entirely when no mode='card' delegated binding is present.
175
- const registerCap = cardMandateRegister?.loadCapability() ?? null;
176
- let registerFailed = false;
177
- if (registerCap && cardMandateRegister) {
178
- const reg = await cardMandateRegister
179
- .register({
180
- authBaseUrl: registerCap.authBaseUrl,
181
- agentKey: registerCap.agentKey,
182
- mandateId: facts.mandateId,
183
- mintToken,
184
- ceiling: input.ceiling,
185
- currency: input.currency,
186
- })
187
- .catch((err) => ({
188
- ok: false,
189
- reason: err instanceof Error ? err.message : String(err),
190
- }));
191
- if (!reg.ok) {
192
- registerFailed = true;
193
- // Register failed: the server `card_mandate_spend` row was never
194
- // created, so a delegated draw against this mandate would 404
195
- // `no_mandate`. Mark it register-failed so findCovering() SKIPS it and
196
- // the owner's next checkout falls through to a fresh per-purchase tap,
197
- // rather than silently selecting a mandate that can't be drawn. Marking
198
- // is best-effort too — a failure here must not abort mandate-start.
199
- const marked = await ledger
200
- .markRegisterFailed(facts.mandateId, now())
201
- .then(() => true)
202
- .catch(() => false);
203
- process.stderr.write(marked
204
- ? `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) — this mandate ` +
205
- `will NOT be used for tap-free draws; your next checkout will use a fresh ` +
206
- `per-purchase passkey tap. Re-run 'mandate start' to try again.\n`
207
- : // Escalate: the mark write ALSO failed, so the mandate is persisted but
208
- // NOT disabled — findCovering could still select an undrawable mandate.
209
- // Tell the owner loudly not to rely on it and how to recover.
210
- `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) AND the mandate ` +
211
- `could NOT be disabled locally — do not rely on it. Run 'mandate list' and re-run ` +
212
- `'mandate start'. mandateId=${facts.mandateId}\n`);
213
- }
214
- }
215
- return { ...facts, merchantHost: new URL(merchant.url).hostname, registerFailed };
735
+ const registered = await registerMandateOrDisable({
736
+ registerCap,
737
+ mandateId: facts.mandateId,
738
+ mintToken: claim.mintToken,
739
+ ceiling: claim.ceiling,
740
+ currency: claim.currency,
741
+ });
742
+ return {
743
+ ...facts,
744
+ ...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
745
+ merchantHost: new URL(claim.merchant.url).hostname,
746
+ registerFailed: registered.registerFailed,
747
+ ...(registered.registerFailureReason !== undefined
748
+ ? { registerFailureReason: registered.registerFailureReason }
749
+ : {}),
750
+ };
216
751
  },
217
752
  async review(input) {
218
753
  const amountMinor = decimalToMinor(input.amount);
@@ -223,6 +758,7 @@ export function createCliCheckoutEngine(deps = {}) {
223
758
  try {
224
759
  const prep = await prepareCheckout({
225
760
  url: input.url,
761
+ checkoutRoute: input.checkoutRoute,
226
762
  mandate: {
227
763
  maxAmountMinor: amountMinor,
228
764
  currency: input.currency,
@@ -232,22 +768,62 @@ export function createCliCheckoutEngine(deps = {}) {
232
768
  amountMinor,
233
769
  currency: input.currency,
234
770
  browser,
771
+ contact: input.contact,
772
+ ...(input.trustedMerchantIdentity
773
+ ? { trustedMerchantIdentity: input.trustedMerchantIdentity }
774
+ : {}),
235
775
  }, store);
236
776
  if (prep.status !== 'ready') {
237
- await browser.close();
238
- throw new Error(`review refused: ${prep.result.outcome} — ${prep.result.detail ?? ''}`);
777
+ // A terminal review is still a capability observation. Persist the
778
+ // same compact, owner-only artifact as a pay attempt so the local
779
+ // ledger records unsupported merchant surfaces and the safe card
780
+ // display identity instead of retaining successes only.
781
+ const observationId = createObservationId();
782
+ const receiptWrite = await writeObservedReceipt('dry-run', buildReceipt({
783
+ mode: 'dry-run',
784
+ reviewId: null,
785
+ observationId,
786
+ merchant: {
787
+ name: input.merchantName ?? host,
788
+ host,
789
+ url: input.url,
790
+ },
791
+ transaction: {
792
+ amount: input.amount,
793
+ amountMinor,
794
+ currency: input.currency,
795
+ },
796
+ result: prep.result,
797
+ vicConfirmation: null,
798
+ agentName: input.agentName,
799
+ cardLast4: input.cardLast4,
800
+ recordedAt: now(),
801
+ }));
802
+ throw new CheckoutReviewRefusedError(prep.result, receiptWrite.event);
239
803
  }
240
804
  const r = prep.checkout.review;
241
805
  // Expire the companion session on the SAME schedule as the store entry
242
806
  // (unref'd so a pending timer never keeps the process alive).
243
807
  const cleanupTimer = setTimeout(() => void closeSession(r.id), ttlMs);
244
808
  cleanupTimer.unref?.();
809
+ const target = buildTarget(input);
810
+ if (r.merchantOrigin) {
811
+ // The credential and mandate bind to the exact origin the browser
812
+ // actually reviewed, not the Shopify service continuation that led
813
+ // there. Keep the latter separately for review/pay replay binding.
814
+ target.merchantUrl = r.merchantOrigin;
815
+ target.merchantName = input.merchantName ?? r.merchantHost;
816
+ }
245
817
  sessions.set(r.id, {
246
818
  browser,
247
- target: buildTarget(input),
819
+ requestUrl: new URL(input.url).toString(),
820
+ checkoutRoute: input.checkoutRoute,
821
+ target,
248
822
  amountMinor,
249
823
  currency: input.currency,
250
824
  contact: input.contact,
825
+ ...(input.agentJkt ? { agentJkt: input.agentJkt } : {}),
826
+ ...(input.intentNarrative ? { intentNarrative: input.intentNarrative } : {}),
251
827
  cleanupTimer,
252
828
  });
253
829
  return {
@@ -264,8 +840,43 @@ export function createCliCheckoutEngine(deps = {}) {
264
840
  throw err;
265
841
  }
266
842
  },
843
+ /**
844
+ * Give up a prepared review without paying it (#7100).
845
+ *
846
+ * The retained browser is what lets a same-process MCP `review` → `pay`
847
+ * draw against the checkout it already inspected. A TERMINAL review-only
848
+ * run has no such second call — that process tells the operator to re-RUN
849
+ * with `--submit`, and the new process cannot reach this one's in-memory
850
+ * session. So the browser sat open for the full TTL, and because a live
851
+ * browser connection keeps the event loop alive, a command that had already
852
+ * succeeded looked hung until Ctrl-C.
853
+ *
854
+ * Idempotent and non-throwing: an unknown or already-released id is a
855
+ * no-op, so it is safe on an error path that may not have prepared anything
856
+ * and safe to call twice.
857
+ */
858
+ async releaseReview(reviewId) {
859
+ await closeSession(reviewId);
860
+ payAttempts.delete(reviewId);
861
+ },
267
862
  async pay(input) {
863
+ const noCredentialFacts = {
864
+ credentialIssued: false,
865
+ credentialDisclosed: false,
866
+ };
867
+ const fingerprint = payAttemptFingerprint(input);
868
+ const priorAttempt = payAttempts.get(input.reviewId);
869
+ if (priorAttempt) {
870
+ return priorAttempt.fingerprint === fingerprint
871
+ ? priorAttempt.promise
872
+ : failedPay(`review ${input.reviewId} is already executing with different payment facts — wait for that exact attempt and inspect its receipt`);
873
+ }
268
874
  const session = sessions.get(input.reviewId);
875
+ // One signal for the whole pay lifecycle. `cardTokenId` is populated only
876
+ // by the resolved v4 card-grant authority; legacy credential-file calls
877
+ // omit it. Keeping the classification here prevents copy and fallback
878
+ // behavior from drifting onto different grant detectors.
879
+ const isV4CardGrant = typeof input.cardTokenId === 'string' && input.cardTokenId.trim() !== '';
269
880
  if (!session) {
270
881
  return {
271
882
  outcome: 'failed',
@@ -275,6 +886,83 @@ export function createCliCheckoutEngine(deps = {}) {
275
886
  vicConfirmation: null,
276
887
  source: null,
277
888
  remainingMinor: null,
889
+ ...noCredentialFacts,
890
+ };
891
+ }
892
+ // The reviewId selects the prepared browser session, but the pay call also
893
+ // repeats the target facts. Refuse if any repeated fact disagrees so the
894
+ // caller cannot submit one reviewed checkout while labeling the result or
895
+ // telemetry as another. Never echo the full URLs: payment links can carry
896
+ // claimable secrets in their path/query.
897
+ let payUrl;
898
+ let reviewedUrl;
899
+ try {
900
+ payUrl = new URL(input.url).toString();
901
+ reviewedUrl = session.requestUrl;
902
+ }
903
+ catch {
904
+ await closeSession(input.reviewId);
905
+ return {
906
+ outcome: 'failed',
907
+ confirmationRef: null,
908
+ receiptPath: null,
909
+ detail: 'pay merchant URL is invalid or does not match the reviewed checkout — start a fresh review',
910
+ vicConfirmation: null,
911
+ source: null,
912
+ remainingMinor: null,
913
+ ...noCredentialFacts,
914
+ };
915
+ }
916
+ if (payUrl !== reviewedUrl) {
917
+ await closeSession(input.reviewId);
918
+ return {
919
+ outcome: 'failed',
920
+ confirmationRef: null,
921
+ receiptPath: null,
922
+ detail: 'pay merchant URL does not match the reviewed checkout — start a fresh review',
923
+ vicConfirmation: null,
924
+ source: null,
925
+ remainingMinor: null,
926
+ ...noCredentialFacts,
927
+ };
928
+ }
929
+ if (input.currency.toUpperCase() !== session.currency.toUpperCase()) {
930
+ await closeSession(input.reviewId);
931
+ return {
932
+ outcome: 'failed',
933
+ confirmationRef: null,
934
+ receiptPath: null,
935
+ detail: `pay currency ${JSON.stringify(input.currency)} does not match the reviewed currency (${session.currency}) — start a fresh review`,
936
+ vicConfirmation: null,
937
+ source: null,
938
+ remainingMinor: null,
939
+ ...noCredentialFacts,
940
+ };
941
+ }
942
+ if (input.agentJkt !== session.agentJkt) {
943
+ await closeSession(input.reviewId);
944
+ return {
945
+ outcome: 'failed',
946
+ confirmationRef: null,
947
+ receiptPath: null,
948
+ detail: 'pay agent authority does not match the reviewed request key — start a fresh review',
949
+ vicConfirmation: null,
950
+ source: null,
951
+ remainingMinor: null,
952
+ ...noCredentialFacts,
953
+ };
954
+ }
955
+ if (input.checkoutRoute !== session.checkoutRoute) {
956
+ await closeSession(input.reviewId);
957
+ return {
958
+ outcome: 'failed',
959
+ confirmationRef: null,
960
+ receiptPath: null,
961
+ detail: 'pay checkout route does not match the reviewed guest-card route — start a fresh review',
962
+ vicConfirmation: null,
963
+ source: null,
964
+ remainingMinor: null,
965
+ ...noCredentialFacts,
278
966
  };
279
967
  }
280
968
  // Amount-bind the confirmation: the pay-call amount must match the
@@ -292,6 +980,7 @@ export function createCliCheckoutEngine(deps = {}) {
292
980
  vicConfirmation: null,
293
981
  source: null,
294
982
  remainingMinor: null,
983
+ ...noCredentialFacts,
295
984
  };
296
985
  }
297
986
  // Reject an expired prepared checkout BEFORE running the hosted passkey
@@ -307,112 +996,93 @@ export function createCliCheckoutEngine(deps = {}) {
307
996
  vicConfirmation: null,
308
997
  source: null,
309
998
  remainingMinor: null,
999
+ ...noCredentialFacts,
310
1000
  };
311
1001
  }
1002
+ let resolveAttempt;
1003
+ let rejectAttempt;
1004
+ const sharedResult = new Promise((resolve, reject) => {
1005
+ resolveAttempt = resolve;
1006
+ rejectAttempt = reject;
1007
+ });
1008
+ // The first caller still receives the direct execution result below; this
1009
+ // retained promise exists for overlapping and bounded late duplicates.
1010
+ // Mark its rejection handled even when there is no duplicate consumer.
1011
+ void sharedResult.catch(() => undefined);
1012
+ payAttempts.set(input.reviewId, { fingerprint, promise: sharedResult });
1013
+ let completedAttempt;
1014
+ const finishAttempt = (result) => {
1015
+ completedAttempt = result;
1016
+ resolveAttempt(result);
1017
+ return result;
1018
+ };
312
1019
  clearTimeout(session.cleanupTimer);
313
1020
  try {
314
- const credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
1021
+ // NO instrument read here. A tap-free mandate draw spends `covering
1022
+ // .tokenId` (frozen into the mandate at start) and needs neither the
1023
+ // legacy credential file nor a grant token, so a grant-only device with
1024
+ // an as-yet-unrefreshed token can still draw on a mandate it already
1025
+ // holds.
315
1026
  const host = new URL(session.target.merchantUrl).hostname;
316
1027
  // Does an ACTIVE card mandate already cover this exact purchase? If so,
317
- // draw against it TAP-FREE (no hosted passkey). Else fall back to today's
318
- // 1:1 fresh-tap flow. `mintToken` requirement means a mandate with no
319
- // persisted token can never silently short-circuit the tap.
1028
+ // draw against it TAP-FREE (no hosted passkey). Else refuse with the
1029
+ // mandate remedy (#7348). Mandates no longer persist the bootstrap
1030
+ // token: it is consumed by intent creation + register and has no draw
1031
+ // power.
320
1032
  const covering = await ledger.findCovering({
321
1033
  merchantHost: host,
322
1034
  currencyCode: session.currency,
323
1035
  amountMinor: session.amountMinor,
1036
+ agentJkt: session.agentJkt,
324
1037
  now: now(),
325
1038
  });
326
1039
  let source;
327
1040
  let confirmBase;
328
- let confirmToken;
1041
+ // Mandate draws set this only after the local reserve succeeds and auth
1042
+ // returns the exact per-draw verdict used to mint the credential.
1043
+ let confirmationAuthority = null;
329
1044
  let drawnRemaining = null;
330
1045
  let instrument;
331
- if (covering && covering.mintToken) {
1046
+ if (covering) {
332
1047
  // --- Tap-free mandate draw ------------------------------------------
333
1048
  source = 'mandate';
334
1049
  confirmBase = covering.approvalBaseUrl;
335
- // The bearer mint token stays the auth for the VIC confirmation POST
336
- // (the confirmation route accepts only the mint token). The CRYPTOGRAM
337
- // mint, however, may use a delegated verdict token — see below.
338
- confirmToken = covering.mintToken;
339
1050
  const mandateId = covering.mandateId;
340
- // #5923 delegated card-draw: when the agent holds a local mode='card'
341
- // binding, mint the cryptogram with a PoP-signed VERDICT token (the
342
- // verify-web tryVerdictAuth path) INSTEAD of the bearer mint token. No
343
- // binding (or seam not injected) → `drawToken` stays the bearer token:
344
- // today's #5917 draw, byte-for-byte unchanged (converge-not-break).
1051
+ // A mandate cryptogram is authorized only by a PoP-signed verdict.
1052
+ // The budget mint token is bootstrap-only and cannot bypass the
1053
+ // server reserve/commit ledger when local draw authority is absent.
345
1054
  //
346
1055
  // #5928 CRITICAL-2: the CLI does NOT settle the reservation. verify-web
347
1056
  // (which knows whether the cryptogram was payable) commits it on a
348
1057
  // payable mint and releases it on a decline, server-authoritatively, so
349
1058
  // the drawer can never release after a payable mint to dodge the ceiling.
350
- let drawToken = confirmToken;
351
- const capability = cardDrawVerdict?.loadCapability() ?? null;
352
- if (capability && cardDrawVerdict) {
353
- try {
354
- const { verdict } = await cardDrawVerdict.fetchVerdict({
355
- authBaseUrl: capability.authBaseUrl,
356
- agentKey: capability.agentKey,
357
- mandateId,
358
- // One draw per review; the reviewId is the server-side idempotency key.
359
- drawId: input.reviewId,
360
- draw: {
361
- tokenId: covering.tokenId,
362
- amount: session.target.transactionAmount,
363
- currency: session.currency,
364
- merchantName: session.target.merchantName,
365
- merchantUrl: session.target.merchantUrl,
366
- merchantCountryCode: session.target.merchantCountryCode,
367
- },
368
- });
369
- drawToken = verdict;
370
- }
371
- catch (err) {
372
- // The verdict gate refused (or a transient error) BEFORE the cryptogram
373
- // mint. This point is OUTSIDE the submitApprovedCheckout catch that
374
- // gracefully handles later draw errors, so an un-caught throw here
375
- // escapes pay() raw — making an agent WITH a card binding strictly more
376
- // fragile than one without (breaks converge-not-break). Convert it into
377
- // a graceful FAILED result instead. We do NOT silently fall back to the
378
- // bearer mint token: on an over_ceiling/reserve_refused refusal that
379
- // would DODGE the server-side ceiling the verdict path exists to enforce.
380
- //
381
- // Definitive gate refusal (no_mandate, over_ceiling, reserve_refused,
382
- // checkout_access_revoked, token_mismatch, …): the mandate cannot be
383
- // drawn, so disable it — findCovering then skips it and the NEXT
384
- // pay_merchant falls through to a fresh per-purchase tap. Transient
385
- // (503 card_draw_disabled, challenge hiccup, network): leave the mandate
386
- // healthy and surface a retryable message. Duck-typed: the engine cannot
387
- // import CardDrawRefusedError (it lives in @visa/cli, above this seam).
388
- const reasons = Array.isArray(err.reasons)
389
- ? err.reasons
390
- : [];
391
- const status = typeof err.status === 'number'
392
- ? err.status
393
- : 0;
394
- const TRANSIENT = new Set([
395
- 'challenge_failed',
396
- 'challenge_malformed',
397
- 'verdict_refused',
398
- ]);
399
- const transient = status === 503 || reasons.length === 0 || reasons.every((r) => TRANSIENT.has(r));
400
- if (!transient) {
401
- // Best-effort: a mark failure must not turn a clean refusal into a throw.
402
- await ledger.markUnhonored(mandateId, now()).catch(() => { });
403
- }
404
- return {
405
- outcome: 'failed',
406
- confirmationRef: null,
407
- receiptPath: null,
408
- detail: transient
409
- ? `the card-mandate draw could not be authorized right now (${reasons.join(', ') || 'temporary error'}) — retry shortly`
410
- : `this card mandate can no longer be drawn (${reasons.join(', ') || 'refused'}); it has been disabled — retry the checkout to use a fresh per-purchase passkey tap`,
411
- vicConfirmation: null,
412
- source,
413
- remainingMinor: null,
414
- };
415
- }
1059
+ const verdictSeam = cardDrawVerdict;
1060
+ const capability = verdictSeam?.loadCapability(covering.agentJkt) ?? null;
1061
+ if (!capability || !verdictSeam) {
1062
+ return finishAttempt({
1063
+ outcome: 'failed',
1064
+ confirmationRef: null,
1065
+ receiptPath: null,
1066
+ detail: 'this mandate cannot draw because its delegated card authority is unavailable; ' +
1067
+ 're-pair this runtime to restore the binding, or start a new mandate for this agent',
1068
+ vicConfirmation: null,
1069
+ source,
1070
+ remainingMinor: null,
1071
+ ...noCredentialFacts,
1072
+ });
1073
+ }
1074
+ if (covering.agentJkt && capability.agentJkt !== covering.agentJkt) {
1075
+ return finishAttempt({
1076
+ outcome: 'failed',
1077
+ confirmationRef: null,
1078
+ receiptPath: null,
1079
+ detail: 'this budget belongs to a different request key than the selected card capability; ' +
1080
+ 'restore that exact runtime key, or start a new mandate from the current runtime',
1081
+ vicConfirmation: null,
1082
+ source,
1083
+ remainingMinor: null,
1084
+ ...noCredentialFacts,
1085
+ });
416
1086
  }
417
1087
  // VgsLiveInstrument mints against an EXISTING intent (the mandate) with
418
1088
  // no fresh assurance — exactly the draw semantics. The fetchCredential
@@ -435,7 +1105,36 @@ export function createCliCheckoutEngine(deps = {}) {
435
1105
  amountMinor: session.amountMinor,
436
1106
  transaction: { ...transaction, transactionCurrencyCode: session.currency },
437
1107
  }, {
438
- fetchCryptogram: (i) => fetchMandateCryptogram(confirmBase, drawToken, i),
1108
+ // Ordering matters: reserve the local ledger FIRST, then ask
1109
+ // auth to reserve the server-authoritative budget immediately
1110
+ // before the payable mint. A local reserve race/expiry/file
1111
+ // failure therefore makes zero auth/verdict calls and cannot
1112
+ // strand a server reservation until its sweep.
1113
+ fetchCryptogram: async (i) => {
1114
+ const { verdict } = await verdictSeam.fetchVerdict({
1115
+ authBaseUrl: capability.authBaseUrl,
1116
+ agentKey: capability.agentKey,
1117
+ ...(capability.runtimeCertificate
1118
+ ? { runtimeCertificate: capability.runtimeCertificate }
1119
+ : {}),
1120
+ mandateId,
1121
+ // One draw per review; the reviewId is auth's idempotency key.
1122
+ drawId: input.reviewId,
1123
+ draw: {
1124
+ tokenId: covering.tokenId,
1125
+ amount: session.target.transactionAmount,
1126
+ currency: session.currency,
1127
+ merchantName: session.target.merchantName,
1128
+ merchantUrl: session.target.merchantUrl,
1129
+ merchantCountryCode: session.target.merchantCountryCode,
1130
+ },
1131
+ ...(session.intentNarrative
1132
+ ? { intentNarrative: session.intentNarrative }
1133
+ : {}),
1134
+ });
1135
+ confirmationAuthority = verdict;
1136
+ return fetchMandateCryptogram(confirmBase, verdict, i);
1137
+ },
439
1138
  ledger,
440
1139
  now,
441
1140
  });
@@ -445,7 +1144,8 @@ export function createCliCheckoutEngine(deps = {}) {
445
1144
  // (MandateDrawDeclinedError). That case leaves the mandate
446
1145
  // active+covering, so a naive retry would re-select it and fail
447
1146
  // identically — trapping the caller; marking it unhonored makes the
448
- // next pay_merchant skip it and take a fresh per-purchase tap. We do
1147
+ // next pay_merchant skip it and surface the no-covering-mandate refusal
1148
+ // (#7348) instead of re-failing identically. We do
449
1149
  // NOT attempt an unsafe same-call browser fallback mid-submit.
450
1150
  //
451
1151
  // A PRE-network failure — reserve() failing closed on a concurrent
@@ -463,9 +1163,28 @@ export function createCliCheckoutEngine(deps = {}) {
463
1163
  // Classify on the underlying gateway CAUSE, not the MandateDrawDeclinedError
464
1164
  // wrapper — the wrapper's own advisory text mentions "try again"/"network",
465
1165
  // which would otherwise self-classify every decline as transient.
466
- if (err instanceof MandateDrawDeclinedError &&
467
- !isTransientDrawFailure(err.cause ?? err)) {
468
- await ledger.markUnhonored(mandateId, now());
1166
+ if (err instanceof MandateDrawDeclinedError) {
1167
+ const verdictFailure = classifyCardDrawVerdictFailure(err);
1168
+ if (verdictFailure) {
1169
+ // #7491: a GRANT-capacity refusal is pre-credential and says
1170
+ // nothing about this mandate, so it must NOT disable it. The
1171
+ // mandate is healthy and becomes drawable again the moment the
1172
+ // owner supplies a grant with room; disabling it here threw
1173
+ // away a just-approved budget and pointed the caller at a new
1174
+ // mandate that would die on the same wall.
1175
+ if (!verdictFailure.transient && !verdictFailure.grantCapacity) {
1176
+ await ledger.markUnhonored(mandateId, now()).catch(() => { });
1177
+ }
1178
+ if (verdictFailure.grantCapacity) {
1179
+ throw new Error(`this agent's card grant has no remaining capacity (${verdictFailure.reasons.join(', ') || 'grant exhausted'}) — nothing was charged and the mandate is still valid; ask the owner for a new or replenished card grant (visa agent grant-card) before retrying this checkout`, { cause: err });
1180
+ }
1181
+ throw new Error(verdictFailure.transient
1182
+ ? `the card-mandate draw could not be authorized right now (${verdictFailure.reasons.join(', ') || 'temporary error'}) — retry shortly`
1183
+ : `this card mandate can no longer be drawn (${verdictFailure.reasons.join(', ') || 'refused'}); it has been disabled — create and claim a new mandate before retrying this checkout`, { cause: err });
1184
+ }
1185
+ if (!isTransientDrawFailure(err.cause ?? err)) {
1186
+ await ledger.markUnhonored(mandateId, now());
1187
+ }
469
1188
  }
470
1189
  // #5928 CRITICAL-2: the server-side reservation is released by
471
1190
  // verify-web (it saw the mint fail), not here — the CLI never settles.
@@ -479,48 +1198,31 @@ export function createCliCheckoutEngine(deps = {}) {
479
1198
  instrument = new VgsLiveInstrument(reference, session.contact.fullName ?? '', fetchCredential);
480
1199
  }
481
1200
  else {
482
- // --- 1:1 fresh-tap flow (unchanged) ---------------------------------
483
- source = 'fresh-tap';
484
- const assurance = await runHostedApproval({
485
- baseUrl: input.approvalBaseUrl,
486
- tokenId: credential.tokenId,
487
- target: session.target,
488
- consumerEmail: session.contact.email,
489
- onApprovalUrl: input.onApprovalUrl ?? deps.onApprovalUrl,
490
- });
491
- // Server-side mint (Phase 1): the approval claim releases a scoped mint
492
- // token; the credential is minted by the verify-web deployment (which
493
- // holds the VGS secret), never on this machine. No token means the
494
- // deployment ran the dev-auth stub — refuse loudly rather than reach for
495
- // a client-held secret (there is none anymore).
496
- const mintToken = assurance.mintToken;
497
- if (!mintToken) {
498
- return {
499
- outcome: 'failed',
500
- confirmationRef: null,
501
- receiptPath: null,
502
- detail: 'the approval server issued no mint token — server-side minting requires the ' +
503
- 'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.',
504
- vicConfirmation: null,
505
- source: 'fresh-tap',
506
- remainingMinor: null,
507
- };
508
- }
509
- confirmBase = input.approvalBaseUrl;
510
- confirmToken = mintToken;
511
- instrument = new VgsAssuranceInstrument(credential, assurance, session.target, session.contact.fullName ?? '', async (mintInput) => {
512
- const { intentId, status } = await serverCreateIntent(confirmBase, confirmToken, mintInput);
513
- try {
514
- const payment = await serverFetchCryptogram(confirmBase, confirmToken, {
515
- tokenId: mintInput.tokenId,
516
- intentId,
517
- transaction: mintInput.transaction,
518
- });
519
- return { payment, intentId };
520
- }
521
- catch (err) {
522
- throw new Error(`${err.message} (intent status at creation: ${status ?? 'unknown'})`);
523
- }
1201
+ // #7348 single-purchase retirement: card spend is mandate-only for
1202
+ // EVERY runtime. The legacy 1:1 single-purchase flow here minted a
1203
+ // payable credential outside the grant/mandate ledger — invisible to
1204
+ // spending controls. The server now refuses non-budget approvals at
1205
+ // registration and non-budget mint tokens at verification; this
1206
+ // client refusal exists to give the honest remedy up front instead
1207
+ // of a mid-flow server error.
1208
+ return finishAttempt({
1209
+ outcome: 'failed',
1210
+ confirmationRef: null,
1211
+ receiptPath: null,
1212
+ detail: isV4CardGrant
1213
+ ? 'this v4 card grant has no active mandate covering the purchase; run ' +
1214
+ '`visa mandate start --ceiling <usd> --per-transaction <usd>` for the selected ' +
1215
+ 'agent, approve and claim the mandate, then retry this checkout. No payment ' +
1216
+ 'credential was requested and no charge was made.'
1217
+ : 'one-off card approvals are retired (#7348): card spending always runs through ' +
1218
+ 'an owner-approved mandate now. Attach card authority to this agent ' +
1219
+ '(`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait`), ' +
1220
+ 'then start and claim a mandate (`visa mandate start <usd> --per-transaction <usd>`), ' +
1221
+ 'and retry the checkout. No payment credential was requested and no charge was made.',
1222
+ vicConfirmation: null,
1223
+ source: null,
1224
+ remainingMinor: null,
1225
+ ...noCredentialFacts,
524
1226
  });
525
1227
  }
526
1228
  const mode = input.submit ? 'submit' : 'dry-run';
@@ -529,24 +1231,33 @@ export function createCliCheckoutEngine(deps = {}) {
529
1231
  instrument,
530
1232
  contact: session.contact,
531
1233
  mode,
1234
+ ...(deps.resolveEmailOtp ? { resolveEmailOtp: deps.resolveEmailOtp } : {}),
532
1235
  }, store);
533
1236
  // Report the observed submit outcome to VIC for the consumed intent
534
1237
  // (APPROVED/DECLINED). Only definitive submit answers post. Mirrors
535
1238
  // run-live-fill.ts.
536
1239
  let vicConfirmation = null;
1240
+ let processorIntentId = null;
537
1241
  if (mode === 'submit') {
1242
+ const confirmationTarget = instrument.confirmationTarget();
1243
+ processorIntentId = confirmationTarget?.intentId ?? null;
538
1244
  vicConfirmation = await reportVicOutcome({
539
- target: instrument.confirmationTarget(),
1245
+ target: confirmationTarget,
540
1246
  outcome: result.outcome,
541
1247
  transaction: {
542
1248
  transactionAmount: session.target.transactionAmount,
543
1249
  transactionCurrencyCode: session.currency,
544
1250
  },
545
- post: (confInput) => serverPostConfirmation(confirmBase, confirmToken, confInput),
1251
+ post: (confInput) => {
1252
+ if (!confirmationAuthority) {
1253
+ throw new Error('confirmation authority missing for the consumed VIC intent');
1254
+ }
1255
+ return postServerConfirmation(confirmBase, confirmationAuthority, confInput);
1256
+ },
546
1257
  });
547
1258
  }
548
1259
  let receiptPath = null;
549
- const report = await writeReceipt(RECEIPT_DIR, buildReceipt({
1260
+ const receiptWrite = await writeObservedReceipt(mode, buildReceipt({
550
1261
  mode,
551
1262
  reviewId: input.reviewId,
552
1263
  // Derive BOTH name and host from the reviewed session target (not
@@ -555,6 +1266,7 @@ export function createCliCheckoutEngine(deps = {}) {
555
1266
  merchant: {
556
1267
  name: session.target.merchantName,
557
1268
  host: new URL(session.target.merchantUrl).hostname,
1269
+ url: session.target.merchantUrl,
558
1270
  },
559
1271
  transaction: {
560
1272
  amount: session.target.transactionAmount,
@@ -563,10 +1275,12 @@ export function createCliCheckoutEngine(deps = {}) {
563
1275
  },
564
1276
  result,
565
1277
  vicConfirmation,
1278
+ agentName: input.agentName,
1279
+ cardLast4: input.cardLast4,
566
1280
  }));
567
- if (report.written)
568
- receiptPath = report.path;
569
- return {
1281
+ if (receiptWrite.report.written)
1282
+ receiptPath = receiptWrite.report.path;
1283
+ return finishAttempt({
570
1284
  outcome: result.outcome,
571
1285
  confirmationRef: result.confirmationRef ?? null,
572
1286
  receiptPath,
@@ -574,10 +1288,34 @@ export function createCliCheckoutEngine(deps = {}) {
574
1288
  vicConfirmation,
575
1289
  source,
576
1290
  remainingMinor: drawnRemaining,
577
- };
1291
+ processorIntentId,
1292
+ credentialIssued: result.credentialLifecycle !== 'not-requested',
1293
+ credentialDisclosed: result.credentialLifecycle === 'partially-exposed' ||
1294
+ result.credentialLifecycle === 'fully-filled',
1295
+ receiptWrite: receiptWrite.event,
1296
+ });
1297
+ }
1298
+ catch (error) {
1299
+ rejectAttempt(error);
1300
+ throw error;
578
1301
  }
579
1302
  finally {
580
1303
  await closeSession(input.reviewId);
1304
+ if (!completedAttempt ||
1305
+ completedAttempt.outcome === 'failed' ||
1306
+ completedAttempt.outcome === 'declined') {
1307
+ if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
1308
+ payAttempts.delete(input.reviewId);
1309
+ }
1310
+ }
1311
+ else {
1312
+ const expiry = setTimeout(() => {
1313
+ if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
1314
+ payAttempts.delete(input.reviewId);
1315
+ }
1316
+ }, ttlMs);
1317
+ expiry.unref?.();
1318
+ }
581
1319
  }
582
1320
  },
583
1321
  };