@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.
package/dist/index.cjs CHANGED
@@ -169,6 +169,7 @@ __export(src_exports, {
169
169
  resetConfig: () => resetConfig,
170
170
  sameAmount: () => sameAmount,
171
171
  settleExpiredHoldBacking: () => settleExpiredHoldBacking,
172
+ signupGrantJournalInput: () => signupGrantJournalInput,
172
173
  spendableBalance: () => spendableBalance,
173
174
  storedAmountCeil: () => storedAmountCeil,
174
175
  storedAmountFloor: () => storedAmountFloor,
@@ -178,6 +179,7 @@ __export(src_exports, {
178
179
  supportsIdempotentCredit: () => supportsIdempotentCredit,
179
180
  synchronousDeferred: () => synchronousDeferred,
180
181
  terminalStatusOf: () => terminalStatusOf,
182
+ tierChangeJournalInput: () => tierChangeJournalInput,
181
183
  tierSchema: () => tierSchema,
182
184
  toCents: () => toCents,
183
185
  toClientUserCredits: () => toClientUserCredits,
@@ -1104,17 +1106,25 @@ var DEFAULT_CONFIG = {
1104
1106
  }
1105
1107
  };
1106
1108
  var currentConfig = DEFAULT_CONFIG;
1109
+ function isOperationCostMap(value) {
1110
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1111
+ return Object.values(value).every((cost) => isRepresentableAmount(cost) && cost >= 0);
1112
+ }
1107
1113
  function loadConfigFromEnv() {
1108
1114
  const envConfig = {};
1109
1115
  const operationCostsEnv = process.env.CREDITS_OPERATION_COSTS;
1110
1116
  if (operationCostsEnv) {
1111
1117
  try {
1112
1118
  const parsed = JSON.parse(operationCostsEnv);
1113
- if (typeof parsed === "object" && parsed !== null) {
1119
+ if (isOperationCostMap(parsed)) {
1114
1120
  envConfig.operationCosts = {
1115
1121
  ...DEFAULT_CONFIG.operationCosts,
1116
1122
  ...parsed
1117
1123
  };
1124
+ } else {
1125
+ console.warn(
1126
+ "[Credits Config] Ignoring CREDITS_OPERATION_COSTS: it must be a JSON object whose costs are numbers >= 0, exact to the cent"
1127
+ );
1118
1128
  }
1119
1129
  } catch {
1120
1130
  console.warn("[Credits Config] Failed to parse CREDITS_OPERATION_COSTS env var as JSON");
@@ -1562,6 +1572,50 @@ function assertValidBalanceUpdate(current, updates) {
1562
1572
  throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
1563
1573
  }
1564
1574
  }
1575
+ function signupGrantJournalInput(userId, tier, grant) {
1576
+ if (grant <= 0) return null;
1577
+ return {
1578
+ userId,
1579
+ entryType: "credit",
1580
+ amount: grant,
1581
+ balanceBefore: 0,
1582
+ balanceAfter: grant,
1583
+ source: "bonus",
1584
+ referenceId: `signup-${userId}`,
1585
+ referenceType: "adjustment",
1586
+ description: `Signup grant for ${tier} tier.`,
1587
+ metadata: { tier }
1588
+ };
1589
+ }
1590
+ function tierChangeJournalInput(input) {
1591
+ const { userId, previousTier, newTier, previousBalance, newBalance, bonusCredits } = input;
1592
+ const change = sumAmounts(newBalance, -previousBalance);
1593
+ if (change === 0) return null;
1594
+ const raised = change > 0;
1595
+ const amount = raised ? newBalance : 0;
1596
+ const balanceBefore = sumAmounts(previousBalance, bonusCredits);
1597
+ const balanceAfter = sumAmounts(newBalance, bonusCredits);
1598
+ assertRepresentableFields(
1599
+ {
1600
+ "journal amount": amount,
1601
+ "journal balanceBefore": balanceBefore,
1602
+ "journal balanceAfter": balanceAfter
1603
+ },
1604
+ { userId, operation: "updateUserTier" }
1605
+ );
1606
+ return {
1607
+ userId,
1608
+ entryType: raised ? "credit" : "debit",
1609
+ amount,
1610
+ balanceBefore,
1611
+ balanceAfter,
1612
+ source: raised ? "subscription_upgrade" : "subscription_downgrade",
1613
+ referenceId: `tier-${Date.now()}`,
1614
+ referenceType: "subscription",
1615
+ description: `Tier changed from ${previousTier} to ${newTier}.`,
1616
+ metadata: { previousTier, newTier, previousBalance, newBalance }
1617
+ };
1618
+ }
1565
1619
 
1566
1620
  // src/repository/memory/snapshot.ts
1567
1621
  function copyRecord(record) {
@@ -2105,6 +2159,8 @@ var InMemoryCreditRepository = class {
2105
2159
  updatedAt: now
2106
2160
  };
2107
2161
  this.store.users.set(userId, credits);
2162
+ const grantLine = signupGrantJournalInput(userId, tier, initialBalance);
2163
+ if (grantLine) this.recordJournalEntry(grantLine);
2108
2164
  return credits;
2109
2165
  }
2110
2166
  async updateUserCredits(userId, updates) {
@@ -2185,21 +2241,29 @@ var InMemoryCreditRepository = class {
2185
2241
  if (!credits) {
2186
2242
  throw new Error(`User ${userId} not found`);
2187
2243
  }
2188
- if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
2189
- const capped = planBalanceCap(
2244
+ let planned;
2245
+ if (input.balance !== void 0) {
2246
+ planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
2247
+ } else if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
2248
+ planned = planBalanceCap(
2190
2249
  credits.balance,
2191
2250
  input.balanceCap,
2192
2251
  credits.reserved,
2193
2252
  credits.bonusCredits,
2194
2253
  credits.expiredHoldBacking
2195
2254
  );
2196
- credits.balance = capped.balance;
2197
- writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
2198
2255
  }
2256
+ const journalLine = planned ? tierChangeJournalInput({
2257
+ userId,
2258
+ previousTier: credits.tier,
2259
+ newTier: input.tier,
2260
+ previousBalance: credits.balance,
2261
+ newBalance: planned.balance,
2262
+ bonusCredits: credits.bonusCredits
2263
+ }) : null;
2199
2264
  credits.tier = input.tier;
2200
2265
  credits.monthlyLimit = input.monthlyLimit;
2201
- if (input.balance !== void 0) {
2202
- const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
2266
+ if (planned) {
2203
2267
  credits.balance = planned.balance;
2204
2268
  writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2205
2269
  }
@@ -2218,6 +2282,7 @@ var InMemoryCreditRepository = class {
2218
2282
  }
2219
2283
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2220
2284
  this.store.users.set(userId, credits);
2285
+ if (journalLine) this.recordJournalEntry(journalLine);
2221
2286
  }
2222
2287
  // ==================== Reservations ====================
2223
2288
  async createReservation(input) {
@@ -2839,7 +2904,7 @@ var InMemoryCreditRepository = class {
2839
2904
  userId,
2840
2905
  entryType: "debit",
2841
2906
  amount: 0,
2842
- // No credits deducted, just tier change
2907
+ // 0, however far the cap lowered the balance: the totals record the move
2843
2908
  balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2844
2909
  balanceAfter,
2845
2910
  source: "subscription_downgrade",
@@ -3003,7 +3068,7 @@ var CreditsService = class {
3003
3068
  userId,
3004
3069
  entryType: "debit",
3005
3070
  amount: 0,
3006
- // No credits deducted, just tier change
3071
+ // 0, however far the cap lowered the balance: the totals record the move
3007
3072
  balanceBefore: sumAmounts(data.balance, data.bonusCredits),
3008
3073
  // The ledger balance, matching every other journal writer: the V2
3009
3074
  // transitions and add/deduct all record `balance + bonusCredits`, so
@@ -3395,7 +3460,8 @@ var CreditsService = class {
3395
3460
  ]);
3396
3461
  const historyEntries = entries.map((entry) => {
3397
3462
  const type = this.mapSourceToHistoryType(entry.source);
3398
- const creditsChange = entry.entryType === "credit" ? entry.amount : -entry.amount;
3463
+ const signedAmount = entry.entryType === "credit" ? entry.amount : -entry.amount;
3464
+ const creditsChange = entry.balanceBefore !== void 0 ? sumAmounts(entry.balanceAfter, -entry.balanceBefore) : signedAmount;
3399
3465
  return {
3400
3466
  id: entry.id,
3401
3467
  type,
@@ -4468,6 +4534,7 @@ function createDummyReservation2(userId, operationType) {
4468
4534
  resetConfig,
4469
4535
  sameAmount,
4470
4536
  settleExpiredHoldBacking,
4537
+ signupGrantJournalInput,
4471
4538
  spendableBalance,
4472
4539
  storedAmountCeil,
4473
4540
  storedAmountFloor,
@@ -4477,6 +4544,7 @@ function createDummyReservation2(userId, operationType) {
4477
4544
  supportsIdempotentCredit,
4478
4545
  synchronousDeferred,
4479
4546
  terminalStatusOf,
4547
+ tierChangeJournalInput,
4480
4548
  tierSchema,
4481
4549
  toCents,
4482
4550
  toClientUserCredits,