@nehorai/credits 3.0.0 → 3.1.0

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.
@@ -631,17 +631,25 @@ var DEFAULT_CONFIG = {
631
631
  }
632
632
  };
633
633
  var currentConfig = DEFAULT_CONFIG;
634
+ function isOperationCostMap(value) {
635
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
636
+ return Object.values(value).every((cost) => isRepresentableAmount(cost) && cost >= 0);
637
+ }
634
638
  function loadConfigFromEnv() {
635
639
  const envConfig = {};
636
640
  const operationCostsEnv = process.env.CREDITS_OPERATION_COSTS;
637
641
  if (operationCostsEnv) {
638
642
  try {
639
643
  const parsed = JSON.parse(operationCostsEnv);
640
- if (typeof parsed === "object" && parsed !== null) {
644
+ if (isOperationCostMap(parsed)) {
641
645
  envConfig.operationCosts = {
642
646
  ...DEFAULT_CONFIG.operationCosts,
643
647
  ...parsed
644
648
  };
649
+ } else {
650
+ console.warn(
651
+ "[Credits Config] Ignoring CREDITS_OPERATION_COSTS: it must be a JSON object whose costs are numbers >= 0, exact to the cent"
652
+ );
645
653
  }
646
654
  } catch {
647
655
  console.warn("[Credits Config] Failed to parse CREDITS_OPERATION_COSTS env var as JSON");
@@ -986,6 +994,50 @@ function assertValidBalanceUpdate(current, updates) {
986
994
  throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
987
995
  }
988
996
  }
997
+ function signupGrantJournalInput(userId, tier, grant) {
998
+ if (grant <= 0) return null;
999
+ return {
1000
+ userId,
1001
+ entryType: "credit",
1002
+ amount: grant,
1003
+ balanceBefore: 0,
1004
+ balanceAfter: grant,
1005
+ source: "bonus",
1006
+ referenceId: `signup-${userId}`,
1007
+ referenceType: "adjustment",
1008
+ description: `Signup grant for ${tier} tier.`,
1009
+ metadata: { tier }
1010
+ };
1011
+ }
1012
+ function tierChangeJournalInput(input) {
1013
+ const { userId, previousTier, newTier, previousBalance, newBalance, bonusCredits } = input;
1014
+ const change = sumAmounts(newBalance, -previousBalance);
1015
+ if (change === 0) return null;
1016
+ const raised = change > 0;
1017
+ const amount = raised ? newBalance : 0;
1018
+ const balanceBefore = sumAmounts(previousBalance, bonusCredits);
1019
+ const balanceAfter = sumAmounts(newBalance, bonusCredits);
1020
+ assertRepresentableFields(
1021
+ {
1022
+ "journal amount": amount,
1023
+ "journal balanceBefore": balanceBefore,
1024
+ "journal balanceAfter": balanceAfter
1025
+ },
1026
+ { userId, operation: "updateUserTier" }
1027
+ );
1028
+ return {
1029
+ userId,
1030
+ entryType: raised ? "credit" : "debit",
1031
+ amount,
1032
+ balanceBefore,
1033
+ balanceAfter,
1034
+ source: raised ? "subscription_upgrade" : "subscription_downgrade",
1035
+ referenceId: `tier-${Date.now()}`,
1036
+ referenceType: "subscription",
1037
+ description: `Tier changed from ${previousTier} to ${newTier}.`,
1038
+ metadata: { previousTier, newTier, previousBalance, newBalance }
1039
+ };
1040
+ }
989
1041
 
990
1042
  // src/repository/memory/snapshot.ts
991
1043
  function copyRecord(record) {
@@ -1630,6 +1682,8 @@ var InMemoryCreditRepository = class {
1630
1682
  updatedAt: now
1631
1683
  };
1632
1684
  this.store.users.set(userId, credits);
1685
+ const grantLine = signupGrantJournalInput(userId, tier, initialBalance);
1686
+ if (grantLine) this.recordJournalEntry(grantLine);
1633
1687
  return credits;
1634
1688
  }
1635
1689
  async updateUserCredits(userId, updates) {
@@ -1710,21 +1764,29 @@ var InMemoryCreditRepository = class {
1710
1764
  if (!credits) {
1711
1765
  throw new Error(`User ${userId} not found`);
1712
1766
  }
1713
- if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1714
- const capped = planBalanceCap(
1767
+ let planned;
1768
+ if (input.balance !== void 0) {
1769
+ planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1770
+ } else if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1771
+ planned = planBalanceCap(
1715
1772
  credits.balance,
1716
1773
  input.balanceCap,
1717
1774
  credits.reserved,
1718
1775
  credits.bonusCredits,
1719
1776
  credits.expiredHoldBacking
1720
1777
  );
1721
- credits.balance = capped.balance;
1722
- writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
1723
1778
  }
1779
+ const journalLine = planned ? tierChangeJournalInput({
1780
+ userId,
1781
+ previousTier: credits.tier,
1782
+ newTier: input.tier,
1783
+ previousBalance: credits.balance,
1784
+ newBalance: planned.balance,
1785
+ bonusCredits: credits.bonusCredits
1786
+ }) : null;
1724
1787
  credits.tier = input.tier;
1725
1788
  credits.monthlyLimit = input.monthlyLimit;
1726
- if (input.balance !== void 0) {
1727
- const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1789
+ if (planned) {
1728
1790
  credits.balance = planned.balance;
1729
1791
  writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1730
1792
  }
@@ -1743,6 +1805,7 @@ var InMemoryCreditRepository = class {
1743
1805
  }
1744
1806
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1745
1807
  this.store.users.set(userId, credits);
1808
+ if (journalLine) this.recordJournalEntry(journalLine);
1746
1809
  }
1747
1810
  // ==================== Reservations ====================
1748
1811
  async createReservation(input) {
@@ -2364,7 +2427,7 @@ var InMemoryCreditRepository = class {
2364
2427
  userId,
2365
2428
  entryType: "debit",
2366
2429
  amount: 0,
2367
- // No credits deducted, just tier change
2430
+ // 0, however far the cap lowered the balance: the totals record the move
2368
2431
  balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2369
2432
  balanceAfter,
2370
2433
  source: "subscription_downgrade",