@visa/cli 4.1.0-rc.152 → 4.1.0-rc.154

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 CHANGED
@@ -180,6 +180,7 @@ is required so the CLI never guesses which agent can spend.
180
180
  | `wallet_directory_pay` | Directory find + pay in one call, same policy path |
181
181
  | `wallet_history` | Journaled payment receipts |
182
182
  | `wallet_fund` | Funding instructions for the wallet address |
183
+ | `checkout_merchants` | Read-only: merchants where your card has completed real checkouts, from this device's receipts |
183
184
  | `get_status` | Account and wallet state summary |
184
185
  | `feedback` | Submit feedback on a tool result |
185
186
  | `reset` | Clear local auth state and credentials |
@@ -1,5 +1,5 @@
1
1
  import { type Browser } from 'playwright-core';
2
- import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type PreparedCheckoutSessionStore } from './executor.js';
2
+ import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type CheckoutOutcome, type PreparedCheckoutSessionStore } from './executor.js';
3
3
  import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
4
4
  import { type VgsCheckoutTarget } from './vgs-live-instrument.js';
5
5
  import { serverFetchCryptogram, serverPostConfirmation } from './vgs-gateway/server-mint-client.js';
@@ -58,7 +58,7 @@ export type CliPayInput = CliReviewInput & {
58
58
  onApprovalUrl?: (url: string) => void;
59
59
  };
60
60
  export type CliReceiptFacts = {
61
- outcome: string;
61
+ outcome: CheckoutOutcome;
62
62
  confirmationRef: string | null;
63
63
  receiptPath: string | null;
64
64
  detail: string | null;
@@ -84,6 +84,10 @@ export type CliReceiptFacts = {
84
84
  /** True only when at least one payment field was successfully filled. */
85
85
  credentialDisclosed: boolean;
86
86
  };
87
+ type PayAttempt = {
88
+ fingerprint: string;
89
+ promise: Promise<CliReceiptFacts>;
90
+ };
87
91
  export type CliStartMandateInput = {
88
92
  /** Optional local card-capability selector (legacy name or exact request-key JKT). */
89
93
  agentRef?: string;
@@ -222,6 +226,8 @@ export type CliEngineDeps = {
222
226
  writeReceipt?: typeof realWriteReceipt;
223
227
  store?: PreparedCheckoutSessionStore;
224
228
  sessions?: Map<string, Session>;
229
+ /** Exact-review singleflight registry; tests inject a fresh map for isolation. */
230
+ payAttempts?: Map<string, PayAttempt>;
225
231
  ttlMs?: number;
226
232
  /** Owner-only card-mandate ledger — defaults to the ~/.visa-mcp singleton. */
227
233
  ledger?: MandateLedger;
@@ -10,10 +10,9 @@
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';
16
14
  import { launchCheckoutBrowser } from './browser-launch.js';
15
+ import { RECEIPT_DIR } from './receipt-dir.js';
17
16
  import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
18
17
  import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval, } from './hosted-approval.js';
19
18
  import { VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-instrument.js';
@@ -119,16 +118,45 @@ async function resolveCardInstrument(input) {
119
118
  '`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait` to ' +
120
119
  'attach one, or use a pre-provisioned VIC runtime.', readError instanceof Error ? { cause: readError } : undefined);
121
120
  }
122
- const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
121
+ function payAttemptFingerprint(input) {
122
+ return JSON.stringify([
123
+ input.url,
124
+ input.amount,
125
+ input.currency,
126
+ input.credentialPath,
127
+ input.cardTokenId ?? null,
128
+ input.agentJkt ?? null,
129
+ input.contact,
130
+ input.approvalBaseUrl,
131
+ input.merchantName ?? null,
132
+ input.merchantCountryCode ?? null,
133
+ input.submit,
134
+ ]);
135
+ }
136
+ function failedPay(detail) {
137
+ return {
138
+ outcome: 'failed',
139
+ confirmationRef: null,
140
+ receiptPath: null,
141
+ detail,
142
+ vicConfirmation: null,
143
+ source: null,
144
+ remainingMinor: null,
145
+ credentialIssued: false,
146
+ credentialDisclosed: false,
147
+ };
148
+ }
123
149
  // Must match the prepared-checkout store TTL so a session and its store entry
124
150
  // expire together — an abandoned review can't leak the browser + state.
125
151
  const PREPARED_TTL_MS = 5 * 60 * 1000;
126
152
  const defaultStore = new InMemoryPreparedCheckoutStore({ ttlMs: PREPARED_TTL_MS });
127
153
  const defaultSessions = new Map();
154
+ const defaultPayAttempts = new Map();
128
155
  const defaultLedger = new MandateLedger();
129
156
  export function createCliCheckoutEngine(deps = {}) {
130
157
  const store = deps.store ?? defaultStore;
131
158
  const sessions = deps.sessions ?? defaultSessions;
159
+ const payAttempts = deps.payAttempts ?? defaultPayAttempts;
132
160
  const ttlMs = deps.ttlMs ?? PREPARED_TTL_MS;
133
161
  const launchBrowser = deps.launchBrowser ?? (() => launchCheckoutBrowser());
134
162
  const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
@@ -508,12 +536,20 @@ export function createCliCheckoutEngine(deps = {}) {
508
536
  */
509
537
  async releaseReview(reviewId) {
510
538
  await closeSession(reviewId);
539
+ payAttempts.delete(reviewId);
511
540
  },
512
541
  async pay(input) {
513
542
  const noCredentialFacts = {
514
543
  credentialIssued: false,
515
544
  credentialDisclosed: false,
516
545
  };
546
+ const fingerprint = payAttemptFingerprint(input);
547
+ const priorAttempt = payAttempts.get(input.reviewId);
548
+ if (priorAttempt) {
549
+ return priorAttempt.fingerprint === fingerprint
550
+ ? priorAttempt.promise
551
+ : failedPay(`review ${input.reviewId} is already executing with different payment facts — wait for that exact attempt and inspect its receipt`);
552
+ }
517
553
  const session = sessions.get(input.reviewId);
518
554
  // One signal for the whole pay lifecycle. `cardTokenId` is populated only
519
555
  // by the resolved v4 card-grant authority; legacy credential-file calls
@@ -629,6 +665,23 @@ export function createCliCheckoutEngine(deps = {}) {
629
665
  ...noCredentialFacts,
630
666
  };
631
667
  }
668
+ let resolveAttempt;
669
+ let rejectAttempt;
670
+ const sharedResult = new Promise((resolve, reject) => {
671
+ resolveAttempt = resolve;
672
+ rejectAttempt = reject;
673
+ });
674
+ // The first caller still receives the direct execution result below; this
675
+ // retained promise exists for overlapping and bounded late duplicates.
676
+ // Mark its rejection handled even when there is no duplicate consumer.
677
+ void sharedResult.catch(() => undefined);
678
+ payAttempts.set(input.reviewId, { fingerprint, promise: sharedResult });
679
+ let completedAttempt;
680
+ const finishAttempt = (result) => {
681
+ completedAttempt = result;
682
+ resolveAttempt(result);
683
+ return result;
684
+ };
632
685
  clearTimeout(session.cleanupTimer);
633
686
  try {
634
687
  // NO instrument read here. A tap-free mandate draw spends `covering
@@ -672,7 +725,7 @@ export function createCliCheckoutEngine(deps = {}) {
672
725
  const verdictSeam = cardDrawVerdict;
673
726
  const capability = verdictSeam?.loadCapability(covering.agentJkt) ?? null;
674
727
  if (!capability || !verdictSeam) {
675
- return {
728
+ return finishAttempt({
676
729
  outcome: 'failed',
677
730
  confirmationRef: null,
678
731
  receiptPath: null,
@@ -682,10 +735,10 @@ export function createCliCheckoutEngine(deps = {}) {
682
735
  source,
683
736
  remainingMinor: null,
684
737
  ...noCredentialFacts,
685
- };
738
+ });
686
739
  }
687
740
  if (covering.agentJkt && capability.agentJkt !== covering.agentJkt) {
688
- return {
741
+ return finishAttempt({
689
742
  outcome: 'failed',
690
743
  confirmationRef: null,
691
744
  receiptPath: null,
@@ -695,7 +748,7 @@ export function createCliCheckoutEngine(deps = {}) {
695
748
  source,
696
749
  remainingMinor: null,
697
750
  ...noCredentialFacts,
698
- };
751
+ });
699
752
  }
700
753
  // VgsLiveInstrument mints against an EXISTING intent (the mandate) with
701
754
  // no fresh assurance — exactly the draw semantics. The fetchCredential
@@ -803,7 +856,7 @@ export function createCliCheckoutEngine(deps = {}) {
803
856
  // registration and non-budget mint tokens at verification; this
804
857
  // client refusal exists to give the honest remedy up front instead
805
858
  // of a mid-flow server error.
806
- return {
859
+ return finishAttempt({
807
860
  outcome: 'failed',
808
861
  confirmationRef: null,
809
862
  receiptPath: null,
@@ -821,7 +874,7 @@ export function createCliCheckoutEngine(deps = {}) {
821
874
  source: null,
822
875
  remainingMinor: null,
823
876
  ...noCredentialFacts,
824
- };
877
+ });
825
878
  }
826
879
  const mode = input.submit ? 'submit' : 'dry-run';
827
880
  const result = await submitApprovedCheckout(input.reviewId, {
@@ -875,7 +928,7 @@ export function createCliCheckoutEngine(deps = {}) {
875
928
  }));
876
929
  if (report.written)
877
930
  receiptPath = report.path;
878
- return {
931
+ return finishAttempt({
879
932
  outcome: result.outcome,
880
933
  confirmationRef: result.confirmationRef ?? null,
881
934
  receiptPath,
@@ -887,10 +940,29 @@ export function createCliCheckoutEngine(deps = {}) {
887
940
  credentialIssued: result.credentialLifecycle !== 'not-requested',
888
941
  credentialDisclosed: result.credentialLifecycle === 'partially-exposed' ||
889
942
  result.credentialLifecycle === 'fully-filled',
890
- };
943
+ });
944
+ }
945
+ catch (error) {
946
+ rejectAttempt(error);
947
+ throw error;
891
948
  }
892
949
  finally {
893
950
  await closeSession(input.reviewId);
951
+ if (!completedAttempt ||
952
+ completedAttempt.outcome === 'failed' ||
953
+ completedAttempt.outcome === 'declined') {
954
+ if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
955
+ payAttempts.delete(input.reviewId);
956
+ }
957
+ }
958
+ else {
959
+ const expiry = setTimeout(() => {
960
+ if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
961
+ payAttempts.delete(input.reviewId);
962
+ }
963
+ }, ttlMs);
964
+ expiry.unref?.();
965
+ }
894
966
  }
895
967
  },
896
968
  };
@@ -0,0 +1,31 @@
1
+ /** One confirmed charge, in the receipt's own terms. */
2
+ export type ConfirmedCharge = {
3
+ recordedAt: string;
4
+ amount: string;
5
+ currency: string;
6
+ /** Receipt file basename, so an operator can open the evidence log. */
7
+ receiptFile: string;
8
+ };
9
+ export type ConfirmedMerchant = {
10
+ /** The checkout page the card went through. */
11
+ url: string;
12
+ host: string;
13
+ /**
14
+ * Curated human identity (known-merchants.ts), present only when someone has
15
+ * identified who is behind this checkout URL. Hosted payment links carry an
16
+ * opaque path on the PSP's host, so without this a row names nobody.
17
+ */
18
+ name?: string;
19
+ category?: string;
20
+ website?: string;
21
+ confirmedCount: number;
22
+ lastConfirmedAt: string;
23
+ charges: ConfirmedCharge[];
24
+ };
25
+ /**
26
+ * Merchants this device has completed a real card checkout at, newest first.
27
+ *
28
+ * Never throws: a missing directory (nothing has ever been checked out here)
29
+ * and an unreadable one both read as an empty registry.
30
+ */
31
+ export declare function readConfirmedMerchants(receiptDir?: string): Promise<ConfirmedMerchant[]>;
@@ -0,0 +1,136 @@
1
+ // "Where can I use my card?", the merchant registry derived from this
2
+ // device's checkout receipts.
3
+ //
4
+ // Receipts (receipt.ts) are the only local record that a real merchant
5
+ // checkout completed on this box. This module reads them back and answers one
6
+ // question: which checkout pages has this card actually gone through?
7
+ //
8
+ // A merchant only counts when BOTH halves agree. The engine's own outcome must
9
+ // be 'confirmed' (the merchant showed a definitive success) AND the VIC
10
+ // confirmation must have posted APPROVED (the network side of the same
11
+ // purchase). Either half alone is a claim, not a completion: a 'confirmed' with
12
+ // no posted confirmation is exactly the reconciliation case receipt.ts flags,
13
+ // and a posted DECLINED is a completed report of a failure.
14
+ //
15
+ // Reading is best-effort by construction. The receipts directory is an operator
16
+ // artifact that anything on the box can touch, so an unreadable or malformed
17
+ // file is skipped rather than failing the whole read. One bad file must not
18
+ // hide every merchant behind it.
19
+ import { readdir, readFile } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ import { KNOWN_MERCHANT_IDENTITIES } from './known-merchants.js';
22
+ import { RECEIPT_DIR } from './receipt-dir.js';
23
+ /**
24
+ * The URL a receipt's charge happened at.
25
+ *
26
+ * The receipt's top-level merchant block records the HOST only, on purpose: a
27
+ * full checkout URL can carry cart/session identifiers, so it stays out of the
28
+ * shareable summary. The evidence log still holds it (the executor's first
29
+ * 'navigation' step is the page the run opened), so recover it from there and
30
+ * fall back to the host when the evidence is absent or shaped differently.
31
+ */
32
+ function checkoutUrlOf(receipt) {
33
+ const navigation = receipt.evidence.steps.find((step) => step?.type === 'navigation');
34
+ const data = navigation?.data;
35
+ const url = typeof data === 'object' && data !== null ? data.url : undefined;
36
+ return typeof url === 'string' && url.length > 0 ? url : `https://${receipt.merchant.host}/`;
37
+ }
38
+ function isConfirmedCompletion(receipt) {
39
+ return (receipt.outcome === 'confirmed' &&
40
+ receipt.vicConfirmation?.posted === true &&
41
+ receipt.vicConfirmation.transactionStatus === 'APPROVED');
42
+ }
43
+ /**
44
+ * Parse one receipt file, or null when it is not a v1 receipt this module can
45
+ * read. Deliberately permissive about everything the grouping does not touch:
46
+ * the file was written by an older or newer engine and only has to carry the
47
+ * fields read below.
48
+ */
49
+ function parseReceipt(json) {
50
+ let parsed;
51
+ try {
52
+ parsed = JSON.parse(json);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ if (typeof parsed !== 'object' || parsed === null)
58
+ return null;
59
+ const receipt = parsed;
60
+ if (receipt.schema !== 'checkout-agent-receipt/v1')
61
+ return null;
62
+ if (typeof receipt.recordedAt !== 'string')
63
+ return null;
64
+ if (typeof receipt.merchant?.host !== 'string')
65
+ return null;
66
+ if (typeof receipt.transaction?.amount !== 'string')
67
+ return null;
68
+ if (typeof receipt.transaction?.currency !== 'string')
69
+ return null;
70
+ if (!Array.isArray(receipt.evidence?.steps))
71
+ return null;
72
+ return receipt;
73
+ }
74
+ /**
75
+ * Merchants this device has completed a real card checkout at, newest first.
76
+ *
77
+ * Never throws: a missing directory (nothing has ever been checked out here)
78
+ * and an unreadable one both read as an empty registry.
79
+ */
80
+ export async function readConfirmedMerchants(receiptDir = RECEIPT_DIR) {
81
+ let names;
82
+ try {
83
+ names = await readdir(receiptDir);
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ const byUrl = new Map();
89
+ for (const name of names) {
90
+ if (!name.endsWith('.json'))
91
+ continue;
92
+ let raw;
93
+ try {
94
+ raw = await readFile(join(receiptDir, name), 'utf8');
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ const receipt = parseReceipt(raw);
100
+ if (!receipt || !isConfirmedCompletion(receipt))
101
+ continue;
102
+ const url = checkoutUrlOf(receipt);
103
+ const charge = {
104
+ recordedAt: receipt.recordedAt,
105
+ amount: receipt.transaction.amount,
106
+ currency: receipt.transaction.currency,
107
+ receiptFile: name,
108
+ };
109
+ const existing = byUrl.get(url);
110
+ if (existing) {
111
+ existing.confirmedCount += 1;
112
+ existing.charges.push(charge);
113
+ if (charge.recordedAt > existing.lastConfirmedAt) {
114
+ existing.lastConfirmedAt = charge.recordedAt;
115
+ }
116
+ continue;
117
+ }
118
+ const identity = KNOWN_MERCHANT_IDENTITIES[url];
119
+ byUrl.set(url, {
120
+ url,
121
+ host: receipt.merchant.host,
122
+ // Conditional spread on purpose: an unidentified merchant carries no
123
+ // identity keys at all, rather than keys holding undefined.
124
+ ...(identity ?? {}),
125
+ confirmedCount: 1,
126
+ lastConfirmedAt: charge.recordedAt,
127
+ charges: [charge],
128
+ });
129
+ }
130
+ const merchants = [...byUrl.values()];
131
+ for (const merchant of merchants) {
132
+ merchant.charges.sort((left, right) => (left.recordedAt < right.recordedAt ? 1 : -1));
133
+ }
134
+ merchants.sort((left, right) => (left.lastConfirmedAt < right.lastConfirmedAt ? 1 : -1));
135
+ return merchants;
136
+ }
@@ -1,6 +1,9 @@
1
1
  export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, } from './cli-engine.js';
2
2
  export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
3
3
  export type { CheckoutResult, CheckoutReview, CheckoutOutcome } from './executor.js';
4
+ export { readConfirmedMerchants, type ConfirmedMerchant, type ConfirmedCharge, } from './confirmed-merchants.js';
5
+ export { KNOWN_MERCHANT_IDENTITIES, type MerchantIdentity } from './known-merchants.js';
6
+ export { RECEIPT_DIR } from './receipt-dir.js';
4
7
  export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
5
8
  export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, type CreateCardMandateInput, type CreateCardMandateDeps, type CardMandateFacts, type DrawFromMandateInput, type DrawFromMandateDeps, type DrawResult, type CardMandateMerchant, } from './mandate/card-mandate.js';
6
9
  export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, type CardMandateRecord, type CardMandateLedgerFile, type CardMandateDraw, type CardMandateReservation, } from './mandate/mandate-ledger.js';
@@ -3,6 +3,9 @@
3
3
  // primitives are re-exported for direct/embedded use.
4
4
  export { createCliCheckoutEngine, } from './cli-engine.js';
5
5
  export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
6
+ export { readConfirmedMerchants, } from './confirmed-merchants.js';
7
+ export { KNOWN_MERCHANT_IDENTITIES } from './known-merchants.js';
8
+ export { RECEIPT_DIR } from './receipt-dir.js';
6
9
  export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
7
10
  export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, } from './mandate/card-mandate.js';
8
11
  export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, } from './mandate/mandate-ledger.js';
@@ -0,0 +1,10 @@
1
+ export type MerchantIdentity = {
2
+ /** Human-recognizable merchant name. */
3
+ name: string;
4
+ /** What the merchant is, e.g. "charity (animal rescue)". */
5
+ category: string;
6
+ /** The merchant's own site, when known — not the checkout URL. */
7
+ website?: string;
8
+ };
9
+ /** Checkout URL → who is actually behind it. Confirmed live 2026-07/2026-08. */
10
+ export declare const KNOWN_MERCHANT_IDENTITIES: Readonly<Record<string, MerchantIdentity>>;
@@ -0,0 +1,38 @@
1
+ // Curated identities for checkout URLs the registry cannot name on its own.
2
+ //
3
+ // Hosted payment links are anonymous by construction: the page host is the
4
+ // PSP's (donate.stripe.com, buy.stripe.com), the path is an opaque token, and
5
+ // the receipt records nothing else. So a registry row for a completed payment
6
+ // link answers "where can I use my card?" with a string no human recognizes.
7
+ // This map carries the once-per-link human identification, keyed by the exact
8
+ // checkout URL the receipts group on.
9
+ //
10
+ // Curation rule: an entry is added only after a checkout at that URL completed
11
+ // for real (engine outcome 'confirmed' + VIC APPROVED — the same admission rule
12
+ // confirmed-merchants.ts enforces) and the merchant behind the link was
13
+ // identified by a person. The map never creates registry rows; it only names
14
+ // rows the device's own receipts already earned. A URL missing here still
15
+ // lists — it just shows as its host.
16
+ /** Checkout URL → who is actually behind it. Confirmed live 2026-07/2026-08. */
17
+ export const KNOWN_MERCHANT_IDENTITIES = {
18
+ 'https://donate.stripe.com/3cscPcaqY20H8ta4gh': {
19
+ name: 'NC Seaside Animal Rescue',
20
+ category: 'charity (animal rescue, NC nonprofit)',
21
+ website: 'https://www.ncseasideanimalrescue.org/make-a-donation',
22
+ },
23
+ 'https://donate.stripe.com/14k3ei9TYgwFclq145': {
24
+ name: 'FreeCAD',
25
+ category: 'open-source project donation (CAD software)',
26
+ website: 'https://www.freecad.org',
27
+ },
28
+ 'https://buy.stripe.com/dR6eWGaK41MX2YgaEF': {
29
+ name: 'scribepod',
30
+ category: 'indie creator tip jar (AI podcast project)',
31
+ website: 'https://github.com/yacineMTB/scribepod',
32
+ },
33
+ 'https://donate.stripe.com/7sIcNj9Gs4WO6d29AA': {
34
+ name: 'n0bleKing GRAFFITI',
35
+ category: 'indie creator tip jar (pixel art and fonts)',
36
+ website: 'https://nobleking-graffiti.itch.io',
37
+ },
38
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Where this device's checkout receipts live. Owned by its own module so the
3
+ * writer (cli-engine.ts) and the reader (confirmed-merchants.ts) can never
4
+ * drift onto two paths.
5
+ */
6
+ export declare const RECEIPT_DIR: string;
@@ -0,0 +1,8 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * Where this device's checkout receipts live. Owned by its own module so the
5
+ * writer (cli-engine.ts) and the reader (confirmed-merchants.ts) can never
6
+ * drift onto two paths.
7
+ */
8
+ export const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');