@nehorai/credits 2.1.0 → 2.2.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/index.cjs +2 -2
  3. package/dist/adapters/index.cjs.map +1 -1
  4. package/dist/adapters/index.d.cts +2 -2
  5. package/dist/adapters/index.d.ts +2 -2
  6. package/dist/adapters/index.js +2 -2
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/core/index.cjs +102 -2
  9. package/dist/core/index.cjs.map +1 -1
  10. package/dist/core/index.d.cts +92 -5
  11. package/dist/core/index.d.ts +92 -5
  12. package/dist/core/index.js +92 -2
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/index.cjs +272 -39
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +6 -6
  17. package/dist/index.d.ts +6 -6
  18. package/dist/index.js +260 -39
  19. package/dist/index.js.map +1 -1
  20. package/dist/repository/index.cjs +247 -39
  21. package/dist/repository/index.cjs.map +1 -1
  22. package/dist/repository/index.d.cts +34 -6
  23. package/dist/repository/index.d.ts +34 -6
  24. package/dist/repository/index.js +244 -38
  25. package/dist/repository/index.js.map +1 -1
  26. package/dist/sdk/index.cjs.map +1 -1
  27. package/dist/sdk/index.d.cts +2 -2
  28. package/dist/sdk/index.d.ts +2 -2
  29. package/dist/sdk/index.js.map +1 -1
  30. package/dist/service/index.cjs +8 -4
  31. package/dist/service/index.cjs.map +1 -1
  32. package/dist/service/index.d.cts +2 -2
  33. package/dist/service/index.d.ts +2 -2
  34. package/dist/service/index.js +8 -4
  35. package/dist/service/index.js.map +1 -1
  36. package/dist/{types-BhCdep_t.d.ts → types-3c-IAwZI.d.ts} +38 -3
  37. package/dist/{types-Dzc443vy.d.cts → types-MaViK5tD.d.cts} +18 -0
  38. package/dist/{types-Dzc443vy.d.ts → types-MaViK5tD.d.ts} +18 -0
  39. package/dist/{types-wtksMa4n.d.cts → types-XSjNXO5l.d.cts} +38 -3
  40. package/package.json +70 -71
@@ -22,6 +22,7 @@ var repository_exports = {};
22
22
  __export(repository_exports, {
23
23
  InMemoryCreditRepository: () => InMemoryCreditRepository,
24
24
  addCreditsThroughRepository: () => addCreditsThroughRepository,
25
+ assertValidBalanceUpdate: () => assertValidBalanceUpdate,
25
26
  commitThroughRepository: () => commitThroughRepository,
26
27
  createInMemoryCreditRepository: () => createInMemoryCreditRepository,
27
28
  generateId: () => generateId,
@@ -32,7 +33,8 @@ __export(repository_exports, {
32
33
  supportsCreditsV2: () => supportsCreditsV2,
33
34
  supportsIdempotentCredit: () => supportsIdempotentCredit,
34
35
  toClientUserCredits: () => toClientUserCredits,
35
- toDate: () => toDate
36
+ toDate: () => toDate,
37
+ validateBalanceUpdate: () => validateBalanceUpdate
36
38
  });
37
39
  module.exports = __toCommonJS(repository_exports);
38
40
 
@@ -54,6 +56,8 @@ function toClientUserCredits(credits) {
54
56
  monthlyUsed: credits.monthlyUsed,
55
57
  monthlyResetAt: toISOString(credits.monthlyResetAt),
56
58
  subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
59
+ ...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
60
+ ...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
57
61
  createdAt: toISOString(credits.createdAt),
58
62
  updatedAt: toISOString(credits.updatedAt)
59
63
  };
@@ -144,6 +148,83 @@ function backedBalanceFloor(reserved, bonusCredits) {
144
148
  assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
145
149
  return floor;
146
150
  }
151
+ function spendableBalance(balance, expiredHoldBacking = 0) {
152
+ return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
153
+ }
154
+ function planBalanceTarget(target, reserved, bonusCredits) {
155
+ const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
156
+ return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
157
+ }
158
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
159
+ const spendable = spendableBalance(balance, expiredHoldBacking);
160
+ const target = Math.min(spendable, cap);
161
+ const kept = Math.max(
162
+ sumAmounts(balance, -spendable),
163
+ sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
164
+ );
165
+ const next = Math.min(balance, sumAmounts(target, kept));
166
+ return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
167
+ }
168
+ function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
169
+ const held = Math.min(expiredHoldBacking, balance);
170
+ const used = Math.min(held, holdBacking);
171
+ return { used, remaining: sumAmounts(held, -used) };
172
+ }
173
+ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
174
+ const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
175
+ const rest = sumAmounts(amount, -backing.used);
176
+ const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
177
+ const fromBonus = sumAmounts(rest, -fromSpendable);
178
+ return {
179
+ covered: fromBonus <= bonusCredits,
180
+ balance: sumAmounts(balance, -backing.used, -fromSpendable),
181
+ bonusCredits: sumAmounts(bonusCredits, -fromBonus),
182
+ expiredHoldBacking: backing.remaining
183
+ };
184
+ }
185
+ function attributeExpiredBacking(holds, total) {
186
+ const placed = holds.filter((hold) => hold.holdPlacedAt).sort(
187
+ (a, b) => a.holdPlacedAt === b.holdPlacedAt ? a.id < b.id ? -1 : 1 : a.holdPlacedAt < b.holdPlacedAt ? -1 : 1
188
+ );
189
+ const shares = /* @__PURE__ */ new Map();
190
+ let left = total;
191
+ for (const hold of placed) {
192
+ const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);
193
+ shares.set(hold.id, kept);
194
+ left = sumAmounts(left, -kept);
195
+ }
196
+ for (const hold of placed) {
197
+ const share = shares.get(hold.id);
198
+ const added = Math.min(sumAmounts(hold.amount, -share), left);
199
+ shares.set(hold.id, sumAmounts(share, added));
200
+ left = sumAmounts(left, -added);
201
+ }
202
+ return shares;
203
+ }
204
+ function expiredHoldBackingPatch(next, stored = 0) {
205
+ return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
206
+ }
207
+ function assertValidBalanceTarget(value, field, context) {
208
+ assertRepresentableAmount(value, field, context);
209
+ if (value < 0) {
210
+ throw new CreditError(
211
+ `${field} cannot be negative (got ${value})`,
212
+ CreditErrorCode.INVALID_AMOUNT,
213
+ { field, value, ...context }
214
+ );
215
+ }
216
+ }
217
+ function assertValidTierBalanceInput(input, context) {
218
+ if (input.balanceCap === void 0) return;
219
+ if (input.balance !== void 0) {
220
+ throw new CreditError(
221
+ "A tier update cannot set both balance and balanceCap",
222
+ CreditErrorCode.INVALID_AMOUNT,
223
+ { balance: input.balance, balanceCap: input.balanceCap, ...context }
224
+ );
225
+ }
226
+ assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
227
+ }
147
228
  function assertRepresentableTierAmount(value, field, context) {
148
229
  if (value === Number.POSITIVE_INFINITY) return;
149
230
  assertRepresentableAmount(value, field, context);
@@ -361,9 +442,9 @@ var creditSystemConfigSchema = import_zod.z.object({
361
442
  */
362
443
  reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
363
444
  /**
364
- * Default credits for new users (free tier)
445
+ * Default credits for new users (free tier). 0 means no signup grant.
365
446
  */
366
- defaultFreeCredits: import_zod.z.number().positive().default(25),
447
+ defaultFreeCredits: import_zod.z.number().min(0).default(25),
367
448
  /**
368
449
  * Grace period in days after subscription expires before downgrade
369
450
  */
@@ -793,11 +874,36 @@ function toDate(value) {
793
874
  return /* @__PURE__ */ new Date();
794
875
  }
795
876
  function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
796
- const next = new Date(from);
797
- next.setMonth(next.getMonth() + 1);
798
- next.setDate(1);
799
- next.setHours(0, 0, 0, 0);
800
- return next;
877
+ return new Date(from.getFullYear(), from.getMonth() + 1, 1);
878
+ }
879
+ function validateBalanceUpdate(current, updates) {
880
+ const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
881
+ const balance = next(current.balance, updates.balance, updates.balanceIncrement);
882
+ const bonusCredits = next(
883
+ current.bonusCredits ?? 0,
884
+ updates.bonusCredits,
885
+ updates.bonusCreditsIncrement
886
+ );
887
+ const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
888
+ const errors = [];
889
+ if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
890
+ const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
891
+ if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
892
+ errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
893
+ }
894
+ if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
895
+ if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
896
+ const covering = sumAmounts(balance, bonusCredits);
897
+ if (reserved > covering) {
898
+ errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
899
+ }
900
+ return { valid: errors.length === 0, errors };
901
+ }
902
+ function assertValidBalanceUpdate(current, updates) {
903
+ const result = validateBalanceUpdate(current, updates);
904
+ if (!result.valid) {
905
+ throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
906
+ }
801
907
  }
802
908
 
803
909
  // src/repository/memory/snapshot.ts
@@ -1078,6 +1184,22 @@ function assertTrustworthy(reservation, transition) {
1078
1184
  assertLockedAmount(reservation, transition);
1079
1185
  assertHoldPlaced(reservation, transition);
1080
1186
  }
1187
+ function openHolds(store, userId) {
1188
+ return [...store.reservations.get(userId)?.values() ?? []].filter(
1189
+ (reservation) => reservation.status === "reserved"
1190
+ );
1191
+ }
1192
+ function writeExpiredHoldBacking(store, credits, next) {
1193
+ const stored = credits.expiredHoldBacking;
1194
+ if (next <= 0 && !stored) return;
1195
+ const holds = openHolds(store, credits.userId);
1196
+ const shares = attributeExpiredBacking(holds, next);
1197
+ for (const hold of holds) {
1198
+ const share = shares.get(hold.id) ?? 0;
1199
+ if (share !== (hold.expiredBacking ?? 0)) hold.expiredBacking = share;
1200
+ }
1201
+ Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
1202
+ }
1081
1203
  function getReservation(store, userId, reservationId) {
1082
1204
  return store.reservations.get(userId)?.get(reservationId);
1083
1205
  }
@@ -1175,16 +1297,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
1175
1297
  const amount = reservation.amount;
1176
1298
  await yieldPoint(store);
1177
1299
  assertReservedCoversHold(credits, userId, reservationId, amount);
1178
- if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
1300
+ const paid = planHoldCommit(
1301
+ credits.balance,
1302
+ credits.bonusCredits,
1303
+ credits.expiredHoldBacking ?? 0,
1304
+ reservation.expiredBacking ?? 0,
1305
+ amount
1306
+ );
1307
+ if (!paid.covered) {
1179
1308
  throw new CreditError(
1180
1309
  `Insufficient credits to commit reservation ${reservationId}`,
1181
1310
  CreditErrorCode.INSUFFICIENT_CREDITS,
1182
1311
  { userId, reservationId, required: amount }
1183
1312
  );
1184
1313
  }
1185
- const fromBalance = Math.min(credits.balance, amount);
1186
- const nextBalance = sumAmounts(credits.balance, -fromBalance);
1187
- const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
1314
+ const nextBalance = paid.balance;
1315
+ const nextBonusCredits = paid.bonusCredits;
1188
1316
  const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
1189
1317
  const journal = {
1190
1318
  userId,
@@ -1218,6 +1346,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1218
1346
  credits.bonusCredits = nextBonusCredits;
1219
1347
  credits.reserved = nextReserved;
1220
1348
  credits.monthlyUsed = nextMonthlyUsed;
1349
+ Object.assign(
1350
+ credits,
1351
+ expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1352
+ );
1221
1353
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1222
1354
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
1223
1355
  return {
@@ -1297,19 +1429,26 @@ function finishUnspent(store, reservation, transition) {
1297
1429
  reservation.id,
1298
1430
  reservation.amount
1299
1431
  );
1432
+ const backing = settleExpiredHoldBacking(
1433
+ credits.balance,
1434
+ credits.expiredHoldBacking ?? 0,
1435
+ reservation.expiredBacking ?? 0
1436
+ );
1437
+ const nextBalance = sumAmounts(credits.balance, -backing.used);
1300
1438
  const journal = {
1301
1439
  userId: reservation.userId,
1302
1440
  operation: `${transition.transition}Reservation`,
1303
1441
  entryType: "credit",
1304
1442
  amount: 0,
1305
- balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
1443
+ balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1306
1444
  source: transition.source,
1307
1445
  reservationId: reservation.id,
1308
1446
  description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1309
1447
  metadata: {
1310
1448
  ...transition.options?.metadata,
1311
1449
  operationType: reservation.operationType,
1312
- amount: reservation.amount
1450
+ amount: reservation.amount,
1451
+ ...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
1313
1452
  },
1314
1453
  idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
1315
1454
  };
@@ -1322,6 +1461,8 @@ function finishUnspent(store, reservation, transition) {
1322
1461
  reservation.status = transition.status;
1323
1462
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1324
1463
  credits.reserved = nextReserved;
1464
+ credits.balance = nextBalance;
1465
+ Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1325
1466
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1326
1467
  return commitTransitionJournal(store, journal, existingEntryId);
1327
1468
  }
@@ -1332,6 +1473,10 @@ function isoOf(value) {
1332
1473
  }
1333
1474
  var InMemoryCreditRepository = class {
1334
1475
  store = new MemoryStore();
1476
+ options;
1477
+ constructor(options = {}) {
1478
+ this.options = options;
1479
+ }
1335
1480
  // ==================== User Credits ====================
1336
1481
  /**
1337
1482
  * A copy, never the live record.
@@ -1348,7 +1493,8 @@ var InMemoryCreditRepository = class {
1348
1493
  return copyRecord(credits) ?? null;
1349
1494
  }
1350
1495
  async initializeUserCredits(userId, tier, initialBalance) {
1351
- return copyRecord(this.seedUser(userId, tier, initialBalance));
1496
+ const existing = this.store.users.get(userId);
1497
+ return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
1352
1498
  }
1353
1499
  /**
1354
1500
  * The synchronous body of `initializeUserCredits`.
@@ -1399,8 +1545,11 @@ var InMemoryCreditRepository = class {
1399
1545
  if (!credits) {
1400
1546
  throw new Error(`User ${userId} not found`);
1401
1547
  }
1548
+ assertValidBalanceUpdate(credits, updates);
1402
1549
  const next = { ...credits };
1403
- if (updates.balance !== void 0) next.balance = updates.balance;
1550
+ if (updates.balance !== void 0) {
1551
+ next.balance = updates.balance;
1552
+ }
1404
1553
  if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
1405
1554
  if (updates.reserved !== void 0) next.reserved = updates.reserved;
1406
1555
  if (updates.tier !== void 0) next.tier = updates.tier;
@@ -1437,6 +1586,9 @@ var InMemoryCreditRepository = class {
1437
1586
  userId,
1438
1587
  operation: "updateUserCredits"
1439
1588
  });
1589
+ if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
1590
+ writeExpiredHoldBacking(this.store, next, 0);
1591
+ }
1440
1592
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1441
1593
  this.store.users.set(userId, next);
1442
1594
  }
@@ -1445,17 +1597,28 @@ var InMemoryCreditRepository = class {
1445
1597
  { monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
1446
1598
  { userId, operation: "updateUserTier" }
1447
1599
  );
1600
+ assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
1448
1601
  const credits = this.store.users.get(userId);
1449
1602
  if (!credits) {
1450
1603
  throw new Error(`User ${userId} not found`);
1451
1604
  }
1605
+ if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1606
+ const capped = planBalanceCap(
1607
+ credits.balance,
1608
+ input.balanceCap,
1609
+ credits.reserved,
1610
+ credits.bonusCredits,
1611
+ credits.expiredHoldBacking
1612
+ );
1613
+ credits.balance = capped.balance;
1614
+ writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
1615
+ }
1452
1616
  credits.tier = input.tier;
1453
1617
  credits.monthlyLimit = input.monthlyLimit;
1454
1618
  if (input.balance !== void 0) {
1455
- credits.balance = Math.max(
1456
- input.balance,
1457
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
1458
- );
1619
+ const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1620
+ credits.balance = planned.balance;
1621
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1459
1622
  }
1460
1623
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1461
1624
  if (input.subscriptionExpiresAt !== void 0) {
@@ -1676,13 +1839,17 @@ var InMemoryCreditRepository = class {
1676
1839
  if (!credits) {
1677
1840
  throw new Error(`User credits not found for userId: ${userId}`);
1678
1841
  }
1679
- const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
1842
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
1843
+ const available = Math.min(
1844
+ sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
1845
+ sumAmounts(spendable, credits.bonusCredits)
1846
+ );
1680
1847
  if (available < amount) {
1681
1848
  throw new Error(
1682
1849
  `Insufficient credits. Available: ${available}, requested: ${amount}`
1683
1850
  );
1684
1851
  }
1685
- const balanceDeduction = Math.min(credits.balance, amount);
1852
+ const balanceDeduction = Math.min(spendable, amount);
1686
1853
  const bonusDeduction = sumAmounts(amount, -balanceDeduction);
1687
1854
  const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
1688
1855
  const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
@@ -1919,7 +2086,11 @@ var InMemoryCreditRepository = class {
1919
2086
  return { expiredCount, creditsReleased, errors };
1920
2087
  }
1921
2088
  // ==================== Atomic Monthly Reset ====================
1922
- async atomicMonthlyReset(userId, tier, expectedResetAt) {
2089
+ /**
2090
+ * @param _tier - Not trusted. The caller read it earlier and the account may
2091
+ * have changed tier since, so the reset uses the tier stored on the account.
2092
+ */
2093
+ async atomicMonthlyReset(userId, _tier, expectedResetAt) {
1923
2094
  const credits = this.store.users.get(userId);
1924
2095
  if (!credits) {
1925
2096
  throw new Error(`User ${userId} not found`);
@@ -1929,17 +2100,41 @@ var InMemoryCreditRepository = class {
1929
2100
  if (currentResetAt !== expected) {
1930
2101
  return { wasReset: false, credits: { ...credits } };
1931
2102
  }
1932
- assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2103
+ const tier = credits.tier;
2104
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
2105
+ const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
2106
+ if (decision === null) {
2107
+ return { wasReset: false, credits: { ...credits } };
2108
+ }
2109
+ if (decision) {
2110
+ assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
2111
+ if (decision.monthlyLimit !== void 0) {
2112
+ assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
2113
+ userId,
2114
+ tier
2115
+ });
2116
+ }
2117
+ } else {
2118
+ assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2119
+ }
1933
2120
  const nextReset = getNextMonthlyReset();
1934
- const target = monthlyResetBalance(tier);
1935
- const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
1936
2121
  const previousBalance = credits.balance;
1937
- const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
2122
+ let target;
2123
+ if (decision) {
2124
+ target = decision.balance;
2125
+ } else {
2126
+ const configured = monthlyResetBalance(tier);
2127
+ target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
2128
+ }
2129
+ const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
2130
+ const newBalance = planned.balance;
1938
2131
  const change = sumAmounts(newBalance, -previousBalance);
1939
2132
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
1940
2133
  assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
1941
2134
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
1942
2135
  credits.balance = newBalance;
2136
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2137
+ if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
1943
2138
  credits.monthlyUsed = 0;
1944
2139
  credits.monthlyResetAt = nextReset.toISOString();
1945
2140
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -1993,23 +2188,34 @@ var InMemoryCreditRepository = class {
1993
2188
  };
1994
2189
  }
1995
2190
  const defaultTier = getDefaultTier();
1996
- const configured = getConfigMonthlyLimit(defaultTier);
1997
- assertRepresentableTierAmount(configured, "monthlyLimit", {
1998
- userId,
1999
- tier: defaultTier
2000
- });
2001
- const limit = storedMonthlyLimit(configured);
2191
+ let limit;
2192
+ if (this.options.getDowngradeBalanceCap) {
2193
+ limit = this.options.getDowngradeBalanceCap();
2194
+ assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
2195
+ } else {
2196
+ const configured = getConfigMonthlyLimit(defaultTier);
2197
+ assertRepresentableTierAmount(configured, "monthlyLimit", {
2198
+ userId,
2199
+ tier: defaultTier
2200
+ });
2201
+ limit = storedMonthlyLimit(configured);
2202
+ }
2002
2203
  const previousTier = credits.tier;
2003
2204
  const previousBalance = credits.balance;
2004
- const newBalance = Math.max(
2005
- Math.min(credits.balance, limit),
2006
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
2205
+ const planned = planBalanceCap(
2206
+ credits.balance,
2207
+ limit,
2208
+ credits.reserved,
2209
+ credits.bonusCredits,
2210
+ credits.expiredHoldBacking
2007
2211
  );
2212
+ const newBalance = planned.balance;
2008
2213
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2009
2214
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2010
2215
  credits.tier = defaultTier;
2011
2216
  credits.monthlyLimit = limit;
2012
2217
  credits.balance = newBalance;
2218
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2013
2219
  credits.subscriptionExpiresAt = null;
2014
2220
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2015
2221
  this.store.users.set(userId, credits);
@@ -2067,13 +2273,14 @@ var InMemoryCreditRepository = class {
2067
2273
  return copyRecords(Array.from(userReservations.values()));
2068
2274
  }
2069
2275
  };
2070
- function createInMemoryCreditRepository() {
2071
- return new InMemoryCreditRepository();
2276
+ function createInMemoryCreditRepository(options = {}) {
2277
+ return new InMemoryCreditRepository(options);
2072
2278
  }
2073
2279
  // Annotate the CommonJS export names for ESM import in node:
2074
2280
  0 && (module.exports = {
2075
2281
  InMemoryCreditRepository,
2076
2282
  addCreditsThroughRepository,
2283
+ assertValidBalanceUpdate,
2077
2284
  commitThroughRepository,
2078
2285
  createInMemoryCreditRepository,
2079
2286
  generateId,
@@ -2084,6 +2291,7 @@ function createInMemoryCreditRepository() {
2084
2291
  supportsCreditsV2,
2085
2292
  supportsIdempotentCredit,
2086
2293
  toClientUserCredits,
2087
- toDate
2294
+ toDate,
2295
+ validateBalanceUpdate
2088
2296
  });
2089
2297
  //# sourceMappingURL=index.cjs.map