@visa/cli 4.1.0-rc.26 → 4.1.0-rc.261
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +202 -46
- package/dist/checkout-engine/adapters/generic.d.ts +69 -0
- package/dist/checkout-engine/adapters/generic.js +383 -58
- package/dist/checkout-engine/adapters/index.d.ts +4 -1
- package/dist/checkout-engine/adapters/index.js +10 -3
- package/dist/checkout-engine/adapters/shopify.d.ts +80 -0
- package/dist/checkout-engine/adapters/shopify.js +688 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/browser-launch.d.ts +9 -4
- package/dist/checkout-engine/browser-launch.js +19 -4
- package/dist/checkout-engine/browserbase-browser.d.ts +24 -0
- package/dist/checkout-engine/browserbase-browser.js +186 -0
- package/dist/checkout-engine/cli-engine.d.ts +206 -32
- package/dist/checkout-engine/cli-engine.js +772 -216
- package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
- package/dist/checkout-engine/confirmed-merchants.js +165 -0
- package/dist/checkout-engine/detect.d.ts +1 -1
- package/dist/checkout-engine/detect.js +6 -0
- package/dist/checkout-engine/evidence.d.ts +1 -1
- package/dist/checkout-engine/executor.d.ts +61 -3
- package/dist/checkout-engine/executor.js +550 -144
- package/dist/checkout-engine/hosted-approval.d.ts +69 -9
- package/dist/checkout-engine/hosted-approval.js +211 -21
- package/dist/checkout-engine/index.d.ts +6 -2
- package/dist/checkout-engine/index.js +5 -1
- package/dist/checkout-engine/instrument.d.ts +6 -0
- package/dist/checkout-engine/known-merchants.d.ts +10 -0
- package/dist/checkout-engine/known-merchants.js +38 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +5 -11
- package/dist/checkout-engine/live-fill-approval.js +20 -34
- package/dist/checkout-engine/mandate/card-mandate.d.ts +6 -2
- package/dist/checkout-engine/mandate/card-mandate.js +10 -5
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +63 -23
- package/dist/checkout-engine/mandate/mandate-ledger.js +124 -17
- package/dist/checkout-engine/receipt-dir.d.ts +6 -0
- package/dist/checkout-engine/receipt-dir.js +8 -0
- package/dist/checkout-engine/receipt.d.ts +56 -2
- package/dist/checkout-engine/receipt.js +55 -16
- package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
- package/dist/checkout-engine/shopify-primary-domain.js +96 -0
- package/dist/checkout-engine/trace-handles.d.ts +8 -0
- package/dist/checkout-engine/trace-handles.js +12 -0
- package/dist/checkout-engine/types.d.ts +15 -2
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +134 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +26 -7
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +43 -3
- package/dist/checkout-engine/vgs-live-instrument.d.ts +11 -35
- package/dist/checkout-engine/vgs-live-instrument.js +14 -74
- package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
- package/dist/checkout-engine/vic-confirmation.js +9 -3
- package/dist/checkout-engine/web-bot-auth.d.ts +98 -0
- package/dist/checkout-engine/web-bot-auth.js +218 -0
- package/dist/cli.js +899 -387
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +725 -254
- package/dist/merchant-ucp-mcp/index.js +6 -0
- package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
- package/dist/skills/pair-visa-agent/SKILL.md +433 -318
- package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
- package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
- package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
- package/dist/skills/visa-ucp-shopping/SKILL.md +86 -0
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +7 -6
- package/install.sh +3 -3
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +31 -28
- package/server.json +4 -4
- package/dist/checkout-engine/inline-target.d.ts +0 -13
- package/dist/checkout-engine/inline-target.js +0 -37
- package/dist/checkout-engine/pay-args.d.ts +0 -14
- package/dist/checkout-engine/pay-args.js +0 -44
- package/dist/checkout-engine/pay.d.ts +0 -1
- package/dist/checkout-engine/pay.js +0 -13
- package/dist/checkout-engine/repo-env.d.ts +0 -11
- package/dist/checkout-engine/repo-env.js +0 -23
- package/dist/checkout-engine/run-live-fill.d.ts +0 -1
- package/dist/checkout-engine/run-live-fill.js +0 -493
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
- 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 { 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 {
|
|
18
|
+
import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval, } from './hosted-approval.js';
|
|
19
|
+
import { VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-instrument.js';
|
|
20
20
|
import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
|
|
21
|
-
import { createCardMandate, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.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,211 @@ 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
|
-
|
|
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
|
+
requiresAdapter;
|
|
165
|
+
detectedRoles;
|
|
166
|
+
receiptWrite;
|
|
167
|
+
detail;
|
|
168
|
+
constructor(result, receiptWrite) {
|
|
169
|
+
super(result.detail
|
|
170
|
+
? `review refused: ${result.outcome} — ${result.detail}`
|
|
171
|
+
: `review refused: ${result.outcome}`);
|
|
172
|
+
this.name = 'CheckoutReviewRefusedError';
|
|
173
|
+
this.checkoutOutcome = result.outcome;
|
|
174
|
+
this.failureCode = result.failureCode;
|
|
175
|
+
this.requiresAdapter = [...result.requiresAdapter];
|
|
176
|
+
this.detectedRoles = Object.keys(result.fields);
|
|
177
|
+
this.receiptWrite = receiptWrite;
|
|
178
|
+
this.detail = result.detail;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function payAttemptFingerprint(input) {
|
|
182
|
+
return JSON.stringify([
|
|
183
|
+
input.url,
|
|
184
|
+
input.checkoutRoute,
|
|
185
|
+
input.amount,
|
|
186
|
+
input.currency,
|
|
187
|
+
input.credentialPath,
|
|
188
|
+
input.cardTokenId ?? null,
|
|
189
|
+
input.agentJkt ?? null,
|
|
190
|
+
input.contact,
|
|
191
|
+
input.approvalBaseUrl,
|
|
192
|
+
input.merchantName ?? null,
|
|
193
|
+
input.merchantCountryCode ?? null,
|
|
194
|
+
input.submit,
|
|
195
|
+
]);
|
|
196
|
+
}
|
|
197
|
+
function failedPay(detail) {
|
|
198
|
+
return {
|
|
199
|
+
outcome: 'failed',
|
|
200
|
+
confirmationRef: null,
|
|
201
|
+
receiptPath: null,
|
|
202
|
+
detail,
|
|
203
|
+
vicConfirmation: null,
|
|
204
|
+
source: null,
|
|
205
|
+
remainingMinor: null,
|
|
206
|
+
credentialIssued: false,
|
|
207
|
+
credentialDisclosed: false,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function boundedReceiptWriteErrorCode(reason) {
|
|
211
|
+
return reason.match(/\b(?:EACCES|EEXIST|ENOSPC|ENOTDIR|EPERM|EROFS)\b/)?.[0] ?? 'UNKNOWN';
|
|
212
|
+
}
|
|
50
213
|
// Must match the prepared-checkout store TTL so a session and its store entry
|
|
51
214
|
// expire together — an abandoned review can't leak the browser + state.
|
|
52
215
|
const PREPARED_TTL_MS = 5 * 60 * 1000;
|
|
53
216
|
const defaultStore = new InMemoryPreparedCheckoutStore({ ttlMs: PREPARED_TTL_MS });
|
|
54
217
|
const defaultSessions = new Map();
|
|
218
|
+
const defaultPayAttempts = new Map();
|
|
55
219
|
const defaultLedger = new MandateLedger();
|
|
56
220
|
export function createCliCheckoutEngine(deps = {}) {
|
|
57
221
|
const store = deps.store ?? defaultStore;
|
|
58
222
|
const sessions = deps.sessions ?? defaultSessions;
|
|
223
|
+
const payAttempts = deps.payAttempts ?? defaultPayAttempts;
|
|
59
224
|
const ttlMs = deps.ttlMs ?? PREPARED_TTL_MS;
|
|
60
225
|
const launchBrowser = deps.launchBrowser ?? (() => launchCheckoutBrowser());
|
|
61
226
|
const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
|
|
62
227
|
const submitApprovedCheckout = deps.submitApprovedCheckout ?? realSubmitApprovedCheckout;
|
|
63
228
|
const runHostedApproval = deps.runHostedApproval ?? realRunHostedApproval;
|
|
229
|
+
const claimMandatePickup = deps.claimMandatePickup ?? realClaimMandatePickup;
|
|
64
230
|
const reportVicOutcome = deps.reportVicOutcome ?? realReportVicOutcome;
|
|
65
231
|
const writeReceipt = deps.writeReceipt ?? realWriteReceipt;
|
|
66
232
|
const ledger = deps.ledger ?? defaultLedger;
|
|
67
233
|
const now = deps.now ?? (() => new Date());
|
|
68
234
|
const fetchMandateCryptogram = deps.serverFetchCryptogram ?? serverFetchCryptogram;
|
|
235
|
+
const postServerConfirmation = deps.serverPostConfirmation ?? serverPostConfirmation;
|
|
236
|
+
const createObservationId = deps.createObservationId ?? randomUUID;
|
|
237
|
+
async function writeObservedReceipt(mode, receipt) {
|
|
238
|
+
let report;
|
|
239
|
+
try {
|
|
240
|
+
report = await writeReceipt(RECEIPT_DIR, receipt);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
// The real writer is fail-open already. Keep that invariant even for an
|
|
244
|
+
// injected writer so a filesystem or test-double failure cannot replace
|
|
245
|
+
// the checkout result after a charge may have completed.
|
|
246
|
+
report = { written: false, reason: err instanceof Error ? err.message : String(err) };
|
|
247
|
+
}
|
|
248
|
+
const event = {
|
|
249
|
+
event: 'checkout_receipt_write',
|
|
250
|
+
status: report.written ? 'written' : 'failed',
|
|
251
|
+
mode,
|
|
252
|
+
checkoutOutcome: receipt.outcome,
|
|
253
|
+
merchantHost: receipt.merchant.host,
|
|
254
|
+
observationId: receipt.observationId ?? receipt.receiptId ?? createObservationId(),
|
|
255
|
+
failureCode: receipt.capability?.failureCode ?? null,
|
|
256
|
+
errorCode: report.written ? null : boundedReceiptWriteErrorCode(report.reason),
|
|
257
|
+
panRedactions: report.written ? report.panRedactions : null,
|
|
258
|
+
};
|
|
259
|
+
try {
|
|
260
|
+
const observerResult = deps.onReceiptWrite?.(event);
|
|
261
|
+
if (observerResult)
|
|
262
|
+
void Promise.resolve(observerResult).catch(() => undefined);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// Observability is non-authoritative and must not affect payment state.
|
|
266
|
+
}
|
|
267
|
+
return { report, event };
|
|
268
|
+
}
|
|
69
269
|
const cardDrawVerdict = deps.cardDrawVerdict ?? null;
|
|
70
270
|
const cardMandateRegister = deps.cardMandateRegister ?? null;
|
|
71
271
|
async function closeSession(reviewId) {
|
|
@@ -85,6 +285,115 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
85
285
|
transactionCurrencyCode: input.currency,
|
|
86
286
|
};
|
|
87
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Restate the facts at the ceiling the SERVER approved, when it lowered the
|
|
290
|
+
* one that was requested. Nothing has been spent or reserved on a mandate this
|
|
291
|
+
* new, so its remaining headroom IS its ceiling — see `createCardMandate`,
|
|
292
|
+
* which records `spentMinor: 0` with no reservations.
|
|
293
|
+
*
|
|
294
|
+
* Only ever lowers, mirroring `applyApprovedCeiling`: the server clamps
|
|
295
|
+
* downward, so a higher number means an unexpected response and is ignored
|
|
296
|
+
* rather than reported as headroom no human approved.
|
|
297
|
+
*/
|
|
298
|
+
function approvedCeilingFacts(facts, approvedCeilingMinor) {
|
|
299
|
+
if (approvedCeilingMinor === undefined || approvedCeilingMinor >= facts.ceilingMinor)
|
|
300
|
+
return {};
|
|
301
|
+
return { ceilingMinor: approvedCeilingMinor, remainingMinor: approvedCeilingMinor };
|
|
302
|
+
}
|
|
303
|
+
// Seed the one server-authoritative cumulative store keyed by the VGS intent
|
|
304
|
+
// ID (#5942). On failure the local record is marked register-failed so
|
|
305
|
+
// findCovering() SKIPS it — the mandate exists but is never drawn tap-free —
|
|
306
|
+
// and the caller reports the failure honestly. Shared by mandate-start and
|
|
307
|
+
// pickup-claim; both refuse before their ceremony/claim when no capability
|
|
308
|
+
// can register, so registerCap is always present here.
|
|
309
|
+
async function registerMandateOrDisable(args) {
|
|
310
|
+
if (!cardMandateRegister)
|
|
311
|
+
return { registerFailed: true, registerFailureReason: 'no_seam' };
|
|
312
|
+
const reg = await cardMandateRegister
|
|
313
|
+
.register({
|
|
314
|
+
authBaseUrl: args.registerCap.authBaseUrl,
|
|
315
|
+
agentKey: args.registerCap.agentKey,
|
|
316
|
+
...(args.registerCap.runtimeCertificate
|
|
317
|
+
? { runtimeCertificate: args.registerCap.runtimeCertificate }
|
|
318
|
+
: {}),
|
|
319
|
+
mandateId: args.mandateId,
|
|
320
|
+
mintToken: args.mintToken,
|
|
321
|
+
ceiling: args.ceiling,
|
|
322
|
+
currency: args.currency,
|
|
323
|
+
})
|
|
324
|
+
.catch((err) => ({
|
|
325
|
+
ok: false,
|
|
326
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
327
|
+
}));
|
|
328
|
+
if (reg.ok) {
|
|
329
|
+
// ONE SPENDING LIMIT: auth just clamped the requested ceiling to the
|
|
330
|
+
// owner's live grant cap and told us what it committed. Adopt it, so the
|
|
331
|
+
// local record — which findCovering() selects on and `mandate list`
|
|
332
|
+
// prints — states the budget the owner actually approved. Best-effort:
|
|
333
|
+
// the mandate is registered and drawable either way, and auth's verdict
|
|
334
|
+
// remains the enforcing cap, so a failed local write must not abort the
|
|
335
|
+
// ceremony. It leaves the record overstating headroom, which is exactly
|
|
336
|
+
// the pre-existing behaviour.
|
|
337
|
+
//
|
|
338
|
+
// If that write fails the returned facts still carry the approved figure —
|
|
339
|
+
// telling the human the truth beats echoing a ceiling their grant refused,
|
|
340
|
+
// and the ledger is left exactly as overstated as it was before this
|
|
341
|
+
// existed. But the two then disagree, so say so out loud rather than let
|
|
342
|
+
// `mandate list` quietly contradict what `mandate start` just printed.
|
|
343
|
+
// Same posture as the mark-failed escalation below.
|
|
344
|
+
if (reg.approvedCeilingMinor !== undefined) {
|
|
345
|
+
const applied = await ledger
|
|
346
|
+
.applyApprovedCeiling(args.mandateId, reg.approvedCeilingMinor)
|
|
347
|
+
.then(() => true)
|
|
348
|
+
.catch(() => false);
|
|
349
|
+
if (!applied) {
|
|
350
|
+
// Rendered through minorToDecimal, never raw /100 — and only when the
|
|
351
|
+
// value is a sane integer, because THIS branch is also where an
|
|
352
|
+
// unusable value lands (applyApprovedCeiling rejects it). A warning
|
|
353
|
+
// must not throw on its way out. The currency is omitted deliberately:
|
|
354
|
+
// both mandate paths refuse anything but USD long before register, so
|
|
355
|
+
// it is known, and passing it would let a non-2-decimal code throw here.
|
|
356
|
+
const approved = Number.isSafeInteger(reg.approvedCeilingMinor) && reg.approvedCeilingMinor > 0
|
|
357
|
+
? ` (${args.currency} ${minorToDecimal(reg.approvedCeilingMinor)})`
|
|
358
|
+
: '';
|
|
359
|
+
process.stderr.write(`warning: your owner's approved limit for this budget${approved} could NOT be saved ` +
|
|
360
|
+
`locally — 'mandate list' will overstate the remaining balance until you re-run ` +
|
|
361
|
+
`'mandate start'. Spending is still capped at the approved limit; a draw over it ` +
|
|
362
|
+
`is refused. mandateId=${args.mandateId}\n`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
registerFailed: false,
|
|
367
|
+
...(reg.approvedCeilingMinor !== undefined
|
|
368
|
+
? { approvedCeilingMinor: reg.approvedCeilingMinor }
|
|
369
|
+
: {}),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
// Register failed: the server `card_mandate_spend` row was never created, so
|
|
373
|
+
// a delegated draw against this mandate would 404 `no_mandate`. Mark it
|
|
374
|
+
// register-failed so findCovering() SKIPS it rather than silently selecting
|
|
375
|
+
// a mandate that can't be drawn. A v4 grant then refuses until a usable
|
|
376
|
+
// mandate exists; only a legacy credential-file runtime can still use the
|
|
377
|
+
// old per-purchase approval. Marking is best-effort too.
|
|
378
|
+
const marked = await ledger
|
|
379
|
+
.markRegisterFailed(args.mandateId, now())
|
|
380
|
+
.then(() => true)
|
|
381
|
+
.catch(() => false);
|
|
382
|
+
process.stderr.write(marked
|
|
383
|
+
? `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) — this mandate ` +
|
|
384
|
+
`will NOT be used for draws. A v4 card grant cannot spend until a usable mandate ` +
|
|
385
|
+
`is registered. Re-run 'mandate start' to try again.\n`
|
|
386
|
+
: // Escalate: the mark write ALSO failed, so the mandate is persisted but
|
|
387
|
+
// NOT disabled — findCovering could still select an undrawable mandate.
|
|
388
|
+
// Tell the owner loudly not to rely on it and how to recover.
|
|
389
|
+
`warning: card-mandate register failed (${reg.reason ?? 'unknown'}) AND the mandate ` +
|
|
390
|
+
`could NOT be disabled locally — do not rely on it. Run 'mandate list' and re-run ` +
|
|
391
|
+
`'mandate start'. mandateId=${args.mandateId}\n`);
|
|
392
|
+
return {
|
|
393
|
+
registerFailed: true,
|
|
394
|
+
...(reg.reason !== undefined ? { registerFailureReason: reg.reason } : {}),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
88
397
|
return {
|
|
89
398
|
// BUDGET step: one passkey approves a CEILING; a VGS intent is minted with
|
|
90
399
|
// that ceiling as its decline threshold and the owner-only ledger records
|
|
@@ -95,6 +404,9 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
95
404
|
if (ceilingMinor === null || ceilingMinor <= 0) {
|
|
96
405
|
throw new Error(`invalid mandate ceiling ${JSON.stringify(input.ceiling)}`);
|
|
97
406
|
}
|
|
407
|
+
if (input.currency.toUpperCase() !== 'USD') {
|
|
408
|
+
throw new Error('card spend budgets currently support USD only');
|
|
409
|
+
}
|
|
98
410
|
if (input.perTransaction !== undefined) {
|
|
99
411
|
const perTxMinor = decimalToMinor(input.perTransaction);
|
|
100
412
|
if (perTxMinor === null || perTxMinor <= 0 || perTxMinor > ceilingMinor) {
|
|
@@ -102,25 +414,27 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
102
414
|
'must be a positive amount at or under the ceiling');
|
|
103
415
|
}
|
|
104
416
|
}
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
417
|
+
// A budget token can bootstrap one VGS intent and its register handshake,
|
|
418
|
+
// but it is never payable draw authority. Refuse before the passkey
|
|
419
|
+
// ceremony unless this runtime can both prove the separately provisioned
|
|
420
|
+
// card capability and register the resulting intent server-side.
|
|
421
|
+
const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
|
|
422
|
+
if (!cardMandateRegister || !registerCap) {
|
|
423
|
+
throw new Error('startCardMandate requires separately provisioned card authority in this runtime; ' +
|
|
424
|
+
'identity pairing alone does not grant a card mandate');
|
|
111
425
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const credential =
|
|
426
|
+
// There is one budget product: eligible retail merchants under the
|
|
427
|
+
// provider's required Retail/5999 network category, with total,
|
|
428
|
+
// per-purchase, count, and time bounds stated on the approval page. The
|
|
429
|
+
// sentinel is provider metadata, never a user-entered merchant route.
|
|
430
|
+
const merchant = {
|
|
431
|
+
name: 'retail spend budget',
|
|
432
|
+
url: 'https://retail-budget.visa/budget',
|
|
433
|
+
countryCode: 'US',
|
|
434
|
+
};
|
|
435
|
+
// Legacy credential file OR the activated card:vic grant's token — see
|
|
436
|
+
// resolveCardInstrument. A v2-paired runtime only ever has the latter.
|
|
437
|
+
const credential = await resolveCardInstrument(input);
|
|
124
438
|
// The passkey ceremony is scoped to the CEILING + merchant (not one
|
|
125
439
|
// charge) — that scope is the unproven part of the spike.
|
|
126
440
|
const ceilingTarget = {
|
|
@@ -132,16 +446,16 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
132
446
|
};
|
|
133
447
|
// BUDGET mode: the ceiling target's amount IS the approved ceiling, so the
|
|
134
448
|
// server mints a budget mint token bound to that ceiling — later draws pull
|
|
135
|
-
// sub-ceiling amounts against it tap-free
|
|
136
|
-
// path below stays non-budget).
|
|
449
|
+
// sub-ceiling amounts against it tap-free.
|
|
137
450
|
const assurance = await runHostedApproval({
|
|
138
451
|
baseUrl: input.approvalBaseUrl,
|
|
139
452
|
tokenId: credential.tokenId,
|
|
140
453
|
target: ceilingTarget,
|
|
141
454
|
consumerEmail: input.contact.email,
|
|
142
455
|
budget: true,
|
|
143
|
-
|
|
144
|
-
|
|
456
|
+
agentJkt: registerCap.agentJkt,
|
|
457
|
+
onApprovalUrl: input.onApprovalUrl ?? deps.onApprovalUrl,
|
|
458
|
+
maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
|
|
145
459
|
...(input.perTransaction !== undefined ? { perTransaction: input.perTransaction } : {}),
|
|
146
460
|
...(input.intent !== undefined ? { intent: input.intent } : {}),
|
|
147
461
|
});
|
|
@@ -150,69 +464,108 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
150
464
|
throw new Error('the approval server issued no mint token — server-side minting requires the ' +
|
|
151
465
|
'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.');
|
|
152
466
|
}
|
|
153
|
-
|
|
467
|
+
if (!Number.isSafeInteger(assurance.validUntil) ||
|
|
468
|
+
assurance.validUntil <= Math.floor(now().getTime() / 1000)) {
|
|
469
|
+
throw new Error('the approval server issued no valid budget expiry');
|
|
470
|
+
}
|
|
471
|
+
const expiresAt = new Date(assurance.validUntil * 1000).toISOString();
|
|
154
472
|
const facts = await createCardMandate({
|
|
473
|
+
agentJkt: registerCap.agentJkt,
|
|
155
474
|
tokenId: credential.tokenId,
|
|
156
475
|
assuranceData: assurance.assuranceData,
|
|
157
476
|
ceilingMinor,
|
|
158
477
|
merchant,
|
|
159
478
|
currencyCode: input.currency,
|
|
160
479
|
expiresAt,
|
|
161
|
-
|
|
162
|
-
|
|
480
|
+
maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
|
|
481
|
+
crossMerchant: true,
|
|
163
482
|
}, {
|
|
164
483
|
createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i),
|
|
165
484
|
ledger,
|
|
166
485
|
approvalBaseUrl: input.approvalBaseUrl,
|
|
167
|
-
mintToken,
|
|
168
486
|
now,
|
|
169
487
|
});
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
}
|
|
488
|
+
// Seed the one server-authoritative cumulative store keyed by the VGS
|
|
489
|
+
// intent ID. A later draw requires its PoP verdict; the budget token never
|
|
490
|
+
// falls back as payable authority.
|
|
491
|
+
const registered = await registerMandateOrDisable({
|
|
492
|
+
registerCap,
|
|
493
|
+
mandateId: facts.mandateId,
|
|
494
|
+
mintToken,
|
|
495
|
+
ceiling: input.ceiling,
|
|
496
|
+
currency: input.currency,
|
|
497
|
+
});
|
|
498
|
+
return {
|
|
499
|
+
...facts,
|
|
500
|
+
...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
|
|
501
|
+
merchantHost: new URL(merchant.url).hostname,
|
|
502
|
+
registerFailed: registered.registerFailed,
|
|
503
|
+
...(registered.registerFailureReason !== undefined
|
|
504
|
+
? { registerFailureReason: registered.registerFailureReason }
|
|
505
|
+
: {}),
|
|
506
|
+
};
|
|
507
|
+
},
|
|
508
|
+
// PICKUP leg: the owner already approved the ceiling in the account panel
|
|
509
|
+
// and handed this runtime a single-use pickup code. Claim it, verify it was
|
|
510
|
+
// minted for exactly this runtime's request key + card token, then run the
|
|
511
|
+
// same intent → register → ledger sequence as startCardMandate. No approval
|
|
512
|
+
// page, no passkey, no contact profile — the human side already happened.
|
|
513
|
+
async claimCardMandate(input) {
|
|
514
|
+
const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
|
|
515
|
+
if (!cardMandateRegister || !registerCap) {
|
|
516
|
+
throw new Error('claimCardMandate requires separately provisioned card authority in this runtime; ' +
|
|
517
|
+
'identity pairing alone does not grant a card mandate');
|
|
214
518
|
}
|
|
215
|
-
|
|
519
|
+
const credential = await resolveCardInstrument(input);
|
|
520
|
+
const claim = await claimMandatePickup({
|
|
521
|
+
baseUrl: input.approvalBaseUrl,
|
|
522
|
+
pickupCode: input.pickupCode,
|
|
523
|
+
agentJkt: registerCap.agentJkt,
|
|
524
|
+
...(input.expectedHandoffId ? { expectedHandoffId: input.expectedHandoffId } : {}),
|
|
525
|
+
tokenId: credential.tokenId,
|
|
526
|
+
});
|
|
527
|
+
const ceilingMinor = decimalToMinor(claim.ceiling);
|
|
528
|
+
if (ceilingMinor === null || ceilingMinor <= 0) {
|
|
529
|
+
throw new Error(`the handoff carried an invalid ceiling ${JSON.stringify(claim.ceiling)}`);
|
|
530
|
+
}
|
|
531
|
+
// Same single-product bound as mandate-start (the panel initiate route
|
|
532
|
+
// enforces it too; a divergent store entry must not widen it here).
|
|
533
|
+
if (claim.currency.toUpperCase() !== 'USD') {
|
|
534
|
+
throw new Error('card spend budgets currently support USD only');
|
|
535
|
+
}
|
|
536
|
+
const expiresAt = new Date(claim.validUntil * 1000).toISOString();
|
|
537
|
+
const facts = await createCardMandate({
|
|
538
|
+
agentJkt: registerCap.agentJkt,
|
|
539
|
+
tokenId: credential.tokenId,
|
|
540
|
+
assuranceData: claim.assuranceData,
|
|
541
|
+
ceilingMinor,
|
|
542
|
+
merchant: claim.merchant,
|
|
543
|
+
currencyCode: claim.currency,
|
|
544
|
+
expiresAt,
|
|
545
|
+
maxDraws: claim.maxDraws,
|
|
546
|
+
crossMerchant: true,
|
|
547
|
+
}, {
|
|
548
|
+
createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, claim.mintToken, i),
|
|
549
|
+
ledger,
|
|
550
|
+
approvalBaseUrl: input.approvalBaseUrl,
|
|
551
|
+
now,
|
|
552
|
+
});
|
|
553
|
+
const registered = await registerMandateOrDisable({
|
|
554
|
+
registerCap,
|
|
555
|
+
mandateId: facts.mandateId,
|
|
556
|
+
mintToken: claim.mintToken,
|
|
557
|
+
ceiling: claim.ceiling,
|
|
558
|
+
currency: claim.currency,
|
|
559
|
+
});
|
|
560
|
+
return {
|
|
561
|
+
...facts,
|
|
562
|
+
...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
|
|
563
|
+
merchantHost: new URL(claim.merchant.url).hostname,
|
|
564
|
+
registerFailed: registered.registerFailed,
|
|
565
|
+
...(registered.registerFailureReason !== undefined
|
|
566
|
+
? { registerFailureReason: registered.registerFailureReason }
|
|
567
|
+
: {}),
|
|
568
|
+
};
|
|
216
569
|
},
|
|
217
570
|
async review(input) {
|
|
218
571
|
const amountMinor = decimalToMinor(input.amount);
|
|
@@ -223,6 +576,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
223
576
|
try {
|
|
224
577
|
const prep = await prepareCheckout({
|
|
225
578
|
url: input.url,
|
|
579
|
+
checkoutRoute: input.checkoutRoute,
|
|
226
580
|
mandate: {
|
|
227
581
|
maxAmountMinor: amountMinor,
|
|
228
582
|
currency: input.currency,
|
|
@@ -232,22 +586,62 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
232
586
|
amountMinor,
|
|
233
587
|
currency: input.currency,
|
|
234
588
|
browser,
|
|
589
|
+
contact: input.contact,
|
|
590
|
+
...(input.trustedMerchantIdentity
|
|
591
|
+
? { trustedMerchantIdentity: input.trustedMerchantIdentity }
|
|
592
|
+
: {}),
|
|
235
593
|
}, store);
|
|
236
594
|
if (prep.status !== 'ready') {
|
|
237
|
-
|
|
238
|
-
|
|
595
|
+
// A terminal review is still a capability observation. Persist the
|
|
596
|
+
// same compact, owner-only artifact as a pay attempt so the local
|
|
597
|
+
// ledger records unsupported merchant surfaces and the safe card
|
|
598
|
+
// display identity instead of retaining successes only.
|
|
599
|
+
const observationId = createObservationId();
|
|
600
|
+
const receiptWrite = await writeObservedReceipt('dry-run', buildReceipt({
|
|
601
|
+
mode: 'dry-run',
|
|
602
|
+
reviewId: null,
|
|
603
|
+
observationId,
|
|
604
|
+
merchant: {
|
|
605
|
+
name: input.merchantName ?? host,
|
|
606
|
+
host,
|
|
607
|
+
url: input.url,
|
|
608
|
+
},
|
|
609
|
+
transaction: {
|
|
610
|
+
amount: input.amount,
|
|
611
|
+
amountMinor,
|
|
612
|
+
currency: input.currency,
|
|
613
|
+
},
|
|
614
|
+
result: prep.result,
|
|
615
|
+
vicConfirmation: null,
|
|
616
|
+
agentName: input.agentName,
|
|
617
|
+
cardLast4: input.cardLast4,
|
|
618
|
+
recordedAt: now(),
|
|
619
|
+
}));
|
|
620
|
+
throw new CheckoutReviewRefusedError(prep.result, receiptWrite.event);
|
|
239
621
|
}
|
|
240
622
|
const r = prep.checkout.review;
|
|
241
623
|
// Expire the companion session on the SAME schedule as the store entry
|
|
242
624
|
// (unref'd so a pending timer never keeps the process alive).
|
|
243
625
|
const cleanupTimer = setTimeout(() => void closeSession(r.id), ttlMs);
|
|
244
626
|
cleanupTimer.unref?.();
|
|
627
|
+
const target = buildTarget(input);
|
|
628
|
+
if (r.merchantOrigin) {
|
|
629
|
+
// The credential and mandate bind to the exact origin the browser
|
|
630
|
+
// actually reviewed, not the Shopify service continuation that led
|
|
631
|
+
// there. Keep the latter separately for review/pay replay binding.
|
|
632
|
+
target.merchantUrl = r.merchantOrigin;
|
|
633
|
+
target.merchantName = input.merchantName ?? r.merchantHost;
|
|
634
|
+
}
|
|
245
635
|
sessions.set(r.id, {
|
|
246
636
|
browser,
|
|
247
|
-
|
|
637
|
+
requestUrl: new URL(input.url).toString(),
|
|
638
|
+
checkoutRoute: input.checkoutRoute,
|
|
639
|
+
target,
|
|
248
640
|
amountMinor,
|
|
249
641
|
currency: input.currency,
|
|
250
642
|
contact: input.contact,
|
|
643
|
+
...(input.agentJkt ? { agentJkt: input.agentJkt } : {}),
|
|
644
|
+
...(input.intentNarrative ? { intentNarrative: input.intentNarrative } : {}),
|
|
251
645
|
cleanupTimer,
|
|
252
646
|
});
|
|
253
647
|
return {
|
|
@@ -264,8 +658,43 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
264
658
|
throw err;
|
|
265
659
|
}
|
|
266
660
|
},
|
|
661
|
+
/**
|
|
662
|
+
* Give up a prepared review without paying it (#7100).
|
|
663
|
+
*
|
|
664
|
+
* The retained browser is what lets a same-process MCP `review` → `pay`
|
|
665
|
+
* draw against the checkout it already inspected. A TERMINAL review-only
|
|
666
|
+
* run has no such second call — that process tells the operator to re-RUN
|
|
667
|
+
* with `--submit`, and the new process cannot reach this one's in-memory
|
|
668
|
+
* session. So the browser sat open for the full TTL, and because a live
|
|
669
|
+
* browser connection keeps the event loop alive, a command that had already
|
|
670
|
+
* succeeded looked hung until Ctrl-C.
|
|
671
|
+
*
|
|
672
|
+
* Idempotent and non-throwing: an unknown or already-released id is a
|
|
673
|
+
* no-op, so it is safe on an error path that may not have prepared anything
|
|
674
|
+
* and safe to call twice.
|
|
675
|
+
*/
|
|
676
|
+
async releaseReview(reviewId) {
|
|
677
|
+
await closeSession(reviewId);
|
|
678
|
+
payAttempts.delete(reviewId);
|
|
679
|
+
},
|
|
267
680
|
async pay(input) {
|
|
681
|
+
const noCredentialFacts = {
|
|
682
|
+
credentialIssued: false,
|
|
683
|
+
credentialDisclosed: false,
|
|
684
|
+
};
|
|
685
|
+
const fingerprint = payAttemptFingerprint(input);
|
|
686
|
+
const priorAttempt = payAttempts.get(input.reviewId);
|
|
687
|
+
if (priorAttempt) {
|
|
688
|
+
return priorAttempt.fingerprint === fingerprint
|
|
689
|
+
? priorAttempt.promise
|
|
690
|
+
: failedPay(`review ${input.reviewId} is already executing with different payment facts — wait for that exact attempt and inspect its receipt`);
|
|
691
|
+
}
|
|
268
692
|
const session = sessions.get(input.reviewId);
|
|
693
|
+
// One signal for the whole pay lifecycle. `cardTokenId` is populated only
|
|
694
|
+
// by the resolved v4 card-grant authority; legacy credential-file calls
|
|
695
|
+
// omit it. Keeping the classification here prevents copy and fallback
|
|
696
|
+
// behavior from drifting onto different grant detectors.
|
|
697
|
+
const isV4CardGrant = typeof input.cardTokenId === 'string' && input.cardTokenId.trim() !== '';
|
|
269
698
|
if (!session) {
|
|
270
699
|
return {
|
|
271
700
|
outcome: 'failed',
|
|
@@ -275,6 +704,83 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
275
704
|
vicConfirmation: null,
|
|
276
705
|
source: null,
|
|
277
706
|
remainingMinor: null,
|
|
707
|
+
...noCredentialFacts,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
// The reviewId selects the prepared browser session, but the pay call also
|
|
711
|
+
// repeats the target facts. Refuse if any repeated fact disagrees so the
|
|
712
|
+
// caller cannot submit one reviewed checkout while labeling the result or
|
|
713
|
+
// telemetry as another. Never echo the full URLs: payment links can carry
|
|
714
|
+
// claimable secrets in their path/query.
|
|
715
|
+
let payUrl;
|
|
716
|
+
let reviewedUrl;
|
|
717
|
+
try {
|
|
718
|
+
payUrl = new URL(input.url).toString();
|
|
719
|
+
reviewedUrl = session.requestUrl;
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
await closeSession(input.reviewId);
|
|
723
|
+
return {
|
|
724
|
+
outcome: 'failed',
|
|
725
|
+
confirmationRef: null,
|
|
726
|
+
receiptPath: null,
|
|
727
|
+
detail: 'pay merchant URL is invalid or does not match the reviewed checkout — start a fresh review',
|
|
728
|
+
vicConfirmation: null,
|
|
729
|
+
source: null,
|
|
730
|
+
remainingMinor: null,
|
|
731
|
+
...noCredentialFacts,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
if (payUrl !== reviewedUrl) {
|
|
735
|
+
await closeSession(input.reviewId);
|
|
736
|
+
return {
|
|
737
|
+
outcome: 'failed',
|
|
738
|
+
confirmationRef: null,
|
|
739
|
+
receiptPath: null,
|
|
740
|
+
detail: 'pay merchant URL does not match the reviewed checkout — start a fresh review',
|
|
741
|
+
vicConfirmation: null,
|
|
742
|
+
source: null,
|
|
743
|
+
remainingMinor: null,
|
|
744
|
+
...noCredentialFacts,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
if (input.currency.toUpperCase() !== session.currency.toUpperCase()) {
|
|
748
|
+
await closeSession(input.reviewId);
|
|
749
|
+
return {
|
|
750
|
+
outcome: 'failed',
|
|
751
|
+
confirmationRef: null,
|
|
752
|
+
receiptPath: null,
|
|
753
|
+
detail: `pay currency ${JSON.stringify(input.currency)} does not match the reviewed currency (${session.currency}) — start a fresh review`,
|
|
754
|
+
vicConfirmation: null,
|
|
755
|
+
source: null,
|
|
756
|
+
remainingMinor: null,
|
|
757
|
+
...noCredentialFacts,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
if (input.agentJkt !== session.agentJkt) {
|
|
761
|
+
await closeSession(input.reviewId);
|
|
762
|
+
return {
|
|
763
|
+
outcome: 'failed',
|
|
764
|
+
confirmationRef: null,
|
|
765
|
+
receiptPath: null,
|
|
766
|
+
detail: 'pay agent authority does not match the reviewed request key — start a fresh review',
|
|
767
|
+
vicConfirmation: null,
|
|
768
|
+
source: null,
|
|
769
|
+
remainingMinor: null,
|
|
770
|
+
...noCredentialFacts,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
if (input.checkoutRoute !== session.checkoutRoute) {
|
|
774
|
+
await closeSession(input.reviewId);
|
|
775
|
+
return {
|
|
776
|
+
outcome: 'failed',
|
|
777
|
+
confirmationRef: null,
|
|
778
|
+
receiptPath: null,
|
|
779
|
+
detail: 'pay checkout route does not match the reviewed guest-card route — start a fresh review',
|
|
780
|
+
vicConfirmation: null,
|
|
781
|
+
source: null,
|
|
782
|
+
remainingMinor: null,
|
|
783
|
+
...noCredentialFacts,
|
|
278
784
|
};
|
|
279
785
|
}
|
|
280
786
|
// Amount-bind the confirmation: the pay-call amount must match the
|
|
@@ -292,6 +798,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
292
798
|
vicConfirmation: null,
|
|
293
799
|
source: null,
|
|
294
800
|
remainingMinor: null,
|
|
801
|
+
...noCredentialFacts,
|
|
295
802
|
};
|
|
296
803
|
}
|
|
297
804
|
// Reject an expired prepared checkout BEFORE running the hosted passkey
|
|
@@ -307,112 +814,93 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
307
814
|
vicConfirmation: null,
|
|
308
815
|
source: null,
|
|
309
816
|
remainingMinor: null,
|
|
817
|
+
...noCredentialFacts,
|
|
310
818
|
};
|
|
311
819
|
}
|
|
820
|
+
let resolveAttempt;
|
|
821
|
+
let rejectAttempt;
|
|
822
|
+
const sharedResult = new Promise((resolve, reject) => {
|
|
823
|
+
resolveAttempt = resolve;
|
|
824
|
+
rejectAttempt = reject;
|
|
825
|
+
});
|
|
826
|
+
// The first caller still receives the direct execution result below; this
|
|
827
|
+
// retained promise exists for overlapping and bounded late duplicates.
|
|
828
|
+
// Mark its rejection handled even when there is no duplicate consumer.
|
|
829
|
+
void sharedResult.catch(() => undefined);
|
|
830
|
+
payAttempts.set(input.reviewId, { fingerprint, promise: sharedResult });
|
|
831
|
+
let completedAttempt;
|
|
832
|
+
const finishAttempt = (result) => {
|
|
833
|
+
completedAttempt = result;
|
|
834
|
+
resolveAttempt(result);
|
|
835
|
+
return result;
|
|
836
|
+
};
|
|
312
837
|
clearTimeout(session.cleanupTimer);
|
|
313
838
|
try {
|
|
314
|
-
|
|
839
|
+
// NO instrument read here. A tap-free mandate draw spends `covering
|
|
840
|
+
// .tokenId` (frozen into the mandate at start) and needs neither the
|
|
841
|
+
// legacy credential file nor a grant token, so a grant-only device with
|
|
842
|
+
// an as-yet-unrefreshed token can still draw on a mandate it already
|
|
843
|
+
// holds.
|
|
315
844
|
const host = new URL(session.target.merchantUrl).hostname;
|
|
316
845
|
// Does an ACTIVE card mandate already cover this exact purchase? If so,
|
|
317
|
-
// draw against it TAP-FREE (no hosted passkey). Else
|
|
318
|
-
//
|
|
319
|
-
//
|
|
846
|
+
// draw against it TAP-FREE (no hosted passkey). Else refuse with the
|
|
847
|
+
// mandate remedy (#7348). Mandates no longer persist the bootstrap
|
|
848
|
+
// token: it is consumed by intent creation + register and has no draw
|
|
849
|
+
// power.
|
|
320
850
|
const covering = await ledger.findCovering({
|
|
321
851
|
merchantHost: host,
|
|
322
852
|
currencyCode: session.currency,
|
|
323
853
|
amountMinor: session.amountMinor,
|
|
854
|
+
agentJkt: session.agentJkt,
|
|
324
855
|
now: now(),
|
|
325
856
|
});
|
|
326
857
|
let source;
|
|
327
858
|
let confirmBase;
|
|
328
|
-
|
|
859
|
+
// Mandate draws set this only after the local reserve succeeds and auth
|
|
860
|
+
// returns the exact per-draw verdict used to mint the credential.
|
|
861
|
+
let confirmationAuthority = null;
|
|
329
862
|
let drawnRemaining = null;
|
|
330
863
|
let instrument;
|
|
331
|
-
if (covering
|
|
864
|
+
if (covering) {
|
|
332
865
|
// --- Tap-free mandate draw ------------------------------------------
|
|
333
866
|
source = 'mandate';
|
|
334
867
|
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
868
|
const mandateId = covering.mandateId;
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// binding (or seam not injected) → `drawToken` stays the bearer token:
|
|
344
|
-
// today's #5917 draw, byte-for-byte unchanged (converge-not-break).
|
|
869
|
+
// A mandate cryptogram is authorized only by a PoP-signed verdict.
|
|
870
|
+
// The budget mint token is bootstrap-only and cannot bypass the
|
|
871
|
+
// server reserve/commit ledger when local draw authority is absent.
|
|
345
872
|
//
|
|
346
873
|
// #5928 CRITICAL-2: the CLI does NOT settle the reservation. verify-web
|
|
347
874
|
// (which knows whether the cryptogram was payable) commits it on a
|
|
348
875
|
// payable mint and releases it on a decline, server-authoritatively, so
|
|
349
876
|
// the drawer can never release after a payable mint to dodge the ceiling.
|
|
350
|
-
|
|
351
|
-
const capability =
|
|
352
|
-
if (capability
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
}
|
|
877
|
+
const verdictSeam = cardDrawVerdict;
|
|
878
|
+
const capability = verdictSeam?.loadCapability(covering.agentJkt) ?? null;
|
|
879
|
+
if (!capability || !verdictSeam) {
|
|
880
|
+
return finishAttempt({
|
|
881
|
+
outcome: 'failed',
|
|
882
|
+
confirmationRef: null,
|
|
883
|
+
receiptPath: null,
|
|
884
|
+
detail: 'this mandate cannot draw because its delegated card authority is unavailable; ' +
|
|
885
|
+
're-pair this runtime to restore the binding, or start a new mandate for this agent',
|
|
886
|
+
vicConfirmation: null,
|
|
887
|
+
source,
|
|
888
|
+
remainingMinor: null,
|
|
889
|
+
...noCredentialFacts,
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
if (covering.agentJkt && capability.agentJkt !== covering.agentJkt) {
|
|
893
|
+
return finishAttempt({
|
|
894
|
+
outcome: 'failed',
|
|
895
|
+
confirmationRef: null,
|
|
896
|
+
receiptPath: null,
|
|
897
|
+
detail: 'this budget belongs to a different request key than the selected card capability; ' +
|
|
898
|
+
'restore that exact runtime key, or start a new mandate from the current runtime',
|
|
899
|
+
vicConfirmation: null,
|
|
900
|
+
source,
|
|
901
|
+
remainingMinor: null,
|
|
902
|
+
...noCredentialFacts,
|
|
903
|
+
});
|
|
416
904
|
}
|
|
417
905
|
// VgsLiveInstrument mints against an EXISTING intent (the mandate) with
|
|
418
906
|
// no fresh assurance — exactly the draw semantics. The fetchCredential
|
|
@@ -435,7 +923,36 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
435
923
|
amountMinor: session.amountMinor,
|
|
436
924
|
transaction: { ...transaction, transactionCurrencyCode: session.currency },
|
|
437
925
|
}, {
|
|
438
|
-
|
|
926
|
+
// Ordering matters: reserve the local ledger FIRST, then ask
|
|
927
|
+
// auth to reserve the server-authoritative budget immediately
|
|
928
|
+
// before the payable mint. A local reserve race/expiry/file
|
|
929
|
+
// failure therefore makes zero auth/verdict calls and cannot
|
|
930
|
+
// strand a server reservation until its sweep.
|
|
931
|
+
fetchCryptogram: async (i) => {
|
|
932
|
+
const { verdict } = await verdictSeam.fetchVerdict({
|
|
933
|
+
authBaseUrl: capability.authBaseUrl,
|
|
934
|
+
agentKey: capability.agentKey,
|
|
935
|
+
...(capability.runtimeCertificate
|
|
936
|
+
? { runtimeCertificate: capability.runtimeCertificate }
|
|
937
|
+
: {}),
|
|
938
|
+
mandateId,
|
|
939
|
+
// One draw per review; the reviewId is auth's idempotency key.
|
|
940
|
+
drawId: input.reviewId,
|
|
941
|
+
draw: {
|
|
942
|
+
tokenId: covering.tokenId,
|
|
943
|
+
amount: session.target.transactionAmount,
|
|
944
|
+
currency: session.currency,
|
|
945
|
+
merchantName: session.target.merchantName,
|
|
946
|
+
merchantUrl: session.target.merchantUrl,
|
|
947
|
+
merchantCountryCode: session.target.merchantCountryCode,
|
|
948
|
+
},
|
|
949
|
+
...(session.intentNarrative
|
|
950
|
+
? { intentNarrative: session.intentNarrative }
|
|
951
|
+
: {}),
|
|
952
|
+
});
|
|
953
|
+
confirmationAuthority = verdict;
|
|
954
|
+
return fetchMandateCryptogram(confirmBase, verdict, i);
|
|
955
|
+
},
|
|
439
956
|
ledger,
|
|
440
957
|
now,
|
|
441
958
|
});
|
|
@@ -445,7 +962,8 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
445
962
|
// (MandateDrawDeclinedError). That case leaves the mandate
|
|
446
963
|
// active+covering, so a naive retry would re-select it and fail
|
|
447
964
|
// identically — trapping the caller; marking it unhonored makes the
|
|
448
|
-
// next pay_merchant skip it and
|
|
965
|
+
// next pay_merchant skip it and surface the no-covering-mandate refusal
|
|
966
|
+
// (#7348) instead of re-failing identically. We do
|
|
449
967
|
// NOT attempt an unsafe same-call browser fallback mid-submit.
|
|
450
968
|
//
|
|
451
969
|
// A PRE-network failure — reserve() failing closed on a concurrent
|
|
@@ -463,9 +981,28 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
463
981
|
// Classify on the underlying gateway CAUSE, not the MandateDrawDeclinedError
|
|
464
982
|
// wrapper — the wrapper's own advisory text mentions "try again"/"network",
|
|
465
983
|
// which would otherwise self-classify every decline as transient.
|
|
466
|
-
if (err instanceof MandateDrawDeclinedError
|
|
467
|
-
|
|
468
|
-
|
|
984
|
+
if (err instanceof MandateDrawDeclinedError) {
|
|
985
|
+
const verdictFailure = classifyCardDrawVerdictFailure(err);
|
|
986
|
+
if (verdictFailure) {
|
|
987
|
+
// #7491: a GRANT-capacity refusal is pre-credential and says
|
|
988
|
+
// nothing about this mandate, so it must NOT disable it. The
|
|
989
|
+
// mandate is healthy and becomes drawable again the moment the
|
|
990
|
+
// owner supplies a grant with room; disabling it here threw
|
|
991
|
+
// away a just-approved budget and pointed the caller at a new
|
|
992
|
+
// mandate that would die on the same wall.
|
|
993
|
+
if (!verdictFailure.transient && !verdictFailure.grantCapacity) {
|
|
994
|
+
await ledger.markUnhonored(mandateId, now()).catch(() => { });
|
|
995
|
+
}
|
|
996
|
+
if (verdictFailure.grantCapacity) {
|
|
997
|
+
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 });
|
|
998
|
+
}
|
|
999
|
+
throw new Error(verdictFailure.transient
|
|
1000
|
+
? `the card-mandate draw could not be authorized right now (${verdictFailure.reasons.join(', ') || 'temporary error'}) — retry shortly`
|
|
1001
|
+
: `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 });
|
|
1002
|
+
}
|
|
1003
|
+
if (!isTransientDrawFailure(err.cause ?? err)) {
|
|
1004
|
+
await ledger.markUnhonored(mandateId, now());
|
|
1005
|
+
}
|
|
469
1006
|
}
|
|
470
1007
|
// #5928 CRITICAL-2: the server-side reservation is released by
|
|
471
1008
|
// verify-web (it saw the mint fail), not here — the CLI never settles.
|
|
@@ -479,48 +1016,31 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
479
1016
|
instrument = new VgsLiveInstrument(reference, session.contact.fullName ?? '', fetchCredential);
|
|
480
1017
|
}
|
|
481
1018
|
else {
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
}
|
|
1019
|
+
// #7348 single-purchase retirement: card spend is mandate-only for
|
|
1020
|
+
// EVERY runtime. The legacy 1:1 single-purchase flow here minted a
|
|
1021
|
+
// payable credential outside the grant/mandate ledger — invisible to
|
|
1022
|
+
// spending controls. The server now refuses non-budget approvals at
|
|
1023
|
+
// registration and non-budget mint tokens at verification; this
|
|
1024
|
+
// client refusal exists to give the honest remedy up front instead
|
|
1025
|
+
// of a mid-flow server error.
|
|
1026
|
+
return finishAttempt({
|
|
1027
|
+
outcome: 'failed',
|
|
1028
|
+
confirmationRef: null,
|
|
1029
|
+
receiptPath: null,
|
|
1030
|
+
detail: isV4CardGrant
|
|
1031
|
+
? 'this v4 card grant has no active mandate covering the purchase; run ' +
|
|
1032
|
+
'`visa mandate start --ceiling <usd> --per-transaction <usd>` for the selected ' +
|
|
1033
|
+
'agent, approve and claim the mandate, then retry this checkout. No payment ' +
|
|
1034
|
+
'credential was requested and no charge was made.'
|
|
1035
|
+
: 'one-off card approvals are retired (#7348): card spending always runs through ' +
|
|
1036
|
+
'an owner-approved mandate now. Attach card authority to this agent ' +
|
|
1037
|
+
'(`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait`), ' +
|
|
1038
|
+
'then start and claim a mandate (`visa mandate start <usd> --per-transaction <usd>`), ' +
|
|
1039
|
+
'and retry the checkout. No payment credential was requested and no charge was made.',
|
|
1040
|
+
vicConfirmation: null,
|
|
1041
|
+
source: null,
|
|
1042
|
+
remainingMinor: null,
|
|
1043
|
+
...noCredentialFacts,
|
|
524
1044
|
});
|
|
525
1045
|
}
|
|
526
1046
|
const mode = input.submit ? 'submit' : 'dry-run';
|
|
@@ -529,24 +1049,33 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
529
1049
|
instrument,
|
|
530
1050
|
contact: session.contact,
|
|
531
1051
|
mode,
|
|
1052
|
+
...(deps.resolveEmailOtp ? { resolveEmailOtp: deps.resolveEmailOtp } : {}),
|
|
532
1053
|
}, store);
|
|
533
1054
|
// Report the observed submit outcome to VIC for the consumed intent
|
|
534
1055
|
// (APPROVED/DECLINED). Only definitive submit answers post. Mirrors
|
|
535
1056
|
// run-live-fill.ts.
|
|
536
1057
|
let vicConfirmation = null;
|
|
1058
|
+
let processorIntentId = null;
|
|
537
1059
|
if (mode === 'submit') {
|
|
1060
|
+
const confirmationTarget = instrument.confirmationTarget();
|
|
1061
|
+
processorIntentId = confirmationTarget?.intentId ?? null;
|
|
538
1062
|
vicConfirmation = await reportVicOutcome({
|
|
539
|
-
target:
|
|
1063
|
+
target: confirmationTarget,
|
|
540
1064
|
outcome: result.outcome,
|
|
541
1065
|
transaction: {
|
|
542
1066
|
transactionAmount: session.target.transactionAmount,
|
|
543
1067
|
transactionCurrencyCode: session.currency,
|
|
544
1068
|
},
|
|
545
|
-
post: (confInput) =>
|
|
1069
|
+
post: (confInput) => {
|
|
1070
|
+
if (!confirmationAuthority) {
|
|
1071
|
+
throw new Error('confirmation authority missing for the consumed VIC intent');
|
|
1072
|
+
}
|
|
1073
|
+
return postServerConfirmation(confirmBase, confirmationAuthority, confInput);
|
|
1074
|
+
},
|
|
546
1075
|
});
|
|
547
1076
|
}
|
|
548
1077
|
let receiptPath = null;
|
|
549
|
-
const
|
|
1078
|
+
const receiptWrite = await writeObservedReceipt(mode, buildReceipt({
|
|
550
1079
|
mode,
|
|
551
1080
|
reviewId: input.reviewId,
|
|
552
1081
|
// Derive BOTH name and host from the reviewed session target (not
|
|
@@ -555,6 +1084,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
555
1084
|
merchant: {
|
|
556
1085
|
name: session.target.merchantName,
|
|
557
1086
|
host: new URL(session.target.merchantUrl).hostname,
|
|
1087
|
+
url: session.target.merchantUrl,
|
|
558
1088
|
},
|
|
559
1089
|
transaction: {
|
|
560
1090
|
amount: session.target.transactionAmount,
|
|
@@ -563,10 +1093,12 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
563
1093
|
},
|
|
564
1094
|
result,
|
|
565
1095
|
vicConfirmation,
|
|
1096
|
+
agentName: input.agentName,
|
|
1097
|
+
cardLast4: input.cardLast4,
|
|
566
1098
|
}));
|
|
567
|
-
if (report.written)
|
|
568
|
-
receiptPath = report.path;
|
|
569
|
-
return {
|
|
1099
|
+
if (receiptWrite.report.written)
|
|
1100
|
+
receiptPath = receiptWrite.report.path;
|
|
1101
|
+
return finishAttempt({
|
|
570
1102
|
outcome: result.outcome,
|
|
571
1103
|
confirmationRef: result.confirmationRef ?? null,
|
|
572
1104
|
receiptPath,
|
|
@@ -574,10 +1106,34 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
574
1106
|
vicConfirmation,
|
|
575
1107
|
source,
|
|
576
1108
|
remainingMinor: drawnRemaining,
|
|
577
|
-
|
|
1109
|
+
processorIntentId,
|
|
1110
|
+
credentialIssued: result.credentialLifecycle !== 'not-requested',
|
|
1111
|
+
credentialDisclosed: result.credentialLifecycle === 'partially-exposed' ||
|
|
1112
|
+
result.credentialLifecycle === 'fully-filled',
|
|
1113
|
+
receiptWrite: receiptWrite.event,
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
catch (error) {
|
|
1117
|
+
rejectAttempt(error);
|
|
1118
|
+
throw error;
|
|
578
1119
|
}
|
|
579
1120
|
finally {
|
|
580
1121
|
await closeSession(input.reviewId);
|
|
1122
|
+
if (!completedAttempt ||
|
|
1123
|
+
completedAttempt.outcome === 'failed' ||
|
|
1124
|
+
completedAttempt.outcome === 'declined') {
|
|
1125
|
+
if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
|
|
1126
|
+
payAttempts.delete(input.reviewId);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
else {
|
|
1130
|
+
const expiry = setTimeout(() => {
|
|
1131
|
+
if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
|
|
1132
|
+
payAttempts.delete(input.reviewId);
|
|
1133
|
+
}
|
|
1134
|
+
}, ttlMs);
|
|
1135
|
+
expiry.unref?.();
|
|
1136
|
+
}
|
|
581
1137
|
}
|
|
582
1138
|
},
|
|
583
1139
|
};
|