@solvapay/server 2.5.0 → 2.6.0-preview-09731417a0a7b4fd3afb4783fdf20b5ad293f63d

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/dist/edge.d.ts CHANGED
@@ -831,6 +831,10 @@ interface components {
831
831
  currency: string;
832
832
  /** @description How many metered items the credit balance still covers (`balance / creditsPerUnit`) */
833
833
  remainingUnits?: number;
834
+ /** @description Credits per minor unit of `currency` (typically 100) */
835
+ creditsPerMinorUnit?: number;
836
+ /** @description USD → display-currency rate used with creditsPerMinorUnit */
837
+ displayExchangeRate?: number;
834
838
  };
835
839
  LimitPlanItemDto: {
836
840
  /** @description Derived billing cycle */
@@ -4291,6 +4295,10 @@ type PaywallGateRecoveryFields = {
4291
4295
  shortfallCredits?: number;
4292
4296
  /** How many calls the current allowance or wallet still covers. */
4293
4297
  remainingCalls?: number;
4298
+ /** Credits per minor currency unit, from the limits balance peg. */
4299
+ creditsPerMinorUnit?: number;
4300
+ /** USD → display-currency rate used with `creditsPerMinorUnit`. */
4301
+ displayExchangeRate?: number;
4294
4302
  /** Active purchase reference when the customer already holds a purchase. */
4295
4303
  purchaseRef?: string;
4296
4304
  /** Purchase/plan status when the backend sent one. */
@@ -5354,6 +5362,12 @@ interface SolvaPay {
5354
5362
  */
5355
5363
  paywall: {
5356
5364
  decide<TArgs extends PaywallArgs>(args: TArgs, metadata?: PaywallMetadata, getCustomerRef?: (args: TArgs) => string): Promise<PaywallDecision<TArgs>>;
5365
+ /**
5366
+ * Drop cached `checkLimits` snapshots for this customer (optionally
5367
+ * scoped to one product). Call after `activate_plan`, a completed
5368
+ * checkout, or a top-up so the next payable call refetches.
5369
+ */
5370
+ invalidateLimits(customerRef: string, product?: string): void;
5357
5371
  };
5358
5372
  /**
5359
5373
  * Direct access to the API client for advanced operations.
@@ -6144,7 +6158,13 @@ declare function processTopupPaymentIntentCore(request: Request, body: {
6144
6158
  declare function createCheckoutSessionCore(request: Request, body: {
6145
6159
  productRef: string;
6146
6160
  planRef?: string;
6147
- returnUrl?: string;
6161
+ /**
6162
+ * URL to send the customer back to after payment. Pass `null` to omit
6163
+ * the field entirely — skips `options.returnUrl` and the request-origin
6164
+ * fallback. Use this when the caller has no browsable page to return to
6165
+ * (MCP servers whose public URL is an API endpoint, not a website).
6166
+ */
6167
+ returnUrl?: string | null;
6148
6168
  purpose?: 'credit_topup';
6149
6169
  }, options?: {
6150
6170
  solvaPay?: SolvaPay;
package/dist/edge.js CHANGED
@@ -683,9 +683,8 @@ async function verifyProductConfiguration(options) {
683
683
  }
684
684
 
685
685
  // src/paywall-state.ts
686
- import { minorUnitsPerMajor } from "@solvapay/core";
686
+ import { creditsToDisplayMinorUnits, minorUnitsPerMajor } from "@solvapay/core";
687
687
  var CHECKOUT_SESSION_TTL_MINUTES = 15;
688
- var DOCS_HINT = "See docs://solvapay/overview.md.";
689
688
  function creditSignals(limits) {
690
689
  if (!limits) return { isCreditBased: false };
691
690
  const creditBalance = limits.balance?.creditBalance ?? limits.creditBalance;
@@ -769,6 +768,24 @@ function formatMinor(amountMinor, currency) {
769
768
  function formatCredits(value) {
770
769
  return value.toLocaleString("en-US");
771
770
  }
771
+ function formatCreditsWithMoney(credits, gate) {
772
+ const amount = formatCredits(credits);
773
+ const peg = gate.creditsPerMinorUnit;
774
+ const currency = gate.currency;
775
+ const rate = gate.displayExchangeRate;
776
+ if (peg && peg > 0 && currency && rate != null && rate > 0) {
777
+ const minor = creditsToDisplayMinorUnits({
778
+ credits,
779
+ creditsPerMinorUnit: peg,
780
+ displayExchangeRate: rate,
781
+ displayCurrency: currency
782
+ });
783
+ if (minor != null) {
784
+ return `${amount} credits (~${formatMinor(minor, currency)})`;
785
+ }
786
+ }
787
+ return `${amount} credits`;
788
+ }
772
789
  function meterLabel(gate) {
773
790
  if (!gate.meterName) return "units";
774
791
  return gate.meterName.replace(/_/g, " ");
@@ -812,49 +829,51 @@ function buildGateMessage(state, gate) {
812
829
  const nextLine = price ? ` The next call is ${price}.` : "";
813
830
  const planName = gate.planName ?? "This plan";
814
831
  const antiTrap = gate.creditBalance !== void 0 && gate.creditBalance > 0 && (gate.creditsPerCall === void 0 || gate.creditsPerCall === 0) ? ` Adding credits will not help, because ${planName} does not spend them.` : "";
815
- const switchLine = ladder ? ` Or switch plan: ${ladder}.` : recoverClause(url, "continue", "checkout");
816
- return `${usedLine}${nextLine}${antiTrap}${switchLine} ${DOCS_HINT}`;
832
+ const switchLine = ladder ? ` Or switch plan: ${ladder}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.` : recoverClause(url, "continue", "checkout");
833
+ return `${usedLine}${nextLine}${antiTrap}${switchLine}`;
817
834
  }
818
835
  case "activation_required":
819
- return `Your plan needs activation.${recoverClause(url, "activate", "checkout")} Or call \`activate_plan\` with a \`planRef\`. ${DOCS_HINT}`;
836
+ return `Your plan needs activation.${recoverClause(url, "activate", "checkout")} Or call \`activate_plan\` with a \`planRef\`.`;
820
837
  case "topup_required": {
821
838
  const balance = gate.creditBalance;
822
839
  const cost = gate.creditsPerCall;
823
840
  const shortfall = gate.shortfallCredits;
824
841
  let lead;
825
842
  if (balance !== void 0 && cost !== void 0 && shortfall !== void 0) {
826
- lead = `Out of credits for this call. Balance ${formatCredits(balance)} credits; this call costs ${formatCredits(cost)} credits \u2014 ${formatCredits(shortfall)} short.`;
843
+ lead = `Out of credits for this call. Balance ${formatCreditsWithMoney(balance, gate)}; this call costs ${formatCreditsWithMoney(cost, gate)} \u2014 ${formatCredits(shortfall)} short.`;
827
844
  } else {
828
845
  lead = "Included usage is exhausted.";
829
846
  }
830
847
  const topup = recoverClause(url, "add credits", "topup", "Add credits");
831
- const auto = autoRechargeDisabled(gate) ? " Auto-recharge is off \u2014 turn it on from the account tool to avoid this next time." : "";
832
- const switchLine = ladder ? ` Or switch plan: ${ladder}.` : "";
833
- return `${lead}${topup}${auto}${switchLine} ${DOCS_HINT}`;
848
+ const auto = autoRechargeDisabled(gate) ? ` Auto-recharge is off \u2014 ${callViewer("account")} to turn it on and avoid this next time.` : "";
849
+ const switchLine = ladder ? ` Or switch plan: ${ladder}.` : hasActivePlan(gate) ? "" : ` Or ${callViewer("checkout")} to pick a plan.`;
850
+ return `${lead}${topup}${auto}${switchLine}`;
834
851
  }
835
852
  case "upgrade_required": {
836
853
  if (hasActivePlan(gate)) {
837
854
  const planName = gate.planName ?? "This plan";
838
855
  const lead = `${planName} is active but its included usage is exhausted, and the automatic switch to the next plan did not complete.`;
839
856
  if (ladder) {
840
- return `${lead} Switch here: ${ladder}. ${DOCS_HINT}`;
857
+ return `${lead} Switch here: ${ladder}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.`;
841
858
  }
842
- return `${lead}${recoverClause(url, "switch plan", "checkout")} ${DOCS_HINT}`;
859
+ return `${lead}${recoverClause(url, "switch plan", "checkout")}`;
843
860
  }
844
861
  if (ladder) {
845
- return `You don't have an active plan for this tool. Pick a plan to use this tool: ${ladder} (links expire in ${CHECKOUT_SESSION_TTL_MINUTES} minutes), or ${callViewer("checkout")}. ${DOCS_HINT}`;
862
+ return `You don't have an active plan for this tool. Pick a plan to use this tool: ${ladder} (links expire in ${CHECKOUT_SESSION_TTL_MINUTES} minutes), or ${callViewer("checkout")}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.`;
846
863
  }
847
- return `You don't have an active plan for this tool.${recoverClause(url, "pick a plan", "checkout")} ${DOCS_HINT}`;
864
+ return `You don't have an active plan for this tool.${recoverClause(url, "pick a plan", "checkout")}`;
848
865
  }
849
866
  case "reactivation_required":
850
- return `Your previous plan is no longer active. ${callViewer("account").replace(/^c/, "C")} to reactivate it, or ${callViewer("checkout")} to pick a new plan. ${DOCS_HINT}`;
867
+ return `Your previous plan is no longer active. ${callViewer("account").replace(/^c/, "C")} to reactivate it, or ${callViewer("checkout")} to pick a new plan.`;
851
868
  }
852
869
  }
853
870
  function buildNudgeMessage(state, limits) {
854
871
  const url = limits?.checkoutUrl && limits.checkoutUrl.length > 0 ? limits.checkoutUrl : null;
855
872
  const visitClause = url ? `, or ${namedCheckoutMarkdown(url)}` : "";
856
- const remaining = creditSignals(limits).remainingCalls;
857
- switch (state.kind) {
873
+ const signals = creditSignals(limits);
874
+ const remaining = signals.remainingCalls;
875
+ const kind = state.kind === "topup_required" && limits && !signals.isCreditBased ? "limit_reached" : state.kind;
876
+ switch (kind) {
858
877
  case "topup_required":
859
878
  if (remaining === 0) {
860
879
  return `Heads up \u2014 0 calls left \u2014 the next call needs a top-up. ${callViewer("topup").replace(/^c/, "C")} to add more${visitClause}.`;
@@ -939,6 +958,8 @@ function recoveryFields(limits, state) {
939
958
  ...signals.creditsPerCall !== void 0 ? { creditsPerCall: signals.creditsPerCall } : {},
940
959
  ...signals.shortfallCredits !== void 0 ? { shortfallCredits: signals.shortfallCredits } : {},
941
960
  ...signals.remainingCalls !== void 0 ? { remainingCalls: signals.remainingCalls } : {},
961
+ ...limits.balance?.creditsPerMinorUnit !== void 0 ? { creditsPerMinorUnit: limits.balance.creditsPerMinorUnit } : {},
962
+ ...limits.balance?.displayExchangeRate !== void 0 ? { displayExchangeRate: limits.balance.displayExchangeRate } : {},
942
963
  ...limits.purchaseRef !== void 0 ? { purchaseRef: limits.purchaseRef } : {},
943
964
  ...limits.autoRecharge !== void 0 ? { autoRecharge: limits.autoRecharge } : {},
944
965
  ...links ? { links } : {}
@@ -1195,6 +1216,23 @@ var SolvaPayPaywall = class {
1195
1216
  console.log(...args);
1196
1217
  }
1197
1218
  }
1219
+ /**
1220
+ * Drop cached limits for `customerRef`. When `product` is omitted every
1221
+ * meter/product entry for that customer is removed. Matches both the
1222
+ * backend `cus_…` ref and an unmapped external ref used as a cache key.
1223
+ */
1224
+ invalidateLimits(customerRef, product) {
1225
+ const aliases = /* @__PURE__ */ new Set([customerRef]);
1226
+ const mapped = this.customerRefMapping.get(customerRef);
1227
+ if (mapped) aliases.add(mapped);
1228
+ if (!customerRef.startsWith("cus_")) aliases.add(`cus_${customerRef}`);
1229
+ for (const key of [...this.limitsCache.keys()]) {
1230
+ const [ref, prod] = key.split(":");
1231
+ if (!ref || !aliases.has(ref)) continue;
1232
+ if (product && prod !== product) continue;
1233
+ this.limitsCache.delete(key);
1234
+ }
1235
+ }
1198
1236
  resolveProduct(metadata) {
1199
1237
  return requireProductRef(metadata.product);
1200
1238
  }
@@ -2214,6 +2252,9 @@ function createSolvaPay(config) {
2214
2252
  paywall: {
2215
2253
  decide(args, metadata, getCustomerRef) {
2216
2254
  return paywall.decide(args, metadata, getCustomerRef);
2255
+ },
2256
+ invalidateLimits(customerRef, product) {
2257
+ paywall.invalidateLimits(customerRef, product);
2217
2258
  }
2218
2259
  },
2219
2260
  // Common API methods exposed directly for convenience
@@ -2282,7 +2323,7 @@ function createSolvaPay(config) {
2282
2323
  customerRef: params.customerRef,
2283
2324
  productRef: params.productRef,
2284
2325
  planRef: params.planRef,
2285
- returnUrl: params.returnUrl,
2326
+ ...params.returnUrl ? { returnUrl: params.returnUrl } : {},
2286
2327
  ...params.purpose ? { purpose: params.purpose } : {}
2287
2328
  });
2288
2329
  },
@@ -2981,12 +3022,15 @@ async function createCheckoutSessionCore(request, body, options = {}) {
2981
3022
  return customerResult;
2982
3023
  }
2983
3024
  const customerRef = customerResult;
2984
- let returnUrl = body.returnUrl || options.returnUrl;
2985
- if (!returnUrl) {
2986
- try {
2987
- const url = new URL(request.url);
2988
- returnUrl = url.origin;
2989
- } catch {
3025
+ let returnUrl;
3026
+ if (body.returnUrl !== null) {
3027
+ returnUrl = body.returnUrl || options.returnUrl;
3028
+ if (!returnUrl) {
3029
+ try {
3030
+ const url = new URL(request.url);
3031
+ returnUrl = url.origin;
3032
+ } catch {
3033
+ }
2990
3034
  }
2991
3035
  }
2992
3036
  const solvaPay = options.solvaPay || createSolvaPay();
@@ -2994,7 +3038,7 @@ async function createCheckoutSessionCore(request, body, options = {}) {
2994
3038
  productRef: body.productRef,
2995
3039
  customerRef,
2996
3040
  planRef: body.planRef || void 0,
2997
- returnUrl,
3041
+ ...returnUrl ? { returnUrl } : {},
2998
3042
  ...body.purpose ? { purpose: body.purpose } : {}
2999
3043
  });
3000
3044
  return {
@@ -841,7 +841,6 @@ function createSolvaPayClient(opts) {
841
841
  // src/paywall-state.ts
842
842
  var import_core3 = require("@solvapay/core");
843
843
  var CHECKOUT_SESSION_TTL_MINUTES = 15;
844
- var DOCS_HINT = "See docs://solvapay/overview.md.";
845
844
  function creditSignals(limits) {
846
845
  if (!limits) return { isCreditBased: false };
847
846
  const creditBalance = limits.balance?.creditBalance ?? limits.creditBalance;
@@ -925,6 +924,24 @@ function formatMinor(amountMinor, currency) {
925
924
  function formatCredits(value) {
926
925
  return value.toLocaleString("en-US");
927
926
  }
927
+ function formatCreditsWithMoney(credits, gate) {
928
+ const amount = formatCredits(credits);
929
+ const peg = gate.creditsPerMinorUnit;
930
+ const currency = gate.currency;
931
+ const rate = gate.displayExchangeRate;
932
+ if (peg && peg > 0 && currency && rate != null && rate > 0) {
933
+ const minor = (0, import_core3.creditsToDisplayMinorUnits)({
934
+ credits,
935
+ creditsPerMinorUnit: peg,
936
+ displayExchangeRate: rate,
937
+ displayCurrency: currency
938
+ });
939
+ if (minor != null) {
940
+ return `${amount} credits (~${formatMinor(minor, currency)})`;
941
+ }
942
+ }
943
+ return `${amount} credits`;
944
+ }
928
945
  function meterLabel(gate) {
929
946
  if (!gate.meterName) return "units";
930
947
  return gate.meterName.replace(/_/g, " ");
@@ -968,42 +985,42 @@ function buildGateMessage(state, gate) {
968
985
  const nextLine = price ? ` The next call is ${price}.` : "";
969
986
  const planName = gate.planName ?? "This plan";
970
987
  const antiTrap = gate.creditBalance !== void 0 && gate.creditBalance > 0 && (gate.creditsPerCall === void 0 || gate.creditsPerCall === 0) ? ` Adding credits will not help, because ${planName} does not spend them.` : "";
971
- const switchLine = ladder ? ` Or switch plan: ${ladder}.` : recoverClause(url, "continue", "checkout");
972
- return `${usedLine}${nextLine}${antiTrap}${switchLine} ${DOCS_HINT}`;
988
+ const switchLine = ladder ? ` Or switch plan: ${ladder}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.` : recoverClause(url, "continue", "checkout");
989
+ return `${usedLine}${nextLine}${antiTrap}${switchLine}`;
973
990
  }
974
991
  case "activation_required":
975
- return `Your plan needs activation.${recoverClause(url, "activate", "checkout")} Or call \`activate_plan\` with a \`planRef\`. ${DOCS_HINT}`;
992
+ return `Your plan needs activation.${recoverClause(url, "activate", "checkout")} Or call \`activate_plan\` with a \`planRef\`.`;
976
993
  case "topup_required": {
977
994
  const balance = gate.creditBalance;
978
995
  const cost = gate.creditsPerCall;
979
996
  const shortfall = gate.shortfallCredits;
980
997
  let lead;
981
998
  if (balance !== void 0 && cost !== void 0 && shortfall !== void 0) {
982
- lead = `Out of credits for this call. Balance ${formatCredits(balance)} credits; this call costs ${formatCredits(cost)} credits \u2014 ${formatCredits(shortfall)} short.`;
999
+ lead = `Out of credits for this call. Balance ${formatCreditsWithMoney(balance, gate)}; this call costs ${formatCreditsWithMoney(cost, gate)} \u2014 ${formatCredits(shortfall)} short.`;
983
1000
  } else {
984
1001
  lead = "Included usage is exhausted.";
985
1002
  }
986
1003
  const topup = recoverClause(url, "add credits", "topup", "Add credits");
987
- const auto = autoRechargeDisabled(gate) ? " Auto-recharge is off \u2014 turn it on from the account tool to avoid this next time." : "";
988
- const switchLine = ladder ? ` Or switch plan: ${ladder}.` : "";
989
- return `${lead}${topup}${auto}${switchLine} ${DOCS_HINT}`;
1004
+ const auto = autoRechargeDisabled(gate) ? ` Auto-recharge is off \u2014 ${callViewer("account")} to turn it on and avoid this next time.` : "";
1005
+ const switchLine = ladder ? ` Or switch plan: ${ladder}.` : hasActivePlan(gate) ? "" : ` Or ${callViewer("checkout")} to pick a plan.`;
1006
+ return `${lead}${topup}${auto}${switchLine}`;
990
1007
  }
991
1008
  case "upgrade_required": {
992
1009
  if (hasActivePlan(gate)) {
993
1010
  const planName = gate.planName ?? "This plan";
994
1011
  const lead = `${planName} is active but its included usage is exhausted, and the automatic switch to the next plan did not complete.`;
995
1012
  if (ladder) {
996
- return `${lead} Switch here: ${ladder}. ${DOCS_HINT}`;
1013
+ return `${lead} Switch here: ${ladder}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.`;
997
1014
  }
998
- return `${lead}${recoverClause(url, "switch plan", "checkout")} ${DOCS_HINT}`;
1015
+ return `${lead}${recoverClause(url, "switch plan", "checkout")}`;
999
1016
  }
1000
1017
  if (ladder) {
1001
- return `You don't have an active plan for this tool. Pick a plan to use this tool: ${ladder} (links expire in ${CHECKOUT_SESSION_TTL_MINUTES} minutes), or ${callViewer("checkout")}. ${DOCS_HINT}`;
1018
+ return `You don't have an active plan for this tool. Pick a plan to use this tool: ${ladder} (links expire in ${CHECKOUT_SESSION_TTL_MINUTES} minutes), or ${callViewer("checkout")}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.`;
1002
1019
  }
1003
- return `You don't have an active plan for this tool.${recoverClause(url, "pick a plan", "checkout")} ${DOCS_HINT}`;
1020
+ return `You don't have an active plan for this tool.${recoverClause(url, "pick a plan", "checkout")}`;
1004
1021
  }
1005
1022
  case "reactivation_required":
1006
- return `Your previous plan is no longer active. ${callViewer("account").replace(/^c/, "C")} to reactivate it, or ${callViewer("checkout")} to pick a new plan. ${DOCS_HINT}`;
1023
+ return `Your previous plan is no longer active. ${callViewer("account").replace(/^c/, "C")} to reactivate it, or ${callViewer("checkout")} to pick a new plan.`;
1007
1024
  }
1008
1025
  }
1009
1026
 
@@ -1073,6 +1090,8 @@ function recoveryFields(limits, state) {
1073
1090
  ...signals.creditsPerCall !== void 0 ? { creditsPerCall: signals.creditsPerCall } : {},
1074
1091
  ...signals.shortfallCredits !== void 0 ? { shortfallCredits: signals.shortfallCredits } : {},
1075
1092
  ...signals.remainingCalls !== void 0 ? { remainingCalls: signals.remainingCalls } : {},
1093
+ ...limits.balance?.creditsPerMinorUnit !== void 0 ? { creditsPerMinorUnit: limits.balance.creditsPerMinorUnit } : {},
1094
+ ...limits.balance?.displayExchangeRate !== void 0 ? { displayExchangeRate: limits.balance.displayExchangeRate } : {},
1076
1095
  ...limits.purchaseRef !== void 0 ? { purchaseRef: limits.purchaseRef } : {},
1077
1096
  ...limits.autoRecharge !== void 0 ? { autoRecharge: limits.autoRecharge } : {},
1078
1097
  ...links ? { links } : {}
@@ -1329,6 +1348,23 @@ var SolvaPayPaywall = class {
1329
1348
  console.log(...args);
1330
1349
  }
1331
1350
  }
1351
+ /**
1352
+ * Drop cached limits for `customerRef`. When `product` is omitted every
1353
+ * meter/product entry for that customer is removed. Matches both the
1354
+ * backend `cus_…` ref and an unmapped external ref used as a cache key.
1355
+ */
1356
+ invalidateLimits(customerRef, product) {
1357
+ const aliases = /* @__PURE__ */ new Set([customerRef]);
1358
+ const mapped = this.customerRefMapping.get(customerRef);
1359
+ if (mapped) aliases.add(mapped);
1360
+ if (!customerRef.startsWith("cus_")) aliases.add(`cus_${customerRef}`);
1361
+ for (const key of [...this.limitsCache.keys()]) {
1362
+ const [ref, prod] = key.split(":");
1363
+ if (!ref || !aliases.has(ref)) continue;
1364
+ if (product && prod !== product) continue;
1365
+ this.limitsCache.delete(key);
1366
+ }
1367
+ }
1332
1368
  resolveProduct(metadata) {
1333
1369
  return requireProductRef(metadata.product);
1334
1370
  }
@@ -2348,6 +2384,9 @@ function createSolvaPay(config) {
2348
2384
  paywall: {
2349
2385
  decide(args, metadata, getCustomerRef) {
2350
2386
  return paywall.decide(args, metadata, getCustomerRef);
2387
+ },
2388
+ invalidateLimits(customerRef, product) {
2389
+ paywall.invalidateLimits(customerRef, product);
2351
2390
  }
2352
2391
  },
2353
2392
  // Common API methods exposed directly for convenience
@@ -2416,7 +2455,7 @@ function createSolvaPay(config) {
2416
2455
  customerRef: params.customerRef,
2417
2456
  productRef: params.productRef,
2418
2457
  planRef: params.planRef,
2419
- returnUrl: params.returnUrl,
2458
+ ...params.returnUrl ? { returnUrl: params.returnUrl } : {},
2420
2459
  ...params.purpose ? { purpose: params.purpose } : {}
2421
2460
  });
2422
2461
  },
@@ -2762,12 +2801,15 @@ async function createCheckoutSessionCore(request, body, options = {}) {
2762
2801
  return customerResult;
2763
2802
  }
2764
2803
  const customerRef = customerResult;
2765
- let returnUrl = body.returnUrl || options.returnUrl;
2766
- if (!returnUrl) {
2767
- try {
2768
- const url = new URL(request.url);
2769
- returnUrl = url.origin;
2770
- } catch {
2804
+ let returnUrl;
2805
+ if (body.returnUrl !== null) {
2806
+ returnUrl = body.returnUrl || options.returnUrl;
2807
+ if (!returnUrl) {
2808
+ try {
2809
+ const url = new URL(request.url);
2810
+ returnUrl = url.origin;
2811
+ } catch {
2812
+ }
2771
2813
  }
2772
2814
  }
2773
2815
  const solvaPay = options.solvaPay || createSolvaPay();
@@ -2775,7 +2817,7 @@ async function createCheckoutSessionCore(request, body, options = {}) {
2775
2817
  productRef: body.productRef,
2776
2818
  customerRef,
2777
2819
  planRef: body.planRef || void 0,
2778
- returnUrl,
2820
+ ...returnUrl ? { returnUrl } : {},
2779
2821
  ...body.purpose ? { purpose: body.purpose } : {}
2780
2822
  });
2781
2823
  return {
@@ -828,6 +828,10 @@ interface components {
828
828
  currency: string;
829
829
  /** @description How many metered items the credit balance still covers (`balance / creditsPerUnit`) */
830
830
  remainingUnits?: number;
831
+ /** @description Credits per minor unit of `currency` (typically 100) */
832
+ creditsPerMinorUnit?: number;
833
+ /** @description USD → display-currency rate used with creditsPerMinorUnit */
834
+ displayExchangeRate?: number;
831
835
  };
832
836
  LimitPlanItemDto: {
833
837
  /** @description Derived billing cycle */
@@ -828,6 +828,10 @@ interface components {
828
828
  currency: string;
829
829
  /** @description How many metered items the credit balance still covers (`balance / creditsPerUnit`) */
830
830
  remainingUnits?: number;
831
+ /** @description Credits per minor unit of `currency` (typically 100) */
832
+ creditsPerMinorUnit?: number;
833
+ /** @description USD → display-currency rate used with creditsPerMinorUnit */
834
+ displayExchangeRate?: number;
831
835
  };
832
836
  LimitPlanItemDto: {
833
837
  /** @description Derived billing cycle */
@@ -785,9 +785,8 @@ function createSolvaPayClient(opts) {
785
785
  }
786
786
 
787
787
  // src/paywall-state.ts
788
- import { minorUnitsPerMajor } from "@solvapay/core";
788
+ import { creditsToDisplayMinorUnits, minorUnitsPerMajor } from "@solvapay/core";
789
789
  var CHECKOUT_SESSION_TTL_MINUTES = 15;
790
- var DOCS_HINT = "See docs://solvapay/overview.md.";
791
790
  function creditSignals(limits) {
792
791
  if (!limits) return { isCreditBased: false };
793
792
  const creditBalance = limits.balance?.creditBalance ?? limits.creditBalance;
@@ -871,6 +870,24 @@ function formatMinor(amountMinor, currency) {
871
870
  function formatCredits(value) {
872
871
  return value.toLocaleString("en-US");
873
872
  }
873
+ function formatCreditsWithMoney(credits, gate) {
874
+ const amount = formatCredits(credits);
875
+ const peg = gate.creditsPerMinorUnit;
876
+ const currency = gate.currency;
877
+ const rate = gate.displayExchangeRate;
878
+ if (peg && peg > 0 && currency && rate != null && rate > 0) {
879
+ const minor = creditsToDisplayMinorUnits({
880
+ credits,
881
+ creditsPerMinorUnit: peg,
882
+ displayExchangeRate: rate,
883
+ displayCurrency: currency
884
+ });
885
+ if (minor != null) {
886
+ return `${amount} credits (~${formatMinor(minor, currency)})`;
887
+ }
888
+ }
889
+ return `${amount} credits`;
890
+ }
874
891
  function meterLabel(gate) {
875
892
  if (!gate.meterName) return "units";
876
893
  return gate.meterName.replace(/_/g, " ");
@@ -914,42 +931,42 @@ function buildGateMessage(state, gate) {
914
931
  const nextLine = price ? ` The next call is ${price}.` : "";
915
932
  const planName = gate.planName ?? "This plan";
916
933
  const antiTrap = gate.creditBalance !== void 0 && gate.creditBalance > 0 && (gate.creditsPerCall === void 0 || gate.creditsPerCall === 0) ? ` Adding credits will not help, because ${planName} does not spend them.` : "";
917
- const switchLine = ladder ? ` Or switch plan: ${ladder}.` : recoverClause(url, "continue", "checkout");
918
- return `${usedLine}${nextLine}${antiTrap}${switchLine} ${DOCS_HINT}`;
934
+ const switchLine = ladder ? ` Or switch plan: ${ladder}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.` : recoverClause(url, "continue", "checkout");
935
+ return `${usedLine}${nextLine}${antiTrap}${switchLine}`;
919
936
  }
920
937
  case "activation_required":
921
- return `Your plan needs activation.${recoverClause(url, "activate", "checkout")} Or call \`activate_plan\` with a \`planRef\`. ${DOCS_HINT}`;
938
+ return `Your plan needs activation.${recoverClause(url, "activate", "checkout")} Or call \`activate_plan\` with a \`planRef\`.`;
922
939
  case "topup_required": {
923
940
  const balance = gate.creditBalance;
924
941
  const cost = gate.creditsPerCall;
925
942
  const shortfall = gate.shortfallCredits;
926
943
  let lead;
927
944
  if (balance !== void 0 && cost !== void 0 && shortfall !== void 0) {
928
- lead = `Out of credits for this call. Balance ${formatCredits(balance)} credits; this call costs ${formatCredits(cost)} credits \u2014 ${formatCredits(shortfall)} short.`;
945
+ lead = `Out of credits for this call. Balance ${formatCreditsWithMoney(balance, gate)}; this call costs ${formatCreditsWithMoney(cost, gate)} \u2014 ${formatCredits(shortfall)} short.`;
929
946
  } else {
930
947
  lead = "Included usage is exhausted.";
931
948
  }
932
949
  const topup = recoverClause(url, "add credits", "topup", "Add credits");
933
- const auto = autoRechargeDisabled(gate) ? " Auto-recharge is off \u2014 turn it on from the account tool to avoid this next time." : "";
934
- const switchLine = ladder ? ` Or switch plan: ${ladder}.` : "";
935
- return `${lead}${topup}${auto}${switchLine} ${DOCS_HINT}`;
950
+ const auto = autoRechargeDisabled(gate) ? ` Auto-recharge is off \u2014 ${callViewer("account")} to turn it on and avoid this next time.` : "";
951
+ const switchLine = ladder ? ` Or switch plan: ${ladder}.` : hasActivePlan(gate) ? "" : ` Or ${callViewer("checkout")} to pick a plan.`;
952
+ return `${lead}${topup}${auto}${switchLine}`;
936
953
  }
937
954
  case "upgrade_required": {
938
955
  if (hasActivePlan(gate)) {
939
956
  const planName = gate.planName ?? "This plan";
940
957
  const lead = `${planName} is active but its included usage is exhausted, and the automatic switch to the next plan did not complete.`;
941
958
  if (ladder) {
942
- return `${lead} Switch here: ${ladder}. ${DOCS_HINT}`;
959
+ return `${lead} Switch here: ${ladder}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.`;
943
960
  }
944
- return `${lead}${recoverClause(url, "switch plan", "checkout")} ${DOCS_HINT}`;
961
+ return `${lead}${recoverClause(url, "switch plan", "checkout")}`;
945
962
  }
946
963
  if (ladder) {
947
- return `You don't have an active plan for this tool. Pick a plan to use this tool: ${ladder} (links expire in ${CHECKOUT_SESSION_TTL_MINUTES} minutes), or ${callViewer("checkout")}. ${DOCS_HINT}`;
964
+ return `You don't have an active plan for this tool. Pick a plan to use this tool: ${ladder} (links expire in ${CHECKOUT_SESSION_TTL_MINUTES} minutes), or ${callViewer("checkout")}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.`;
948
965
  }
949
- return `You don't have an active plan for this tool.${recoverClause(url, "pick a plan", "checkout")} ${DOCS_HINT}`;
966
+ return `You don't have an active plan for this tool.${recoverClause(url, "pick a plan", "checkout")}`;
950
967
  }
951
968
  case "reactivation_required":
952
- return `Your previous plan is no longer active. ${callViewer("account").replace(/^c/, "C")} to reactivate it, or ${callViewer("checkout")} to pick a new plan. ${DOCS_HINT}`;
969
+ return `Your previous plan is no longer active. ${callViewer("account").replace(/^c/, "C")} to reactivate it, or ${callViewer("checkout")} to pick a new plan.`;
953
970
  }
954
971
  }
955
972
 
@@ -1019,6 +1036,8 @@ function recoveryFields(limits, state) {
1019
1036
  ...signals.creditsPerCall !== void 0 ? { creditsPerCall: signals.creditsPerCall } : {},
1020
1037
  ...signals.shortfallCredits !== void 0 ? { shortfallCredits: signals.shortfallCredits } : {},
1021
1038
  ...signals.remainingCalls !== void 0 ? { remainingCalls: signals.remainingCalls } : {},
1039
+ ...limits.balance?.creditsPerMinorUnit !== void 0 ? { creditsPerMinorUnit: limits.balance.creditsPerMinorUnit } : {},
1040
+ ...limits.balance?.displayExchangeRate !== void 0 ? { displayExchangeRate: limits.balance.displayExchangeRate } : {},
1022
1041
  ...limits.purchaseRef !== void 0 ? { purchaseRef: limits.purchaseRef } : {},
1023
1042
  ...limits.autoRecharge !== void 0 ? { autoRecharge: limits.autoRecharge } : {},
1024
1043
  ...links ? { links } : {}
@@ -1275,6 +1294,23 @@ var SolvaPayPaywall = class {
1275
1294
  console.log(...args);
1276
1295
  }
1277
1296
  }
1297
+ /**
1298
+ * Drop cached limits for `customerRef`. When `product` is omitted every
1299
+ * meter/product entry for that customer is removed. Matches both the
1300
+ * backend `cus_…` ref and an unmapped external ref used as a cache key.
1301
+ */
1302
+ invalidateLimits(customerRef, product) {
1303
+ const aliases = /* @__PURE__ */ new Set([customerRef]);
1304
+ const mapped = this.customerRefMapping.get(customerRef);
1305
+ if (mapped) aliases.add(mapped);
1306
+ if (!customerRef.startsWith("cus_")) aliases.add(`cus_${customerRef}`);
1307
+ for (const key of [...this.limitsCache.keys()]) {
1308
+ const [ref, prod] = key.split(":");
1309
+ if (!ref || !aliases.has(ref)) continue;
1310
+ if (product && prod !== product) continue;
1311
+ this.limitsCache.delete(key);
1312
+ }
1313
+ }
1278
1314
  resolveProduct(metadata) {
1279
1315
  return requireProductRef(metadata.product);
1280
1316
  }
@@ -2294,6 +2330,9 @@ function createSolvaPay(config) {
2294
2330
  paywall: {
2295
2331
  decide(args, metadata, getCustomerRef) {
2296
2332
  return paywall.decide(args, metadata, getCustomerRef);
2333
+ },
2334
+ invalidateLimits(customerRef, product) {
2335
+ paywall.invalidateLimits(customerRef, product);
2297
2336
  }
2298
2337
  },
2299
2338
  // Common API methods exposed directly for convenience
@@ -2362,7 +2401,7 @@ function createSolvaPay(config) {
2362
2401
  customerRef: params.customerRef,
2363
2402
  productRef: params.productRef,
2364
2403
  planRef: params.planRef,
2365
- returnUrl: params.returnUrl,
2404
+ ...params.returnUrl ? { returnUrl: params.returnUrl } : {},
2366
2405
  ...params.purpose ? { purpose: params.purpose } : {}
2367
2406
  });
2368
2407
  },
@@ -2710,12 +2749,15 @@ async function createCheckoutSessionCore(request, body, options = {}) {
2710
2749
  return customerResult;
2711
2750
  }
2712
2751
  const customerRef = customerResult;
2713
- let returnUrl = body.returnUrl || options.returnUrl;
2714
- if (!returnUrl) {
2715
- try {
2716
- const url = new URL(request.url);
2717
- returnUrl = url.origin;
2718
- } catch {
2752
+ let returnUrl;
2753
+ if (body.returnUrl !== null) {
2754
+ returnUrl = body.returnUrl || options.returnUrl;
2755
+ if (!returnUrl) {
2756
+ try {
2757
+ const url = new URL(request.url);
2758
+ returnUrl = url.origin;
2759
+ } catch {
2760
+ }
2719
2761
  }
2720
2762
  }
2721
2763
  const solvaPay = options.solvaPay || createSolvaPay();
@@ -2723,7 +2765,7 @@ async function createCheckoutSessionCore(request, body, options = {}) {
2723
2765
  productRef: body.productRef,
2724
2766
  customerRef,
2725
2767
  planRef: body.planRef || void 0,
2726
- returnUrl,
2768
+ ...returnUrl ? { returnUrl } : {},
2727
2769
  ...body.purpose ? { purpose: body.purpose } : {}
2728
2770
  });
2729
2771
  return {
package/dist/index.cjs CHANGED
@@ -722,7 +722,6 @@ function createSolvaPayClient(opts) {
722
722
  // src/paywall-state.ts
723
723
  var import_core2 = require("@solvapay/core");
724
724
  var CHECKOUT_SESSION_TTL_MINUTES = 15;
725
- var DOCS_HINT = "See docs://solvapay/overview.md.";
726
725
  function creditSignals(limits) {
727
726
  if (!limits) return { isCreditBased: false };
728
727
  const creditBalance = limits.balance?.creditBalance ?? limits.creditBalance;
@@ -806,6 +805,24 @@ function formatMinor(amountMinor, currency) {
806
805
  function formatCredits(value) {
807
806
  return value.toLocaleString("en-US");
808
807
  }
808
+ function formatCreditsWithMoney(credits, gate) {
809
+ const amount = formatCredits(credits);
810
+ const peg = gate.creditsPerMinorUnit;
811
+ const currency = gate.currency;
812
+ const rate = gate.displayExchangeRate;
813
+ if (peg && peg > 0 && currency && rate != null && rate > 0) {
814
+ const minor = (0, import_core2.creditsToDisplayMinorUnits)({
815
+ credits,
816
+ creditsPerMinorUnit: peg,
817
+ displayExchangeRate: rate,
818
+ displayCurrency: currency
819
+ });
820
+ if (minor != null) {
821
+ return `${amount} credits (~${formatMinor(minor, currency)})`;
822
+ }
823
+ }
824
+ return `${amount} credits`;
825
+ }
809
826
  function meterLabel(gate) {
810
827
  if (!gate.meterName) return "units";
811
828
  return gate.meterName.replace(/_/g, " ");
@@ -849,49 +866,51 @@ function buildGateMessage(state, gate) {
849
866
  const nextLine = price ? ` The next call is ${price}.` : "";
850
867
  const planName = gate.planName ?? "This plan";
851
868
  const antiTrap = gate.creditBalance !== void 0 && gate.creditBalance > 0 && (gate.creditsPerCall === void 0 || gate.creditsPerCall === 0) ? ` Adding credits will not help, because ${planName} does not spend them.` : "";
852
- const switchLine = ladder ? ` Or switch plan: ${ladder}.` : recoverClause(url, "continue", "checkout");
853
- return `${usedLine}${nextLine}${antiTrap}${switchLine} ${DOCS_HINT}`;
869
+ const switchLine = ladder ? ` Or switch plan: ${ladder}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.` : recoverClause(url, "continue", "checkout");
870
+ return `${usedLine}${nextLine}${antiTrap}${switchLine}`;
854
871
  }
855
872
  case "activation_required":
856
- return `Your plan needs activation.${recoverClause(url, "activate", "checkout")} Or call \`activate_plan\` with a \`planRef\`. ${DOCS_HINT}`;
873
+ return `Your plan needs activation.${recoverClause(url, "activate", "checkout")} Or call \`activate_plan\` with a \`planRef\`.`;
857
874
  case "topup_required": {
858
875
  const balance = gate.creditBalance;
859
876
  const cost = gate.creditsPerCall;
860
877
  const shortfall = gate.shortfallCredits;
861
878
  let lead;
862
879
  if (balance !== void 0 && cost !== void 0 && shortfall !== void 0) {
863
- lead = `Out of credits for this call. Balance ${formatCredits(balance)} credits; this call costs ${formatCredits(cost)} credits \u2014 ${formatCredits(shortfall)} short.`;
880
+ lead = `Out of credits for this call. Balance ${formatCreditsWithMoney(balance, gate)}; this call costs ${formatCreditsWithMoney(cost, gate)} \u2014 ${formatCredits(shortfall)} short.`;
864
881
  } else {
865
882
  lead = "Included usage is exhausted.";
866
883
  }
867
884
  const topup = recoverClause(url, "add credits", "topup", "Add credits");
868
- const auto = autoRechargeDisabled(gate) ? " Auto-recharge is off \u2014 turn it on from the account tool to avoid this next time." : "";
869
- const switchLine = ladder ? ` Or switch plan: ${ladder}.` : "";
870
- return `${lead}${topup}${auto}${switchLine} ${DOCS_HINT}`;
885
+ const auto = autoRechargeDisabled(gate) ? ` Auto-recharge is off \u2014 ${callViewer("account")} to turn it on and avoid this next time.` : "";
886
+ const switchLine = ladder ? ` Or switch plan: ${ladder}.` : hasActivePlan(gate) ? "" : ` Or ${callViewer("checkout")} to pick a plan.`;
887
+ return `${lead}${topup}${auto}${switchLine}`;
871
888
  }
872
889
  case "upgrade_required": {
873
890
  if (hasActivePlan(gate)) {
874
891
  const planName = gate.planName ?? "This plan";
875
892
  const lead = `${planName} is active but its included usage is exhausted, and the automatic switch to the next plan did not complete.`;
876
893
  if (ladder) {
877
- return `${lead} Switch here: ${ladder}. ${DOCS_HINT}`;
894
+ return `${lead} Switch here: ${ladder}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.`;
878
895
  }
879
- return `${lead}${recoverClause(url, "switch plan", "checkout")} ${DOCS_HINT}`;
896
+ return `${lead}${recoverClause(url, "switch plan", "checkout")}`;
880
897
  }
881
898
  if (ladder) {
882
- return `You don't have an active plan for this tool. Pick a plan to use this tool: ${ladder} (links expire in ${CHECKOUT_SESSION_TTL_MINUTES} minutes), or ${callViewer("checkout")}. ${DOCS_HINT}`;
899
+ return `You don't have an active plan for this tool. Pick a plan to use this tool: ${ladder} (links expire in ${CHECKOUT_SESSION_TTL_MINUTES} minutes), or ${callViewer("checkout")}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.`;
883
900
  }
884
- return `You don't have an active plan for this tool.${recoverClause(url, "pick a plan", "checkout")} ${DOCS_HINT}`;
901
+ return `You don't have an active plan for this tool.${recoverClause(url, "pick a plan", "checkout")}`;
885
902
  }
886
903
  case "reactivation_required":
887
- return `Your previous plan is no longer active. ${callViewer("account").replace(/^c/, "C")} to reactivate it, or ${callViewer("checkout")} to pick a new plan. ${DOCS_HINT}`;
904
+ return `Your previous plan is no longer active. ${callViewer("account").replace(/^c/, "C")} to reactivate it, or ${callViewer("checkout")} to pick a new plan.`;
888
905
  }
889
906
  }
890
907
  function buildNudgeMessage(state, limits) {
891
908
  const url = limits?.checkoutUrl && limits.checkoutUrl.length > 0 ? limits.checkoutUrl : null;
892
909
  const visitClause = url ? `, or ${namedCheckoutMarkdown(url)}` : "";
893
- const remaining = creditSignals(limits).remainingCalls;
894
- switch (state.kind) {
910
+ const signals = creditSignals(limits);
911
+ const remaining = signals.remainingCalls;
912
+ const kind = state.kind === "topup_required" && limits && !signals.isCreditBased ? "limit_reached" : state.kind;
913
+ switch (kind) {
895
914
  case "topup_required":
896
915
  if (remaining === 0) {
897
916
  return `Heads up \u2014 0 calls left \u2014 the next call needs a top-up. ${callViewer("topup").replace(/^c/, "C")} to add more${visitClause}.`;
@@ -976,6 +995,8 @@ function recoveryFields(limits, state) {
976
995
  ...signals.creditsPerCall !== void 0 ? { creditsPerCall: signals.creditsPerCall } : {},
977
996
  ...signals.shortfallCredits !== void 0 ? { shortfallCredits: signals.shortfallCredits } : {},
978
997
  ...signals.remainingCalls !== void 0 ? { remainingCalls: signals.remainingCalls } : {},
998
+ ...limits.balance?.creditsPerMinorUnit !== void 0 ? { creditsPerMinorUnit: limits.balance.creditsPerMinorUnit } : {},
999
+ ...limits.balance?.displayExchangeRate !== void 0 ? { displayExchangeRate: limits.balance.displayExchangeRate } : {},
979
1000
  ...limits.purchaseRef !== void 0 ? { purchaseRef: limits.purchaseRef } : {},
980
1001
  ...limits.autoRecharge !== void 0 ? { autoRecharge: limits.autoRecharge } : {},
981
1002
  ...links ? { links } : {}
@@ -1232,6 +1253,23 @@ var SolvaPayPaywall = class {
1232
1253
  console.log(...args);
1233
1254
  }
1234
1255
  }
1256
+ /**
1257
+ * Drop cached limits for `customerRef`. When `product` is omitted every
1258
+ * meter/product entry for that customer is removed. Matches both the
1259
+ * backend `cus_…` ref and an unmapped external ref used as a cache key.
1260
+ */
1261
+ invalidateLimits(customerRef, product) {
1262
+ const aliases = /* @__PURE__ */ new Set([customerRef]);
1263
+ const mapped = this.customerRefMapping.get(customerRef);
1264
+ if (mapped) aliases.add(mapped);
1265
+ if (!customerRef.startsWith("cus_")) aliases.add(`cus_${customerRef}`);
1266
+ for (const key of [...this.limitsCache.keys()]) {
1267
+ const [ref, prod] = key.split(":");
1268
+ if (!ref || !aliases.has(ref)) continue;
1269
+ if (product && prod !== product) continue;
1270
+ this.limitsCache.delete(key);
1271
+ }
1272
+ }
1235
1273
  resolveProduct(metadata) {
1236
1274
  return requireProductRef(metadata.product);
1237
1275
  }
@@ -2252,6 +2290,9 @@ function createSolvaPay(config) {
2252
2290
  paywall: {
2253
2291
  decide(args, metadata, getCustomerRef) {
2254
2292
  return paywall.decide(args, metadata, getCustomerRef);
2293
+ },
2294
+ invalidateLimits(customerRef, product) {
2295
+ paywall.invalidateLimits(customerRef, product);
2255
2296
  }
2256
2297
  },
2257
2298
  // Common API methods exposed directly for convenience
@@ -2320,7 +2361,7 @@ function createSolvaPay(config) {
2320
2361
  customerRef: params.customerRef,
2321
2362
  productRef: params.productRef,
2322
2363
  planRef: params.planRef,
2323
- returnUrl: params.returnUrl,
2364
+ ...params.returnUrl ? { returnUrl: params.returnUrl } : {},
2324
2365
  ...params.purpose ? { purpose: params.purpose } : {}
2325
2366
  });
2326
2367
  },
@@ -3066,12 +3107,15 @@ async function createCheckoutSessionCore(request, body, options = {}) {
3066
3107
  return customerResult;
3067
3108
  }
3068
3109
  const customerRef = customerResult;
3069
- let returnUrl = body.returnUrl || options.returnUrl;
3070
- if (!returnUrl) {
3071
- try {
3072
- const url = new URL(request.url);
3073
- returnUrl = url.origin;
3074
- } catch {
3110
+ let returnUrl;
3111
+ if (body.returnUrl !== null) {
3112
+ returnUrl = body.returnUrl || options.returnUrl;
3113
+ if (!returnUrl) {
3114
+ try {
3115
+ const url = new URL(request.url);
3116
+ returnUrl = url.origin;
3117
+ } catch {
3118
+ }
3075
3119
  }
3076
3120
  }
3077
3121
  const solvaPay = options.solvaPay || createSolvaPay();
@@ -3079,7 +3123,7 @@ async function createCheckoutSessionCore(request, body, options = {}) {
3079
3123
  productRef: body.productRef,
3080
3124
  customerRef,
3081
3125
  planRef: body.planRef || void 0,
3082
- returnUrl,
3126
+ ...returnUrl ? { returnUrl } : {},
3083
3127
  ...body.purpose ? { purpose: body.purpose } : {}
3084
3128
  });
3085
3129
  return {
package/dist/index.d.cts CHANGED
@@ -831,6 +831,10 @@ interface components {
831
831
  currency: string;
832
832
  /** @description How many metered items the credit balance still covers (`balance / creditsPerUnit`) */
833
833
  remainingUnits?: number;
834
+ /** @description Credits per minor unit of `currency` (typically 100) */
835
+ creditsPerMinorUnit?: number;
836
+ /** @description USD → display-currency rate used with creditsPerMinorUnit */
837
+ displayExchangeRate?: number;
834
838
  };
835
839
  LimitPlanItemDto: {
836
840
  /** @description Derived billing cycle */
@@ -4309,6 +4313,10 @@ type PaywallGateRecoveryFields = {
4309
4313
  shortfallCredits?: number;
4310
4314
  /** How many calls the current allowance or wallet still covers. */
4311
4315
  remainingCalls?: number;
4316
+ /** Credits per minor currency unit, from the limits balance peg. */
4317
+ creditsPerMinorUnit?: number;
4318
+ /** USD → display-currency rate used with `creditsPerMinorUnit`. */
4319
+ displayExchangeRate?: number;
4312
4320
  /** Active purchase reference when the customer already holds a purchase. */
4313
4321
  purchaseRef?: string;
4314
4322
  /** Purchase/plan status when the backend sent one. */
@@ -5300,6 +5308,12 @@ interface SolvaPay {
5300
5308
  */
5301
5309
  paywall: {
5302
5310
  decide<TArgs extends PaywallArgs>(args: TArgs, metadata?: PaywallMetadata, getCustomerRef?: (args: TArgs) => string): Promise<PaywallDecision<TArgs>>;
5311
+ /**
5312
+ * Drop cached `checkLimits` snapshots for this customer (optionally
5313
+ * scoped to one product). Call after `activate_plan`, a completed
5314
+ * checkout, or a top-up so the next payable call refetches.
5315
+ */
5316
+ invalidateLimits(customerRef: string, product?: string): void;
5303
5317
  };
5304
5318
  /**
5305
5319
  * Direct access to the API client for advanced operations.
@@ -6209,7 +6223,13 @@ declare function processTopupPaymentIntentCore(request: Request, body: {
6209
6223
  declare function createCheckoutSessionCore(request: Request, body: {
6210
6224
  productRef: string;
6211
6225
  planRef?: string;
6212
- returnUrl?: string;
6226
+ /**
6227
+ * URL to send the customer back to after payment. Pass `null` to omit
6228
+ * the field entirely — skips `options.returnUrl` and the request-origin
6229
+ * fallback. Use this when the caller has no browsable page to return to
6230
+ * (MCP servers whose public URL is an API endpoint, not a website).
6231
+ */
6232
+ returnUrl?: string | null;
6213
6233
  purpose?: 'credit_topup';
6214
6234
  }, options?: {
6215
6235
  solvaPay?: SolvaPay;
package/dist/index.d.ts CHANGED
@@ -831,6 +831,10 @@ interface components {
831
831
  currency: string;
832
832
  /** @description How many metered items the credit balance still covers (`balance / creditsPerUnit`) */
833
833
  remainingUnits?: number;
834
+ /** @description Credits per minor unit of `currency` (typically 100) */
835
+ creditsPerMinorUnit?: number;
836
+ /** @description USD → display-currency rate used with creditsPerMinorUnit */
837
+ displayExchangeRate?: number;
834
838
  };
835
839
  LimitPlanItemDto: {
836
840
  /** @description Derived billing cycle */
@@ -4309,6 +4313,10 @@ type PaywallGateRecoveryFields = {
4309
4313
  shortfallCredits?: number;
4310
4314
  /** How many calls the current allowance or wallet still covers. */
4311
4315
  remainingCalls?: number;
4316
+ /** Credits per minor currency unit, from the limits balance peg. */
4317
+ creditsPerMinorUnit?: number;
4318
+ /** USD → display-currency rate used with `creditsPerMinorUnit`. */
4319
+ displayExchangeRate?: number;
4312
4320
  /** Active purchase reference when the customer already holds a purchase. */
4313
4321
  purchaseRef?: string;
4314
4322
  /** Purchase/plan status when the backend sent one. */
@@ -5300,6 +5308,12 @@ interface SolvaPay {
5300
5308
  */
5301
5309
  paywall: {
5302
5310
  decide<TArgs extends PaywallArgs>(args: TArgs, metadata?: PaywallMetadata, getCustomerRef?: (args: TArgs) => string): Promise<PaywallDecision<TArgs>>;
5311
+ /**
5312
+ * Drop cached `checkLimits` snapshots for this customer (optionally
5313
+ * scoped to one product). Call after `activate_plan`, a completed
5314
+ * checkout, or a top-up so the next payable call refetches.
5315
+ */
5316
+ invalidateLimits(customerRef: string, product?: string): void;
5303
5317
  };
5304
5318
  /**
5305
5319
  * Direct access to the API client for advanced operations.
@@ -6209,7 +6223,13 @@ declare function processTopupPaymentIntentCore(request: Request, body: {
6209
6223
  declare function createCheckoutSessionCore(request: Request, body: {
6210
6224
  productRef: string;
6211
6225
  planRef?: string;
6212
- returnUrl?: string;
6226
+ /**
6227
+ * URL to send the customer back to after payment. Pass `null` to omit
6228
+ * the field entirely — skips `options.returnUrl` and the request-origin
6229
+ * fallback. Use this when the caller has no browsable page to return to
6230
+ * (MCP servers whose public URL is an API endpoint, not a website).
6231
+ */
6232
+ returnUrl?: string | null;
6213
6233
  purpose?: 'credit_topup';
6214
6234
  }, options?: {
6215
6235
  solvaPay?: SolvaPay;
package/dist/index.js CHANGED
@@ -635,9 +635,8 @@ function createSolvaPayClient(opts) {
635
635
  }
636
636
 
637
637
  // src/paywall-state.ts
638
- import { minorUnitsPerMajor } from "@solvapay/core";
638
+ import { creditsToDisplayMinorUnits, minorUnitsPerMajor } from "@solvapay/core";
639
639
  var CHECKOUT_SESSION_TTL_MINUTES = 15;
640
- var DOCS_HINT = "See docs://solvapay/overview.md.";
641
640
  function creditSignals(limits) {
642
641
  if (!limits) return { isCreditBased: false };
643
642
  const creditBalance = limits.balance?.creditBalance ?? limits.creditBalance;
@@ -721,6 +720,24 @@ function formatMinor(amountMinor, currency) {
721
720
  function formatCredits(value) {
722
721
  return value.toLocaleString("en-US");
723
722
  }
723
+ function formatCreditsWithMoney(credits, gate) {
724
+ const amount = formatCredits(credits);
725
+ const peg = gate.creditsPerMinorUnit;
726
+ const currency = gate.currency;
727
+ const rate = gate.displayExchangeRate;
728
+ if (peg && peg > 0 && currency && rate != null && rate > 0) {
729
+ const minor = creditsToDisplayMinorUnits({
730
+ credits,
731
+ creditsPerMinorUnit: peg,
732
+ displayExchangeRate: rate,
733
+ displayCurrency: currency
734
+ });
735
+ if (minor != null) {
736
+ return `${amount} credits (~${formatMinor(minor, currency)})`;
737
+ }
738
+ }
739
+ return `${amount} credits`;
740
+ }
724
741
  function meterLabel(gate) {
725
742
  if (!gate.meterName) return "units";
726
743
  return gate.meterName.replace(/_/g, " ");
@@ -764,49 +781,51 @@ function buildGateMessage(state, gate) {
764
781
  const nextLine = price ? ` The next call is ${price}.` : "";
765
782
  const planName = gate.planName ?? "This plan";
766
783
  const antiTrap = gate.creditBalance !== void 0 && gate.creditBalance > 0 && (gate.creditsPerCall === void 0 || gate.creditsPerCall === 0) ? ` Adding credits will not help, because ${planName} does not spend them.` : "";
767
- const switchLine = ladder ? ` Or switch plan: ${ladder}.` : recoverClause(url, "continue", "checkout");
768
- return `${usedLine}${nextLine}${antiTrap}${switchLine} ${DOCS_HINT}`;
784
+ const switchLine = ladder ? ` Or switch plan: ${ladder}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.` : recoverClause(url, "continue", "checkout");
785
+ return `${usedLine}${nextLine}${antiTrap}${switchLine}`;
769
786
  }
770
787
  case "activation_required":
771
- return `Your plan needs activation.${recoverClause(url, "activate", "checkout")} Or call \`activate_plan\` with a \`planRef\`. ${DOCS_HINT}`;
788
+ return `Your plan needs activation.${recoverClause(url, "activate", "checkout")} Or call \`activate_plan\` with a \`planRef\`.`;
772
789
  case "topup_required": {
773
790
  const balance = gate.creditBalance;
774
791
  const cost = gate.creditsPerCall;
775
792
  const shortfall = gate.shortfallCredits;
776
793
  let lead;
777
794
  if (balance !== void 0 && cost !== void 0 && shortfall !== void 0) {
778
- lead = `Out of credits for this call. Balance ${formatCredits(balance)} credits; this call costs ${formatCredits(cost)} credits \u2014 ${formatCredits(shortfall)} short.`;
795
+ lead = `Out of credits for this call. Balance ${formatCreditsWithMoney(balance, gate)}; this call costs ${formatCreditsWithMoney(cost, gate)} \u2014 ${formatCredits(shortfall)} short.`;
779
796
  } else {
780
797
  lead = "Included usage is exhausted.";
781
798
  }
782
799
  const topup = recoverClause(url, "add credits", "topup", "Add credits");
783
- const auto = autoRechargeDisabled(gate) ? " Auto-recharge is off \u2014 turn it on from the account tool to avoid this next time." : "";
784
- const switchLine = ladder ? ` Or switch plan: ${ladder}.` : "";
785
- return `${lead}${topup}${auto}${switchLine} ${DOCS_HINT}`;
800
+ const auto = autoRechargeDisabled(gate) ? ` Auto-recharge is off \u2014 ${callViewer("account")} to turn it on and avoid this next time.` : "";
801
+ const switchLine = ladder ? ` Or switch plan: ${ladder}.` : hasActivePlan(gate) ? "" : ` Or ${callViewer("checkout")} to pick a plan.`;
802
+ return `${lead}${topup}${auto}${switchLine}`;
786
803
  }
787
804
  case "upgrade_required": {
788
805
  if (hasActivePlan(gate)) {
789
806
  const planName = gate.planName ?? "This plan";
790
807
  const lead = `${planName} is active but its included usage is exhausted, and the automatic switch to the next plan did not complete.`;
791
808
  if (ladder) {
792
- return `${lead} Switch here: ${ladder}. ${DOCS_HINT}`;
809
+ return `${lead} Switch here: ${ladder}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.`;
793
810
  }
794
- return `${lead}${recoverClause(url, "switch plan", "checkout")} ${DOCS_HINT}`;
811
+ return `${lead}${recoverClause(url, "switch plan", "checkout")}`;
795
812
  }
796
813
  if (ladder) {
797
- return `You don't have an active plan for this tool. Pick a plan to use this tool: ${ladder} (links expire in ${CHECKOUT_SESSION_TTL_MINUTES} minutes), or ${callViewer("checkout")}. ${DOCS_HINT}`;
814
+ return `You don't have an active plan for this tool. Pick a plan to use this tool: ${ladder} (links expire in ${CHECKOUT_SESSION_TTL_MINUTES} minutes), or ${callViewer("checkout")}. ${callViewer("account").replace(/^c/, "C")} for usage and recovery.`;
798
815
  }
799
- return `You don't have an active plan for this tool.${recoverClause(url, "pick a plan", "checkout")} ${DOCS_HINT}`;
816
+ return `You don't have an active plan for this tool.${recoverClause(url, "pick a plan", "checkout")}`;
800
817
  }
801
818
  case "reactivation_required":
802
- return `Your previous plan is no longer active. ${callViewer("account").replace(/^c/, "C")} to reactivate it, or ${callViewer("checkout")} to pick a new plan. ${DOCS_HINT}`;
819
+ return `Your previous plan is no longer active. ${callViewer("account").replace(/^c/, "C")} to reactivate it, or ${callViewer("checkout")} to pick a new plan.`;
803
820
  }
804
821
  }
805
822
  function buildNudgeMessage(state, limits) {
806
823
  const url = limits?.checkoutUrl && limits.checkoutUrl.length > 0 ? limits.checkoutUrl : null;
807
824
  const visitClause = url ? `, or ${namedCheckoutMarkdown(url)}` : "";
808
- const remaining = creditSignals(limits).remainingCalls;
809
- switch (state.kind) {
825
+ const signals = creditSignals(limits);
826
+ const remaining = signals.remainingCalls;
827
+ const kind = state.kind === "topup_required" && limits && !signals.isCreditBased ? "limit_reached" : state.kind;
828
+ switch (kind) {
810
829
  case "topup_required":
811
830
  if (remaining === 0) {
812
831
  return `Heads up \u2014 0 calls left \u2014 the next call needs a top-up. ${callViewer("topup").replace(/^c/, "C")} to add more${visitClause}.`;
@@ -891,6 +910,8 @@ function recoveryFields(limits, state) {
891
910
  ...signals.creditsPerCall !== void 0 ? { creditsPerCall: signals.creditsPerCall } : {},
892
911
  ...signals.shortfallCredits !== void 0 ? { shortfallCredits: signals.shortfallCredits } : {},
893
912
  ...signals.remainingCalls !== void 0 ? { remainingCalls: signals.remainingCalls } : {},
913
+ ...limits.balance?.creditsPerMinorUnit !== void 0 ? { creditsPerMinorUnit: limits.balance.creditsPerMinorUnit } : {},
914
+ ...limits.balance?.displayExchangeRate !== void 0 ? { displayExchangeRate: limits.balance.displayExchangeRate } : {},
894
915
  ...limits.purchaseRef !== void 0 ? { purchaseRef: limits.purchaseRef } : {},
895
916
  ...limits.autoRecharge !== void 0 ? { autoRecharge: limits.autoRecharge } : {},
896
917
  ...links ? { links } : {}
@@ -1147,6 +1168,23 @@ var SolvaPayPaywall = class {
1147
1168
  console.log(...args);
1148
1169
  }
1149
1170
  }
1171
+ /**
1172
+ * Drop cached limits for `customerRef`. When `product` is omitted every
1173
+ * meter/product entry for that customer is removed. Matches both the
1174
+ * backend `cus_…` ref and an unmapped external ref used as a cache key.
1175
+ */
1176
+ invalidateLimits(customerRef, product) {
1177
+ const aliases = /* @__PURE__ */ new Set([customerRef]);
1178
+ const mapped = this.customerRefMapping.get(customerRef);
1179
+ if (mapped) aliases.add(mapped);
1180
+ if (!customerRef.startsWith("cus_")) aliases.add(`cus_${customerRef}`);
1181
+ for (const key of [...this.limitsCache.keys()]) {
1182
+ const [ref, prod] = key.split(":");
1183
+ if (!ref || !aliases.has(ref)) continue;
1184
+ if (product && prod !== product) continue;
1185
+ this.limitsCache.delete(key);
1186
+ }
1187
+ }
1150
1188
  resolveProduct(metadata) {
1151
1189
  return requireProductRef(metadata.product);
1152
1190
  }
@@ -2167,6 +2205,9 @@ function createSolvaPay(config) {
2167
2205
  paywall: {
2168
2206
  decide(args, metadata, getCustomerRef) {
2169
2207
  return paywall.decide(args, metadata, getCustomerRef);
2208
+ },
2209
+ invalidateLimits(customerRef, product) {
2210
+ paywall.invalidateLimits(customerRef, product);
2170
2211
  }
2171
2212
  },
2172
2213
  // Common API methods exposed directly for convenience
@@ -2235,7 +2276,7 @@ function createSolvaPay(config) {
2235
2276
  customerRef: params.customerRef,
2236
2277
  productRef: params.productRef,
2237
2278
  planRef: params.planRef,
2238
- returnUrl: params.returnUrl,
2279
+ ...params.returnUrl ? { returnUrl: params.returnUrl } : {},
2239
2280
  ...params.purpose ? { purpose: params.purpose } : {}
2240
2281
  });
2241
2282
  },
@@ -2983,12 +3024,15 @@ async function createCheckoutSessionCore(request, body, options = {}) {
2983
3024
  return customerResult;
2984
3025
  }
2985
3026
  const customerRef = customerResult;
2986
- let returnUrl = body.returnUrl || options.returnUrl;
2987
- if (!returnUrl) {
2988
- try {
2989
- const url = new URL(request.url);
2990
- returnUrl = url.origin;
2991
- } catch {
3027
+ let returnUrl;
3028
+ if (body.returnUrl !== null) {
3029
+ returnUrl = body.returnUrl || options.returnUrl;
3030
+ if (!returnUrl) {
3031
+ try {
3032
+ const url = new URL(request.url);
3033
+ returnUrl = url.origin;
3034
+ } catch {
3035
+ }
2992
3036
  }
2993
3037
  }
2994
3038
  const solvaPay = options.solvaPay || createSolvaPay();
@@ -2996,7 +3040,7 @@ async function createCheckoutSessionCore(request, body, options = {}) {
2996
3040
  productRef: body.productRef,
2997
3041
  customerRef,
2998
3042
  planRef: body.planRef || void 0,
2999
- returnUrl,
3043
+ ...returnUrl ? { returnUrl } : {},
3000
3044
  ...body.purpose ? { purpose: body.purpose } : {}
3001
3045
  });
3002
3046
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solvapay/server",
3
- "version": "2.5.0",
3
+ "version": "2.6.0-preview-09731417a0a7b4fd3afb4783fdf20b5ad293f63d",
4
4
  "type": "module",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -70,8 +70,8 @@
70
70
  "build": "tsup",
71
71
  "dev": "tsup --watch",
72
72
  "generate:types": "tsx scripts/generate-types.ts",
73
- "test": "vitest run __tests__/paywall.unit.test.ts __tests__/paywall-state.unit.test.ts __tests__/paywall-gate.unit.test.ts __tests__/payable-gate.unit.test.ts __tests__/bootstrap-mcp.unit.test.ts __tests__/verify-webhook.unit.test.ts __tests__/edge-exports.unit.test.ts __tests__/credits-usage.unit.test.ts __tests__/limit-outcome-flags.unit.test.ts __tests__/client-error.unit.test.ts src/__tests__/edge-exports.test.ts src/helpers/payment.test.ts src/helpers/usage.test.ts src/resolve-product-ref.test.ts src/verify-product-configuration.test.ts __tests__/auth-core.unit.test.ts __tests__/ensure-customer.unit.test.ts __tests__/create-customer.unit.test.ts __tests__/fetch/cors.test.ts __tests__/fetch/handlers.test.ts __tests__/fetch/utils.test.ts",
74
- "test:unit": "vitest run __tests__/paywall.unit.test.ts __tests__/paywall-state.unit.test.ts __tests__/paywall-gate.unit.test.ts __tests__/payable-gate.unit.test.ts __tests__/bootstrap-mcp.unit.test.ts __tests__/verify-webhook.unit.test.ts __tests__/edge-exports.unit.test.ts __tests__/credits-usage.unit.test.ts __tests__/limit-outcome-flags.unit.test.ts __tests__/client-error.unit.test.ts src/__tests__/edge-exports.test.ts src/helpers/payment.test.ts src/helpers/usage.test.ts src/resolve-product-ref.test.ts src/verify-product-configuration.test.ts __tests__/auth-core.unit.test.ts __tests__/ensure-customer.unit.test.ts __tests__/create-customer.unit.test.ts __tests__/fetch/cors.test.ts __tests__/fetch/handlers.test.ts __tests__/fetch/utils.test.ts",
73
+ "test": "vitest run __tests__/paywall.unit.test.ts __tests__/paywall-state.unit.test.ts __tests__/paywall-gate.unit.test.ts __tests__/payable-gate.unit.test.ts __tests__/bootstrap-mcp.unit.test.ts __tests__/verify-webhook.unit.test.ts __tests__/edge-exports.unit.test.ts __tests__/credits-usage.unit.test.ts __tests__/limit-outcome-flags.unit.test.ts __tests__/client-error.unit.test.ts __tests__/checkout-core.unit.test.ts src/__tests__/edge-exports.test.ts src/helpers/payment.test.ts src/helpers/usage.test.ts src/resolve-product-ref.test.ts src/verify-product-configuration.test.ts __tests__/auth-core.unit.test.ts __tests__/ensure-customer.unit.test.ts __tests__/create-customer.unit.test.ts __tests__/fetch/cors.test.ts __tests__/fetch/handlers.test.ts __tests__/fetch/utils.test.ts",
74
+ "test:unit": "vitest run __tests__/paywall.unit.test.ts __tests__/paywall-state.unit.test.ts __tests__/paywall-gate.unit.test.ts __tests__/payable-gate.unit.test.ts __tests__/bootstrap-mcp.unit.test.ts __tests__/verify-webhook.unit.test.ts __tests__/edge-exports.unit.test.ts __tests__/credits-usage.unit.test.ts __tests__/limit-outcome-flags.unit.test.ts __tests__/client-error.unit.test.ts __tests__/checkout-core.unit.test.ts src/__tests__/edge-exports.test.ts src/helpers/payment.test.ts src/helpers/usage.test.ts src/resolve-product-ref.test.ts src/verify-product-configuration.test.ts __tests__/auth-core.unit.test.ts __tests__/ensure-customer.unit.test.ts __tests__/create-customer.unit.test.ts __tests__/fetch/cors.test.ts __tests__/fetch/handlers.test.ts __tests__/fetch/utils.test.ts",
75
75
  "test:integration": "vitest run --no-file-parallelism __tests__/backend.integration.test.ts __tests__/customer-update.integration.test.ts __tests__/multi-currency-plans.integration.test.ts",
76
76
  "test:integration:backend": "vitest run __tests__/backend.integration.test.ts",
77
77
  "test:integration:customer": "vitest run __tests__/customer-update.integration.test.ts",