@visa/cli 4.1.0-rc.3 → 4.1.0-rc.31

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 (70) hide show
  1. package/README.md +132 -242
  2. package/dist/checkout-engine/adapters/generic.d.ts +19 -0
  3. package/dist/checkout-engine/adapters/generic.js +201 -0
  4. package/dist/checkout-engine/adapters/index.d.ts +7 -0
  5. package/dist/checkout-engine/adapters/index.js +17 -0
  6. package/dist/checkout-engine/adapters/stripe-like.d.ts +10 -0
  7. package/dist/checkout-engine/adapters/stripe-like.js +21 -0
  8. package/dist/checkout-engine/browser-launch.d.ts +46 -0
  9. package/dist/checkout-engine/browser-launch.js +81 -0
  10. package/dist/checkout-engine/ceremony.d.ts +64 -0
  11. package/dist/checkout-engine/ceremony.js +261 -0
  12. package/dist/checkout-engine/cli-engine.d.ts +208 -0
  13. package/dist/checkout-engine/cli-engine.js +584 -0
  14. package/dist/checkout-engine/detect.d.ts +61 -0
  15. package/dist/checkout-engine/detect.js +392 -0
  16. package/dist/checkout-engine/evidence.d.ts +25 -0
  17. package/dist/checkout-engine/evidence.js +104 -0
  18. package/dist/checkout-engine/executor.d.ts +174 -0
  19. package/dist/checkout-engine/executor.js +1306 -0
  20. package/dist/checkout-engine/hosted-approval.d.ts +135 -0
  21. package/dist/checkout-engine/hosted-approval.js +311 -0
  22. package/dist/checkout-engine/index.d.ts +6 -0
  23. package/dist/checkout-engine/index.js +8 -0
  24. package/dist/checkout-engine/inline-target.d.ts +13 -0
  25. package/dist/checkout-engine/inline-target.js +37 -0
  26. package/dist/checkout-engine/instrument.d.ts +55 -0
  27. package/dist/checkout-engine/instrument.js +87 -0
  28. package/dist/checkout-engine/live-fill-approval.d.ts +43 -0
  29. package/dist/checkout-engine/live-fill-approval.js +90 -0
  30. package/dist/checkout-engine/mandate/card-mandate.d.ts +117 -0
  31. package/dist/checkout-engine/mandate/card-mandate.js +221 -0
  32. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +135 -0
  33. package/dist/checkout-engine/mandate/mandate-ledger.js +318 -0
  34. package/dist/checkout-engine/mandate.d.ts +25 -0
  35. package/dist/checkout-engine/mandate.js +100 -0
  36. package/dist/checkout-engine/outcome.d.ts +30 -0
  37. package/dist/checkout-engine/outcome.js +225 -0
  38. package/dist/checkout-engine/owner-only-file.d.ts +19 -0
  39. package/dist/checkout-engine/owner-only-file.js +41 -0
  40. package/dist/checkout-engine/package.json +3 -0
  41. package/dist/checkout-engine/pay-args.d.ts +14 -0
  42. package/dist/checkout-engine/pay-args.js +44 -0
  43. package/dist/checkout-engine/pay.d.ts +1 -0
  44. package/dist/checkout-engine/pay.js +13 -0
  45. package/dist/checkout-engine/receipt.d.ts +81 -0
  46. package/dist/checkout-engine/receipt.js +109 -0
  47. package/dist/checkout-engine/repo-env.d.ts +11 -0
  48. package/dist/checkout-engine/repo-env.js +23 -0
  49. package/dist/checkout-engine/run-live-fill.d.ts +1 -0
  50. package/dist/checkout-engine/run-live-fill.js +493 -0
  51. package/dist/checkout-engine/types.d.ts +39 -0
  52. package/dist/checkout-engine/types.js +2 -0
  53. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +74 -0
  54. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +248 -0
  55. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +82 -0
  56. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +178 -0
  57. package/dist/checkout-engine/vgs-live-instrument.d.ts +168 -0
  58. package/dist/checkout-engine/vgs-live-instrument.js +289 -0
  59. package/dist/checkout-engine/vic-confirmation.d.ts +34 -0
  60. package/dist/checkout-engine/vic-confirmation.js +39 -0
  61. package/dist/cli.js +327 -375
  62. package/dist/mcp-server/index.js +253 -163
  63. package/dist/skills/pair-visa-agent/RUNTIMES.md +79 -0
  64. package/dist/skills/pair-visa-agent/SKILL.md +403 -0
  65. package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
  66. package/install.ps1 +3 -41
  67. package/install.sh +3 -35
  68. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  69. package/package.json +9 -5
  70. package/server.json +3 -3
@@ -0,0 +1,43 @@
1
+ import type { CheckoutMode, CheckoutOutcome, CheckoutResult } from './executor.js';
2
+ export declare const SUBMIT_ENV_FLAG = "CHECKOUT_AGENT_ALLOW_SUBMIT";
3
+ export declare const MODE_ENV_FLAG = "CHECKOUT_AGENT_MODE";
4
+ /**
5
+ * `--mode` values map 1:1 onto CheckoutMode; an absent flag falls back to the
6
+ * CHECKOUT_AGENT_MODE environment default, then to the safe dry-run default.
7
+ */
8
+ export declare function parseCheckoutMode(value: string | undefined, env?: Record<string, string | undefined>): CheckoutMode;
9
+ /**
10
+ * Fail fast (before any file read or browser launch) when submit lacks an
11
+ * enablement. The env arm exists to stop AUTOMATION from reaching a live
12
+ * charge by accident — so it is required only when stdin is not a real TTY
13
+ * (scripts, agents, pipes). An interactive human needs no standing config:
14
+ * their per-run consent is the typed PAY phrase plus the passkey tap, both of
15
+ * which a non-interactive caller cannot fake (the phrase names the exact
16
+ * review id; the passkey is a FIDO ceremony on the verify site).
17
+ */
18
+ export declare function assertSubmitAllowed(mode: CheckoutMode, env?: Record<string, string | undefined>, isInteractive?: boolean): void;
19
+ /** Distinct phrases per mode so dry-run muscle memory can never authorize a payment. */
20
+ export declare function approvalPhrase(mode: CheckoutMode, reviewId: string): string;
21
+ /** Integer-only minor→display formatting (two-decimal currencies, the same assumption as decimalToMinor). */
22
+ export declare function formatAmountMinor(amountMinor: number, currency: string): string;
23
+ /** The operator question. In submit mode it names the exact charge — amount and merchant. */
24
+ export declare function approvalQuestion(mode: CheckoutMode, review: {
25
+ merchantHost: string;
26
+ amountMinor: number;
27
+ currency: string;
28
+ }, phrase: string): string;
29
+ /** The one outcome each mode may exit 0 with. */
30
+ export declare function isRunSuccess(mode: CheckoutMode, outcome: CheckoutOutcome): boolean;
31
+ /**
32
+ * True when the pay control was actually clicked but the run ended with an
33
+ * UNKNOWN outcome — neither 'confirmed' nor 'declined' (e.g. a slow redirect
34
+ * or confirmation copy the heuristics don't recognize). The charge may still
35
+ * have gone through; the operator must verify with the merchant before any
36
+ * retry, because a blind rerun risks a double charge. A 'declined' outcome is
37
+ * a definitive negative from the merchant, so it does not warn. Neither does
38
+ * 'action-required': a 3-D Secure challenge intercepts BEFORE authorization,
39
+ * so no charge can exist until it is completed — the double-charge warning
40
+ * would misdirect the operator, and the runner prints the challenge-specific
41
+ * notice instead.
42
+ */
43
+ export declare function submitClickedWithoutConfirmation(result: CheckoutResult): boolean;
@@ -0,0 +1,90 @@
1
+ // Deliberate-enablement seam for run-live-fill's submit mode. A real submit
2
+ // moves real money, so it requires:
3
+ // 1. an interactive human at a real TTY — or, for scripted/agent runs,
4
+ // the explicit CHECKOUT_AGENT_ALLOW_SUBMIT=1 environment arm,
5
+ // 2. the invocation opts in (--mode submit, or the CHECKOUT_AGENT_MODE
6
+ // environment default for operators who always run one mode),
7
+ // 3. the operator types the PAY phrase against the exact review id (and a
8
+ // hosted run additionally requires the passkey tap on the verify site).
9
+ // The env arm is an anti-automation gate, not a human ritual: a TTY human's
10
+ // consent is act 3 — the PAY phrase differs from dry-run's FILL phrase, so a
11
+ // muscle-memory rerun of the dry-run procedure still cannot drift into
12
+ // charging a card — while a non-interactive caller (script, agent, pipe) can
13
+ // fake neither the phrase's review id nor the FIDO ceremony and must also be
14
+ // explicitly armed. Fresh checkouts therefore need ZERO persisted config.
15
+ export const SUBMIT_ENV_FLAG = 'CHECKOUT_AGENT_ALLOW_SUBMIT';
16
+ export const MODE_ENV_FLAG = 'CHECKOUT_AGENT_MODE';
17
+ /**
18
+ * `--mode` values map 1:1 onto CheckoutMode; an absent flag falls back to the
19
+ * CHECKOUT_AGENT_MODE environment default, then to the safe dry-run default.
20
+ */
21
+ export function parseCheckoutMode(value, env = process.env) {
22
+ const effective = value ?? env[MODE_ENV_FLAG];
23
+ if (effective === undefined || effective === 'dry-run')
24
+ return 'dry-run';
25
+ if (effective === 'submit')
26
+ return 'submit';
27
+ throw new Error(`--mode (or ${MODE_ENV_FLAG}) must be dry-run or submit (got ${JSON.stringify(effective)})`);
28
+ }
29
+ /**
30
+ * Fail fast (before any file read or browser launch) when submit lacks an
31
+ * enablement. The env arm exists to stop AUTOMATION from reaching a live
32
+ * charge by accident — so it is required only when stdin is not a real TTY
33
+ * (scripts, agents, pipes). An interactive human needs no standing config:
34
+ * their per-run consent is the typed PAY phrase plus the passkey tap, both of
35
+ * which a non-interactive caller cannot fake (the phrase names the exact
36
+ * review id; the passkey is a FIDO ceremony on the verify site).
37
+ */
38
+ export function assertSubmitAllowed(mode, env = process.env, isInteractive = process.stdin.isTTY === true) {
39
+ if (mode !== 'submit')
40
+ return;
41
+ if (env[SUBMIT_ENV_FLAG] === '1')
42
+ return;
43
+ if (isInteractive)
44
+ return;
45
+ throw new Error(`--mode submit refused: this is not an interactive terminal, and scripted submits ` +
46
+ `require ${SUBMIT_ENV_FLAG}=1. The gate is default-off so automation cannot ` +
47
+ 'reach a live charge by accident.');
48
+ }
49
+ /** Distinct phrases per mode so dry-run muscle memory can never authorize a payment. */
50
+ export function approvalPhrase(mode, reviewId) {
51
+ return `${mode === 'submit' ? 'PAY' : 'FILL'} ${reviewId}`;
52
+ }
53
+ /** Integer-only minor→display formatting (two-decimal currencies, the same assumption as decimalToMinor). */
54
+ export function formatAmountMinor(amountMinor, currency) {
55
+ return `${Math.floor(amountMinor / 100)}.${String(amountMinor % 100).padStart(2, '0')} ${currency}`;
56
+ }
57
+ /** The operator question. In submit mode it names the exact charge — amount and merchant. */
58
+ export function approvalQuestion(mode, review, phrase) {
59
+ if (mode === 'submit') {
60
+ return (`Inspect the visible checkout. This WILL charge ` +
61
+ `${formatAmountMinor(review.amountMinor, review.currency)} at ${review.merchantHost}. ` +
62
+ `Type ${JSON.stringify(phrase)} to disclose the credential AND submit the order: `);
63
+ }
64
+ return (`Inspect the visible checkout. Type ${JSON.stringify(phrase)} ` +
65
+ `to disclose the credential to this page without submitting: `);
66
+ }
67
+ /** The one outcome each mode may exit 0 with. */
68
+ export function isRunSuccess(mode, outcome) {
69
+ return outcome === (mode === 'submit' ? 'confirmed' : 'filled-dry-run');
70
+ }
71
+ /**
72
+ * True when the pay control was actually clicked but the run ended with an
73
+ * UNKNOWN outcome — neither 'confirmed' nor 'declined' (e.g. a slow redirect
74
+ * or confirmation copy the heuristics don't recognize). The charge may still
75
+ * have gone through; the operator must verify with the merchant before any
76
+ * retry, because a blind rerun risks a double charge. A 'declined' outcome is
77
+ * a definitive negative from the merchant, so it does not warn. Neither does
78
+ * 'action-required': a 3-D Secure challenge intercepts BEFORE authorization,
79
+ * so no charge can exist until it is completed — the double-charge warning
80
+ * would misdirect the operator, and the runner prints the challenge-specific
81
+ * notice instead.
82
+ */
83
+ export function submitClickedWithoutConfirmation(result) {
84
+ if (result.outcome === 'confirmed' ||
85
+ result.outcome === 'declined' ||
86
+ result.outcome === 'action-required') {
87
+ return false;
88
+ }
89
+ return result.evidence.getSteps().some((s) => s.type === 'submit' && s.data.clicked === true);
90
+ }
@@ -0,0 +1,117 @@
1
+ import { type VgsCheckoutTarget, type VgsPaymentCredential } from '../vgs-live-instrument.js';
2
+ import { MandateLedger } from './mandate-ledger.js';
3
+ /**
4
+ * Default number of draws a ceiling intent may fulfil (VGS mandate quantity).
5
+ * This is a purchase-COUNT cap, NOT the exposure bound: VGS enforces the
6
+ * declineThreshold as a CUMULATIVE cap across draws (live-verified 2026-07-23),
7
+ * so the total the network will authorize is the ceiling REGARDLESS of how many
8
+ * draws it is spread across — maxDraws only limits the number of separate
9
+ * purchases, it does not multiply the exposure. Kept SMALL as a sensible default
10
+ * purchase count (the owner-side client ledger also caps cumulative spend at ONE
11
+ * ceiling). This is the single default; callers that don't set maxDraws inherit
12
+ * it (do not re-duplicate it upstream).
13
+ */
14
+ export declare const DEFAULT_MANDATE_MAX_DRAWS = 3;
15
+ export type CardMandateMerchant = {
16
+ name: string;
17
+ url: string;
18
+ countryCode: string;
19
+ };
20
+ export type CreateCardMandateInput = {
21
+ tokenId: string;
22
+ assuranceData: unknown;
23
+ ceilingMinor: number;
24
+ merchant: CardMandateMerchant;
25
+ currencyCode: string;
26
+ expiresAt: string;
27
+ /** Max draws the ceiling intent may fulfil; defaults to DEFAULT_MANDATE_MAX_DRAWS. */
28
+ maxDraws?: number;
29
+ /** BUDGET mandate — spendable at any merchant (see CardMandateRecord.crossMerchant). */
30
+ crossMerchant?: boolean;
31
+ };
32
+ export type CreateIntentFn = (input: {
33
+ tokenId: string;
34
+ assuranceData: unknown;
35
+ transaction: VgsCheckoutTarget;
36
+ mandate: {
37
+ declineThresholdAmount: string;
38
+ quantity: number;
39
+ effectiveUntil: string;
40
+ consumerPrompt: string;
41
+ };
42
+ }) => Promise<{
43
+ intentId: string;
44
+ status: string | null;
45
+ }>;
46
+ export type CreateCardMandateDeps = {
47
+ /** Mint the ceiling intent — wire serverCreateIntent(base, mintToken, …). */
48
+ createIntent: CreateIntentFn;
49
+ ledger: MandateLedger;
50
+ /** verify-web origin the intent is minted against (persisted for later draws). */
51
+ approvalBaseUrl: string;
52
+ /** Scoped mint token to persist for tap-free draws. */
53
+ mintToken?: string;
54
+ now?: () => Date;
55
+ };
56
+ export type CardMandateFacts = {
57
+ mandateId: string;
58
+ ceilingMinor: number;
59
+ remainingMinor: number;
60
+ currencyCode: string;
61
+ merchant: CardMandateMerchant;
62
+ expiresAt: string;
63
+ };
64
+ /**
65
+ * Create a card mandate: approve a spend CEILING once (the passkey assurance
66
+ * passed here was scoped to the ceiling + merchant), mint one VGS intent with
67
+ * that ceiling as its decline threshold, and record the cumulative-budget
68
+ * ledger entry. Integer minor units only.
69
+ */
70
+ export declare function createCardMandate(input: CreateCardMandateInput, deps: CreateCardMandateDeps): Promise<CardMandateFacts>;
71
+ export type FetchCryptogramFn = (input: {
72
+ tokenId: string;
73
+ intentId: string;
74
+ transaction: VgsCheckoutTarget;
75
+ }) => Promise<VgsPaymentCredential>;
76
+ export type DrawFromMandateInput = {
77
+ mandateId: string;
78
+ amountMinor: number;
79
+ /** The exact per-draw checkout target (merchant + amount + currency). */
80
+ transaction: VgsCheckoutTarget;
81
+ };
82
+ export type DrawFromMandateDeps = {
83
+ /** Draw a cryptogram against the mandate intent — wire serverFetchCryptogram. */
84
+ fetchCryptogram: FetchCryptogramFn;
85
+ ledger: MandateLedger;
86
+ /** Override the payable-credential gate (default validateCredential). */
87
+ validate?: (credential: VgsPaymentCredential) => void;
88
+ now?: () => Date;
89
+ };
90
+ export type DrawResult = {
91
+ payment: VgsPaymentCredential;
92
+ intentId: string;
93
+ remainingMinor: number;
94
+ };
95
+ /**
96
+ * Thrown ONLY for the post-reservation network/validation rejection class — the
97
+ * cryptogram fetch failed or returned a non-payable credential (the DRAW_REMEDY
98
+ * path). This is the sole draw failure that should DISABLE the mandate: it means
99
+ * the acquirer would not honor a ceiling-scoped assurance for a sub-amount draw.
100
+ *
101
+ * Pre-network failures — a fail-closed `reserve()` (concurrent over-budget race
102
+ * or expiry) or a `commit()` file-I/O error — throw a plain Error instead, so
103
+ * the caller (cli-engine) can rethrow them WITHOUT marking a HEALTHY mandate
104
+ * unhonored. Discriminating on this type is what keeps a transient race from
105
+ * permanently disabling a good mandate (michaelyang1 M1).
106
+ */
107
+ export declare class MandateDrawDeclinedError extends Error {
108
+ readonly cause?: unknown;
109
+ constructor(message: string, cause?: unknown);
110
+ }
111
+ /**
112
+ * Draw a cryptogram against an existing card mandate with NO fresh tap. The
113
+ * reservation is atomic and the budget is decremented ONLY after a payable
114
+ * cryptogram returns; any rejection releases the reservation so remaining is
115
+ * unchanged. Validates merchant + currency + amount against the mandate first.
116
+ */
117
+ export declare function drawFromMandate(input: DrawFromMandateInput, deps: DrawFromMandateDeps): Promise<DrawResult>;
@@ -0,0 +1,221 @@
1
+ // Card mandate (budget) layer — the split the north-star card flow needs:
2
+ //
3
+ // BUDGET = start_card_mandate: approve a CEILING once with the passkey. This
4
+ // mints ONE VGS intent whose decline threshold is the ceiling (not
5
+ // amount+10) and whose quantity permits many draws. The passkey
6
+ // assurance is scoped to the ceiling + merchant, not one charge.
7
+ // DRAW = pay_merchant (tap-free): pull a cryptogram against that intent for
8
+ // an in-budget purchase, WITHOUT a fresh tap, and decrement the
9
+ // cumulative budget in the owner-only ledger.
10
+ //
11
+ // This mirrors x402's start_session -> wallet_pay shape on the card rail.
12
+ //
13
+ // PROVEN (live probe 2026-07-23): VGS honors a ceiling-scoped assurance across
14
+ // multiple sub-amount cryptogram draws against one intent, AND enforces the
15
+ // decline threshold as a CUMULATIVE cap — draws summing to the ceiling are
16
+ // payable, the next draw over the cumulative ceiling is declined. So the total
17
+ // the network authorizes is the ceiling, not maxDraws × ceiling. drawFromMandate
18
+ // stays FAIL-CLEAN as defense-in-depth: the budget is decremented only after a
19
+ // payable cryptogram returns; a network rejection leaves the budget untouched.
20
+ // The caller (cli-engine) then marks the mandate unhonored so the NEXT
21
+ // pay_merchant skips it and takes a fresh per-purchase tap — the same call is
22
+ // not retried in-flight. Never fail-open.
23
+ import { decimalToMinor, minorToDecimal, validateCredential, } from '../vgs-live-instrument.js';
24
+ import { CARD_MANDATE_LEDGER_VERSION, remainingMinor, } from './mandate-ledger.js';
25
+ /**
26
+ * Default number of draws a ceiling intent may fulfil (VGS mandate quantity).
27
+ * This is a purchase-COUNT cap, NOT the exposure bound: VGS enforces the
28
+ * declineThreshold as a CUMULATIVE cap across draws (live-verified 2026-07-23),
29
+ * so the total the network will authorize is the ceiling REGARDLESS of how many
30
+ * draws it is spread across — maxDraws only limits the number of separate
31
+ * purchases, it does not multiply the exposure. Kept SMALL as a sensible default
32
+ * purchase count (the owner-side client ledger also caps cumulative spend at ONE
33
+ * ceiling). This is the single default; callers that don't set maxDraws inherit
34
+ * it (do not re-duplicate it upstream).
35
+ */
36
+ export const DEFAULT_MANDATE_MAX_DRAWS = 3;
37
+ function assertPositiveInteger(value, label) {
38
+ if (!Number.isSafeInteger(value) || value <= 0) {
39
+ throw new Error(`${label} must be a positive integer (minor units)`);
40
+ }
41
+ }
42
+ function merchantHost(url) {
43
+ try {
44
+ return new URL(url).hostname;
45
+ }
46
+ catch {
47
+ throw new Error(`mandate merchant URL is invalid: ${url}`);
48
+ }
49
+ }
50
+ /**
51
+ * Create a card mandate: approve a spend CEILING once (the passkey assurance
52
+ * passed here was scoped to the ceiling + merchant), mint one VGS intent with
53
+ * that ceiling as its decline threshold, and record the cumulative-budget
54
+ * ledger entry. Integer minor units only.
55
+ */
56
+ export async function createCardMandate(input, deps) {
57
+ assertPositiveInteger(input.ceilingMinor, 'mandate ceiling');
58
+ const currencyCode = String(input.currencyCode).toUpperCase();
59
+ if (!/^[A-Z]{3}$/.test(currencyCode)) {
60
+ throw new Error('mandate currency must be a 3-letter ISO 4217 code');
61
+ }
62
+ if (!input.tokenId?.trim())
63
+ throw new Error('mandate requires a tokenId');
64
+ const now = (deps.now ?? (() => new Date()))();
65
+ const expiryMs = Date.parse(input.expiresAt);
66
+ if (!Number.isFinite(expiryMs))
67
+ throw new Error('mandate expiresAt must be ISO 8601');
68
+ if (expiryMs <= now.getTime())
69
+ throw new Error('mandate expiresAt is already in the past');
70
+ const host = merchantHost(input.merchant.url);
71
+ const maxDraws = input.maxDraws ?? DEFAULT_MANDATE_MAX_DRAWS;
72
+ if (!Number.isSafeInteger(maxDraws) || maxDraws < 1) {
73
+ throw new Error('mandate maxDraws must be a positive integer');
74
+ }
75
+ // Pass the currency so a non-2-decimal currency is REFUSED here rather than
76
+ // silently rendered with a wrong wire amount (minorToDecimal assumes /100).
77
+ const ceilingDecimal = minorToDecimal(input.ceilingMinor, currencyCode);
78
+ // The passkey prompt must state the REAL grant. VGS enforces the
79
+ // declineThreshold as a CUMULATIVE cap across draws (live-verified 2026-07-23),
80
+ // so the total this single approval can authorize is the CEILING — not
81
+ // maxDraws × ceiling. maxDraws only caps how many separate purchases the intent
82
+ // may fulfil. The prompt therefore names the ceiling as the total and maxDraws
83
+ // as the purchase-count limit (H1: consent must match the true grant).
84
+ // The intent transaction is scoped to the CEILING itself: the decline
85
+ // threshold IS the ceiling (not the historical ceil(amount)+10), and quantity
86
+ // permits multiple draws.
87
+ const ceilingTarget = {
88
+ merchantName: input.merchant.name,
89
+ merchantUrl: input.merchant.url,
90
+ merchantCountryCode: input.merchant.countryCode.toUpperCase(),
91
+ transactionAmount: ceilingDecimal,
92
+ transactionCurrencyCode: currencyCode,
93
+ };
94
+ const { intentId } = await deps.createIntent({
95
+ tokenId: input.tokenId,
96
+ assuranceData: input.assuranceData,
97
+ transaction: ceilingTarget,
98
+ mandate: {
99
+ declineThresholdAmount: ceilingDecimal,
100
+ quantity: maxDraws,
101
+ effectiveUntil: new Date(expiryMs).toISOString(),
102
+ consumerPrompt: `Approve up to ${ceilingDecimal} ${currencyCode} total (across up to ` +
103
+ `${maxDraws} purchase${maxDraws === 1 ? '' : 's'}) at ${input.merchant.name} ` +
104
+ `for my Visa agent`,
105
+ },
106
+ });
107
+ if (!intentId?.trim())
108
+ throw new Error('mandate intent creation returned no intentId');
109
+ const record = {
110
+ version: CARD_MANDATE_LEDGER_VERSION,
111
+ mandateId: intentId,
112
+ tokenId: input.tokenId,
113
+ ceilingMinor: input.ceilingMinor,
114
+ spentMinor: 0,
115
+ reservations: [],
116
+ currencyCode,
117
+ merchantName: input.merchant.name,
118
+ merchantUrl: input.merchant.url,
119
+ merchantHost: host,
120
+ merchantCountryCode: input.merchant.countryCode.toUpperCase(),
121
+ approvalBaseUrl: deps.approvalBaseUrl,
122
+ ...(deps.mintToken ? { mintToken: deps.mintToken } : {}),
123
+ expiresAt: new Date(expiryMs).toISOString(),
124
+ createdAt: now.toISOString(),
125
+ draws: [],
126
+ ...(input.crossMerchant ? { crossMerchant: true } : {}),
127
+ };
128
+ await deps.ledger.create(record);
129
+ return {
130
+ mandateId: record.mandateId,
131
+ ceilingMinor: record.ceilingMinor,
132
+ remainingMinor: remainingMinor(record),
133
+ currencyCode,
134
+ merchant: input.merchant,
135
+ expiresAt: record.expiresAt,
136
+ };
137
+ }
138
+ const DRAW_REMEDY = 'the card-mandate draw did not complete — a ceiling-scoped assurance may not be honored ' +
139
+ 'for this sub-amount draw. The budget is untouched. If this was a transient network error ' +
140
+ '(gateway 5xx / try again), retry — the mandate stays active; a persistent decline disables ' +
141
+ 'the mandate and your next pay_merchant uses a fresh per-purchase tap';
142
+ /**
143
+ * Thrown ONLY for the post-reservation network/validation rejection class — the
144
+ * cryptogram fetch failed or returned a non-payable credential (the DRAW_REMEDY
145
+ * path). This is the sole draw failure that should DISABLE the mandate: it means
146
+ * the acquirer would not honor a ceiling-scoped assurance for a sub-amount draw.
147
+ *
148
+ * Pre-network failures — a fail-closed `reserve()` (concurrent over-budget race
149
+ * or expiry) or a `commit()` file-I/O error — throw a plain Error instead, so
150
+ * the caller (cli-engine) can rethrow them WITHOUT marking a HEALTHY mandate
151
+ * unhonored. Discriminating on this type is what keeps a transient race from
152
+ * permanently disabling a good mandate (michaelyang1 M1).
153
+ */
154
+ export class MandateDrawDeclinedError extends Error {
155
+ cause;
156
+ constructor(message, cause) {
157
+ super(message);
158
+ this.name = 'MandateDrawDeclinedError';
159
+ this.cause = cause;
160
+ }
161
+ }
162
+ /**
163
+ * Draw a cryptogram against an existing card mandate with NO fresh tap. The
164
+ * reservation is atomic and the budget is decremented ONLY after a payable
165
+ * cryptogram returns; any rejection releases the reservation so remaining is
166
+ * unchanged. Validates merchant + currency + amount against the mandate first.
167
+ */
168
+ export async function drawFromMandate(input, deps) {
169
+ assertPositiveInteger(input.amountMinor, 'draw amount');
170
+ const validate = deps.validate ?? validateCredential;
171
+ const now = (deps.now ?? (() => new Date()))();
172
+ const record = await deps.ledger.get(input.mandateId);
173
+ if (!record)
174
+ throw new Error(`no such mandate ${input.mandateId}`);
175
+ // The wire amount and the accounting amount MUST agree — a mismatch would
176
+ // draw one figure while reserving another. Fail before any reservation.
177
+ const wireMinor = decimalToMinor(input.transaction.transactionAmount);
178
+ if (wireMinor === null || wireMinor !== input.amountMinor) {
179
+ throw new Error(`draw amount ${input.amountMinor} disagrees with transaction amount ` +
180
+ `${JSON.stringify(input.transaction.transactionAmount)} (minor units)`);
181
+ }
182
+ // Currency must always match; merchant must match UNLESS this is a budget
183
+ // (crossMerchant) mandate the owner approved for any merchant. The network
184
+ // honors a ceiling-scoped cryptogram cross-merchant, so a budget mandate is
185
+ // deliberately not host-restricted — the ceiling + per-transaction limit still
186
+ // bound it.
187
+ const targetHost = merchantHost(input.transaction.merchantUrl);
188
+ if (!record.crossMerchant && targetHost !== record.merchantHost) {
189
+ throw new Error(`draw merchant ${targetHost} does not match mandate merchant ${record.merchantHost}`);
190
+ }
191
+ if (String(input.transaction.transactionCurrencyCode).toUpperCase() !==
192
+ record.currencyCode.toUpperCase()) {
193
+ throw new Error(`draw currency ${input.transaction.transactionCurrencyCode} does not match mandate ` +
194
+ `currency ${record.currencyCode}`);
195
+ }
196
+ // Atomic reserve — throws (fail-closed) if expired or over remaining budget.
197
+ const reservationId = await deps.ledger.reserve(input.mandateId, input.amountMinor, now);
198
+ let payment;
199
+ try {
200
+ payment = await deps.fetchCryptogram({
201
+ tokenId: record.tokenId,
202
+ intentId: record.mandateId,
203
+ transaction: input.transaction,
204
+ });
205
+ // A 2xx that is not actually a payable DAVV must NOT decrement the budget.
206
+ validate(payment);
207
+ }
208
+ catch (err) {
209
+ await deps.ledger.release(input.mandateId, reservationId, now);
210
+ // Typed so the caller disables the mandate ONLY for this post-reservation
211
+ // network/validation decline — never for a pre-network reserve()/commit()
212
+ // failure, which throws a plain Error above/below this try.
213
+ throw new MandateDrawDeclinedError(`${DRAW_REMEDY}: ${err.message}`, err);
214
+ }
215
+ const committed = await deps.ledger.commit(input.mandateId, reservationId, { intentId: record.mandateId }, now);
216
+ return {
217
+ payment,
218
+ intentId: record.mandateId,
219
+ remainingMinor: remainingMinor(committed),
220
+ };
221
+ }
@@ -0,0 +1,135 @@
1
+ export declare const CARD_MANDATE_LEDGER_VERSION: 1;
2
+ export type CardMandateReservation = {
3
+ reservationId: string;
4
+ amountMinor: number;
5
+ reservedAt: string;
6
+ };
7
+ export type CardMandateDraw = {
8
+ drawId: string;
9
+ reservationId: string;
10
+ amountMinor: number;
11
+ intentId: string;
12
+ status: 'committed' | 'released';
13
+ at: string;
14
+ };
15
+ export type CardMandateRecord = {
16
+ version: typeof CARD_MANDATE_LEDGER_VERSION;
17
+ /** == the VGS intentId the ceiling approval created. */
18
+ mandateId: string;
19
+ tokenId: string;
20
+ ceilingMinor: number;
21
+ /** Permanently committed (drawn) spend, integer minor units. */
22
+ spentMinor: number;
23
+ /** In-flight reservations (reserved but not yet committed/released). */
24
+ reservations: CardMandateReservation[];
25
+ currencyCode: string;
26
+ merchantName: string;
27
+ merchantUrl: string;
28
+ merchantHost: string;
29
+ merchantCountryCode: string;
30
+ /** verify-web origin the ceiling intent was minted against. */
31
+ approvalBaseUrl: string;
32
+ /**
33
+ * Scoped mint token released by the ceiling approval, reused to draw
34
+ * cryptograms tap-free. Owner-only (0600) like the rest of the file; whether
35
+ * it stays valid across multiple draws is part of the unproven spike.
36
+ */
37
+ mintToken?: string;
38
+ expiresAt: string;
39
+ createdAt: string;
40
+ draws: CardMandateDraw[];
41
+ /**
42
+ * Set when the network DECLINED a tap-free draw against this mandate (a
43
+ * ceiling-scoped assurance the acquirer would not honor for a sub-amount
44
+ * draw). Distinct from a reservation `status`: it disables the whole mandate.
45
+ * Once set, findCovering() SKIPS this mandate so the caller's next
46
+ * pay_merchant falls through to a fresh per-purchase tap instead of
47
+ * re-selecting the same failing mandate forever. ISO 8601.
48
+ */
49
+ unhonoredAt?: string;
50
+ /**
51
+ * Set when the delegated-draw register handshake FAILED at mandate-start (the
52
+ * server-side `card_mandate_spend` row was never created), so a later
53
+ * delegated (verdict-signed) draw would 404 `no_mandate`. Like `unhonoredAt`,
54
+ * findCovering() SKIPS a register-failed mandate so the next checkout falls
55
+ * through to a fresh per-purchase tap instead of surfacing a confusing
56
+ * `no_mandate`. Only set when a delegated binding was present and register was
57
+ * attempted; a bare bearer mandate (no binding) never carries it. ISO 8601.
58
+ */
59
+ registerFailedAt?: string;
60
+ /**
61
+ * A BUDGET mandate: spendable at ANY merchant, not scoped to `merchantHost`.
62
+ * The card network honors a ceiling-scoped cryptogram cross-merchant (the
63
+ * mandate's merchant is only an advisory hint on the intent), so this is a
64
+ * deliberate product mode — the owner approved a spend budget usable anywhere,
65
+ * bounded by the same ceiling + per-transaction limit. When set, findCovering
66
+ * matches this mandate for any merchant and drawFromMandate skips the
67
+ * merchant-match guard. Absent/false = the default merchant-scoped mandate.
68
+ */
69
+ crossMerchant?: boolean;
70
+ };
71
+ export type CardMandateLedgerFile = {
72
+ version: typeof CARD_MANDATE_LEDGER_VERSION;
73
+ mandates: CardMandateRecord[];
74
+ };
75
+ export type CoverQuery = {
76
+ merchantHost: string;
77
+ currencyCode: string;
78
+ amountMinor: number;
79
+ now?: Date;
80
+ };
81
+ /** Available headroom = ceiling - committed - reserved. Integer minor units. */
82
+ export declare function remainingMinor(record: CardMandateRecord): number;
83
+ /**
84
+ * The persisted card-mandate ledger. All mutating operations are serialized on
85
+ * an in-process promise chain so two concurrent draws cannot both observe the
86
+ * same remaining balance (the read-modify-write is atomic within the process).
87
+ */
88
+ export declare class MandateLedger {
89
+ private readonly path;
90
+ private chain;
91
+ constructor(path?: string);
92
+ /** Serialize a read-modify-write so concurrent draws can't race the file. */
93
+ private run;
94
+ private load;
95
+ private save;
96
+ /** Append a freshly-created mandate record. */
97
+ create(record: CardMandateRecord): Promise<CardMandateRecord>;
98
+ /** Read a single mandate (no lock — a snapshot copy). */
99
+ get(mandateId: string): Promise<CardMandateRecord | null>;
100
+ /**
101
+ * First ACTIVE mandate (not expired) whose merchant + currency match and whose
102
+ * remaining headroom covers amountMinor. Used by pay_merchant to decide the
103
+ * tap-free draw path vs a fresh per-purchase tap.
104
+ */
105
+ findCovering(query: CoverQuery): Promise<CardMandateRecord | null>;
106
+ /**
107
+ * Mark a mandate as unhonored — the network declined a ceiling-scoped draw
108
+ * against it, so it must never be selected again. Atomic, owner-only write on
109
+ * the same serialized chain as every other mutation. Idempotent: a second
110
+ * call keeps the first timestamp. Throws only if the mandate is unknown.
111
+ */
112
+ markUnhonored(mandateId: string, now?: Date): Promise<CardMandateRecord>;
113
+ /**
114
+ * Mark a mandate as register-failed — the delegated-draw register handshake
115
+ * did not create the server row at mandate-start, so it must never be selected
116
+ * for a (delegated) draw. Atomic, owner-only write on the same serialized chain
117
+ * as every other mutation. Idempotent: a second call keeps the first timestamp.
118
+ * Throws only if the mandate is unknown.
119
+ */
120
+ markRegisterFailed(mandateId: string, now?: Date): Promise<CardMandateRecord>;
121
+ /**
122
+ * Atomically reserve headroom for a draw. Fail-closed: refuses when the
123
+ * mandate is unknown, expired, or the amount exceeds remaining headroom. The
124
+ * reservation counts against availability immediately, closing the window in
125
+ * which two concurrent draws both see the same remaining balance.
126
+ */
127
+ reserve(mandateId: string, amountMinor: number, now?: Date): Promise<string>;
128
+ /** Commit a reservation to permanent spend and record the payable draw. */
129
+ commit(mandateId: string, reservationId: string, meta: {
130
+ intentId: string;
131
+ }, now?: Date): Promise<CardMandateRecord>;
132
+ /** Release a reservation back to availability (draw failed / not payable). */
133
+ release(mandateId: string, reservationId: string, now?: Date): Promise<CardMandateRecord>;
134
+ }
135
+ export declare function defaultLedgerPath(): string;