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

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.
@@ -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;
@@ -119,16 +119,46 @@ async function resolveCardInstrument(input) {
119
119
  '`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait` to ' +
120
120
  'attach one, or use a pre-provisioned VIC runtime.', readError instanceof Error ? { cause: readError } : undefined);
121
121
  }
122
+ function payAttemptFingerprint(input) {
123
+ return JSON.stringify([
124
+ input.url,
125
+ input.amount,
126
+ input.currency,
127
+ input.credentialPath,
128
+ input.cardTokenId ?? null,
129
+ input.agentJkt ?? null,
130
+ input.contact,
131
+ input.approvalBaseUrl,
132
+ input.merchantName ?? null,
133
+ input.merchantCountryCode ?? null,
134
+ input.submit,
135
+ ]);
136
+ }
137
+ function failedPay(detail) {
138
+ return {
139
+ outcome: 'failed',
140
+ confirmationRef: null,
141
+ receiptPath: null,
142
+ detail,
143
+ vicConfirmation: null,
144
+ source: null,
145
+ remainingMinor: null,
146
+ credentialIssued: false,
147
+ credentialDisclosed: false,
148
+ };
149
+ }
122
150
  const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
123
151
  // Must match the prepared-checkout store TTL so a session and its store entry
124
152
  // expire together — an abandoned review can't leak the browser + state.
125
153
  const PREPARED_TTL_MS = 5 * 60 * 1000;
126
154
  const defaultStore = new InMemoryPreparedCheckoutStore({ ttlMs: PREPARED_TTL_MS });
127
155
  const defaultSessions = new Map();
156
+ const defaultPayAttempts = new Map();
128
157
  const defaultLedger = new MandateLedger();
129
158
  export function createCliCheckoutEngine(deps = {}) {
130
159
  const store = deps.store ?? defaultStore;
131
160
  const sessions = deps.sessions ?? defaultSessions;
161
+ const payAttempts = deps.payAttempts ?? defaultPayAttempts;
132
162
  const ttlMs = deps.ttlMs ?? PREPARED_TTL_MS;
133
163
  const launchBrowser = deps.launchBrowser ?? (() => launchCheckoutBrowser());
134
164
  const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
@@ -508,12 +538,20 @@ export function createCliCheckoutEngine(deps = {}) {
508
538
  */
509
539
  async releaseReview(reviewId) {
510
540
  await closeSession(reviewId);
541
+ payAttempts.delete(reviewId);
511
542
  },
512
543
  async pay(input) {
513
544
  const noCredentialFacts = {
514
545
  credentialIssued: false,
515
546
  credentialDisclosed: false,
516
547
  };
548
+ const fingerprint = payAttemptFingerprint(input);
549
+ const priorAttempt = payAttempts.get(input.reviewId);
550
+ if (priorAttempt) {
551
+ return priorAttempt.fingerprint === fingerprint
552
+ ? priorAttempt.promise
553
+ : failedPay(`review ${input.reviewId} is already executing with different payment facts — wait for that exact attempt and inspect its receipt`);
554
+ }
517
555
  const session = sessions.get(input.reviewId);
518
556
  // One signal for the whole pay lifecycle. `cardTokenId` is populated only
519
557
  // by the resolved v4 card-grant authority; legacy credential-file calls
@@ -629,6 +667,23 @@ export function createCliCheckoutEngine(deps = {}) {
629
667
  ...noCredentialFacts,
630
668
  };
631
669
  }
670
+ let resolveAttempt;
671
+ let rejectAttempt;
672
+ const sharedResult = new Promise((resolve, reject) => {
673
+ resolveAttempt = resolve;
674
+ rejectAttempt = reject;
675
+ });
676
+ // The first caller still receives the direct execution result below; this
677
+ // retained promise exists for overlapping and bounded late duplicates.
678
+ // Mark its rejection handled even when there is no duplicate consumer.
679
+ void sharedResult.catch(() => undefined);
680
+ payAttempts.set(input.reviewId, { fingerprint, promise: sharedResult });
681
+ let completedAttempt;
682
+ const finishAttempt = (result) => {
683
+ completedAttempt = result;
684
+ resolveAttempt(result);
685
+ return result;
686
+ };
632
687
  clearTimeout(session.cleanupTimer);
633
688
  try {
634
689
  // NO instrument read here. A tap-free mandate draw spends `covering
@@ -672,7 +727,7 @@ export function createCliCheckoutEngine(deps = {}) {
672
727
  const verdictSeam = cardDrawVerdict;
673
728
  const capability = verdictSeam?.loadCapability(covering.agentJkt) ?? null;
674
729
  if (!capability || !verdictSeam) {
675
- return {
730
+ return finishAttempt({
676
731
  outcome: 'failed',
677
732
  confirmationRef: null,
678
733
  receiptPath: null,
@@ -682,10 +737,10 @@ export function createCliCheckoutEngine(deps = {}) {
682
737
  source,
683
738
  remainingMinor: null,
684
739
  ...noCredentialFacts,
685
- };
740
+ });
686
741
  }
687
742
  if (covering.agentJkt && capability.agentJkt !== covering.agentJkt) {
688
- return {
743
+ return finishAttempt({
689
744
  outcome: 'failed',
690
745
  confirmationRef: null,
691
746
  receiptPath: null,
@@ -695,7 +750,7 @@ export function createCliCheckoutEngine(deps = {}) {
695
750
  source,
696
751
  remainingMinor: null,
697
752
  ...noCredentialFacts,
698
- };
753
+ });
699
754
  }
700
755
  // VgsLiveInstrument mints against an EXISTING intent (the mandate) with
701
756
  // no fresh assurance — exactly the draw semantics. The fetchCredential
@@ -803,7 +858,7 @@ export function createCliCheckoutEngine(deps = {}) {
803
858
  // registration and non-budget mint tokens at verification; this
804
859
  // client refusal exists to give the honest remedy up front instead
805
860
  // of a mid-flow server error.
806
- return {
861
+ return finishAttempt({
807
862
  outcome: 'failed',
808
863
  confirmationRef: null,
809
864
  receiptPath: null,
@@ -821,7 +876,7 @@ export function createCliCheckoutEngine(deps = {}) {
821
876
  source: null,
822
877
  remainingMinor: null,
823
878
  ...noCredentialFacts,
824
- };
879
+ });
825
880
  }
826
881
  const mode = input.submit ? 'submit' : 'dry-run';
827
882
  const result = await submitApprovedCheckout(input.reviewId, {
@@ -875,7 +930,7 @@ export function createCliCheckoutEngine(deps = {}) {
875
930
  }));
876
931
  if (report.written)
877
932
  receiptPath = report.path;
878
- return {
933
+ return finishAttempt({
879
934
  outcome: result.outcome,
880
935
  confirmationRef: result.confirmationRef ?? null,
881
936
  receiptPath,
@@ -887,10 +942,29 @@ export function createCliCheckoutEngine(deps = {}) {
887
942
  credentialIssued: result.credentialLifecycle !== 'not-requested',
888
943
  credentialDisclosed: result.credentialLifecycle === 'partially-exposed' ||
889
944
  result.credentialLifecycle === 'fully-filled',
890
- };
945
+ });
946
+ }
947
+ catch (error) {
948
+ rejectAttempt(error);
949
+ throw error;
891
950
  }
892
951
  finally {
893
952
  await closeSession(input.reviewId);
953
+ if (!completedAttempt ||
954
+ completedAttempt.outcome === 'failed' ||
955
+ completedAttempt.outcome === 'declined') {
956
+ if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
957
+ payAttempts.delete(input.reviewId);
958
+ }
959
+ }
960
+ else {
961
+ const expiry = setTimeout(() => {
962
+ if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
963
+ payAttempts.delete(input.reviewId);
964
+ }
965
+ }, ttlMs);
966
+ expiry.unref?.();
967
+ }
894
968
  }
895
969
  },
896
970
  };