@visa/cli 4.1.0-rc.108 → 4.1.0-rc.109

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.
@@ -177,6 +177,12 @@ export interface CardMandateRegisterSeam {
177
177
  }) => Promise<{
178
178
  ok: boolean;
179
179
  reason?: string;
180
+ /**
181
+ * The ceiling the server committed — `min(requested, the owner's live card
182
+ * grant cap)`. Omitted by an older auth that does not report it, in which
183
+ * case the requested ceiling stands.
184
+ */
185
+ approvedCeilingMinor?: number;
180
186
  }>;
181
187
  }
182
188
  export type CliEngineDeps = {
@@ -16,7 +16,7 @@ import { readFile } from 'node:fs/promises';
16
16
  import { launchCheckoutBrowser } from './browser-launch.js';
17
17
  import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
18
18
  import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval, } from './hosted-approval.js';
19
- import { VgsAssuranceInstrument, VgsLiveInstrument, decimalToMinor, } from './vgs-live-instrument.js';
19
+ import { VgsAssuranceInstrument, VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-instrument.js';
20
20
  import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
21
21
  import { createCardMandate, DEFAULT_MANDATE_MAX_DRAWS, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
22
22
  import { MandateLedger } from './mandate/mandate-ledger.js';
@@ -140,6 +140,21 @@ export function createCliCheckoutEngine(deps = {}) {
140
140
  transactionCurrencyCode: input.currency,
141
141
  };
142
142
  }
143
+ /**
144
+ * Restate the facts at the ceiling the SERVER approved, when it lowered the
145
+ * one that was requested. Nothing has been spent or reserved on a mandate this
146
+ * new, so its remaining headroom IS its ceiling — see `createCardMandate`,
147
+ * which records `spentMinor: 0` with no reservations.
148
+ *
149
+ * Only ever lowers, mirroring `applyApprovedCeiling`: the server clamps
150
+ * downward, so a higher number means an unexpected response and is ignored
151
+ * rather than reported as headroom no human approved.
152
+ */
153
+ function approvedCeilingFacts(facts, approvedCeilingMinor) {
154
+ if (approvedCeilingMinor === undefined || approvedCeilingMinor >= facts.ceilingMinor)
155
+ return {};
156
+ return { ceilingMinor: approvedCeilingMinor, remainingMinor: approvedCeilingMinor };
157
+ }
143
158
  // Seed the one server-authoritative cumulative store keyed by the VGS intent
144
159
  // ID (#5942). On failure the local record is marked register-failed so
145
160
  // findCovering() SKIPS it — the mandate exists but is never drawn tap-free —
@@ -162,8 +177,50 @@ export function createCliCheckoutEngine(deps = {}) {
162
177
  ok: false,
163
178
  reason: err instanceof Error ? err.message : String(err),
164
179
  }));
165
- if (reg.ok)
166
- return { registerFailed: false };
180
+ if (reg.ok) {
181
+ // ONE SPENDING LIMIT: auth just clamped the requested ceiling to the
182
+ // owner's live grant cap and told us what it committed. Adopt it, so the
183
+ // local record — which findCovering() selects on and `mandate list`
184
+ // prints — states the budget the owner actually approved. Best-effort:
185
+ // the mandate is registered and drawable either way, and auth's verdict
186
+ // remains the enforcing cap, so a failed local write must not abort the
187
+ // ceremony. It leaves the record overstating headroom, which is exactly
188
+ // the pre-existing behaviour.
189
+ //
190
+ // If that write fails the returned facts still carry the approved figure —
191
+ // telling the human the truth beats echoing a ceiling their grant refused,
192
+ // and the ledger is left exactly as overstated as it was before this
193
+ // existed. But the two then disagree, so say so out loud rather than let
194
+ // `mandate list` quietly contradict what `mandate start` just printed.
195
+ // Same posture as the mark-failed escalation below.
196
+ if (reg.approvedCeilingMinor !== undefined) {
197
+ const applied = await ledger
198
+ .applyApprovedCeiling(args.mandateId, reg.approvedCeilingMinor)
199
+ .then(() => true)
200
+ .catch(() => false);
201
+ if (!applied) {
202
+ // Rendered through minorToDecimal, never raw /100 — and only when the
203
+ // value is a sane integer, because THIS branch is also where an
204
+ // unusable value lands (applyApprovedCeiling rejects it). A warning
205
+ // must not throw on its way out. The currency is omitted deliberately:
206
+ // both mandate paths refuse anything but USD long before register, so
207
+ // it is known, and passing it would let a non-2-decimal code throw here.
208
+ const approved = Number.isSafeInteger(reg.approvedCeilingMinor) && reg.approvedCeilingMinor > 0
209
+ ? ` (${args.currency} ${minorToDecimal(reg.approvedCeilingMinor)})`
210
+ : '';
211
+ process.stderr.write(`warning: your owner's approved limit for this budget${approved} could NOT be saved ` +
212
+ `locally — 'mandate list' will overstate the remaining balance until you re-run ` +
213
+ `'mandate start'. Spending is still capped at the approved limit; a draw over it ` +
214
+ `is refused. mandateId=${args.mandateId}\n`);
215
+ }
216
+ }
217
+ return {
218
+ registerFailed: false,
219
+ ...(reg.approvedCeilingMinor !== undefined
220
+ ? { approvedCeilingMinor: reg.approvedCeilingMinor }
221
+ : {}),
222
+ };
223
+ }
167
224
  // Register failed: the server `card_mandate_spend` row was never created, so
168
225
  // a delegated draw against this mandate would 404 `no_mandate`. Mark it
169
226
  // register-failed so findCovering() SKIPS it and the owner's next checkout
@@ -293,6 +350,7 @@ export function createCliCheckoutEngine(deps = {}) {
293
350
  });
294
351
  return {
295
352
  ...facts,
353
+ ...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
296
354
  merchantHost: new URL(merchant.url).hostname,
297
355
  registerFailed: registered.registerFailed,
298
356
  ...(registered.registerFailureReason !== undefined
@@ -353,6 +411,7 @@ export function createCliCheckoutEngine(deps = {}) {
353
411
  });
354
412
  return {
355
413
  ...facts,
414
+ ...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
356
415
  merchantHost: new URL(claim.merchant.url).hostname,
357
416
  registerFailed: registered.registerFailed,
358
417
  ...(registered.registerFailureReason !== undefined
@@ -127,6 +127,27 @@ export declare class MandateLedger {
127
127
  * Throws only if the mandate is unknown.
128
128
  */
129
129
  markRegisterFailed(mandateId: string, now?: Date): Promise<CardMandateRecord>;
130
+ /**
131
+ * ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
132
+ *
133
+ * The requested ceiling is only a request. At register, auth clamps it to the
134
+ * owner's live card-grant cap (`min(requested, grant daily limit)`) and writes
135
+ * that. The local record used to keep the requested figure, so a runtime whose
136
+ * request exceeded the grant reported — and selected against — a budget the
137
+ * owner never approved. The draw still failed closed at auth's verdict, so it
138
+ * was never over-spend; it was the runtime lying about its own headroom and
139
+ * then hitting a decline it could not explain.
140
+ *
141
+ * LOWERS ONLY. A value at or above the current ceiling is ignored, not
142
+ * written: the server clamps downward, so a higher number means an unexpected
143
+ * response, and honouring it would let a client-observed value hand a runtime
144
+ * headroom no human approved. Cap authority stays server-side either way —
145
+ * this only stops the local copy from overstating it.
146
+ *
147
+ * Atomic and idempotent on the same serialized chain as every other mutation.
148
+ * Throws only if the mandate is unknown.
149
+ */
150
+ applyApprovedCeiling(mandateId: string, approvedCeilingMinor: number): Promise<CardMandateRecord>;
130
151
  /**
131
152
  * Atomically reserve headroom for a draw. Fail-closed: refuses when the
132
153
  * mandate is unknown, expired, or the amount exceeds remaining headroom. The
@@ -252,6 +252,40 @@ export class MandateLedger {
252
252
  return record;
253
253
  });
254
254
  }
255
+ /**
256
+ * ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
257
+ *
258
+ * The requested ceiling is only a request. At register, auth clamps it to the
259
+ * owner's live card-grant cap (`min(requested, grant daily limit)`) and writes
260
+ * that. The local record used to keep the requested figure, so a runtime whose
261
+ * request exceeded the grant reported — and selected against — a budget the
262
+ * owner never approved. The draw still failed closed at auth's verdict, so it
263
+ * was never over-spend; it was the runtime lying about its own headroom and
264
+ * then hitting a decline it could not explain.
265
+ *
266
+ * LOWERS ONLY. A value at or above the current ceiling is ignored, not
267
+ * written: the server clamps downward, so a higher number means an unexpected
268
+ * response, and honouring it would let a client-observed value hand a runtime
269
+ * headroom no human approved. Cap authority stays server-side either way —
270
+ * this only stops the local copy from overstating it.
271
+ *
272
+ * Atomic and idempotent on the same serialized chain as every other mutation.
273
+ * Throws only if the mandate is unknown.
274
+ */
275
+ applyApprovedCeiling(mandateId, approvedCeilingMinor) {
276
+ return this.run(async () => {
277
+ assertPositiveInteger(approvedCeilingMinor, 'approved mandate ceiling');
278
+ const file = await this.load();
279
+ const record = file.mandates.find((m) => m.mandateId === mandateId);
280
+ if (!record)
281
+ throw new Error(`no such mandate ${mandateId}`);
282
+ if (approvedCeilingMinor >= record.ceilingMinor)
283
+ return record;
284
+ record.ceilingMinor = approvedCeilingMinor;
285
+ await this.save(file);
286
+ return record;
287
+ });
288
+ }
255
289
  /**
256
290
  * Atomically reserve headroom for a draw. Fail-closed: refuses when the
257
291
  * mandate is unknown, expired, or the amount exceeds remaining headroom. The