@nehorai/credits 2.0.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 (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +384 -377
  3. package/dist/adapters/index.cjs +12 -8
  4. package/dist/adapters/index.cjs.map +1 -1
  5. package/dist/adapters/index.d.cts +3 -2
  6. package/dist/adapters/index.d.ts +3 -2
  7. package/dist/adapters/index.js +12 -8
  8. package/dist/adapters/index.js.map +1 -1
  9. package/dist/auth/index.cjs.map +1 -1
  10. package/dist/auth/index.js.map +1 -1
  11. package/dist/core/index.cjs +106 -4
  12. package/dist/core/index.cjs.map +1 -1
  13. package/dist/core/index.d.cts +92 -5
  14. package/dist/core/index.d.ts +92 -5
  15. package/dist/core/index.js +96 -4
  16. package/dist/core/index.js.map +1 -1
  17. package/dist/index.cjs +282 -45
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +9 -7
  20. package/dist/index.d.ts +9 -7
  21. package/dist/index.js +270 -45
  22. package/dist/index.js.map +1 -1
  23. package/dist/repository/index.cjs +251 -41
  24. package/dist/repository/index.cjs.map +1 -1
  25. package/dist/repository/index.d.cts +34 -6
  26. package/dist/repository/index.d.ts +34 -6
  27. package/dist/repository/index.js +248 -40
  28. package/dist/repository/index.js.map +1 -1
  29. package/dist/sdk/index.cjs.map +1 -1
  30. package/dist/sdk/index.d.cts +2 -2
  31. package/dist/sdk/index.d.ts +2 -2
  32. package/dist/sdk/index.js.map +1 -1
  33. package/dist/service/index.cjs +12 -6
  34. package/dist/service/index.cjs.map +1 -1
  35. package/dist/service/index.d.cts +2 -2
  36. package/dist/service/index.d.ts +2 -2
  37. package/dist/service/index.js +12 -6
  38. package/dist/service/index.js.map +1 -1
  39. package/dist/{types-BmG5Omzj.d.ts → types-3c-IAwZI.d.ts} +38 -3
  40. package/dist/{types-DJ8yqaVr.d.cts → types-MaViK5tD.d.cts} +23 -1
  41. package/dist/{types-DJ8yqaVr.d.ts → types-MaViK5tD.d.ts} +23 -1
  42. package/dist/{types-C8hZMe8a.d.cts → types-XSjNXO5l.d.cts} +38 -3
  43. package/package.json +1 -1
@@ -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);
@@ -333,11 +414,13 @@ var creditSystemConfigSchema = import_zod.z.object({
333
414
  /**
334
415
  * Operation costs in credits
335
416
  * Keys are operation type names (any non-empty string)
336
- * Values are positive numbers representing credit cost
417
+ * Values are non-negative numbers representing credit cost. 0 marks a free
418
+ * operation: `withCredits` runs it without touching the ledger. Amounts that
419
+ * move credits stay strictly positive, so `reserveCredits` refuses a 0.
337
420
  */
338
421
  operationCosts: import_zod.z.record(
339
422
  import_zod.z.string().min(1),
340
- import_zod.z.number().positive()
423
+ import_zod.z.number().nonnegative()
341
424
  ),
342
425
  /**
343
426
  * Display labels for operation types (English canonical labels).
@@ -359,9 +442,9 @@ var creditSystemConfigSchema = import_zod.z.object({
359
442
  */
360
443
  reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
361
444
  /**
362
- * Default credits for new users (free tier)
445
+ * Default credits for new users (free tier). 0 means no signup grant.
363
446
  */
364
- defaultFreeCredits: import_zod.z.number().positive().default(25),
447
+ defaultFreeCredits: import_zod.z.number().min(0).default(25),
365
448
  /**
366
449
  * Grace period in days after subscription expires before downgrade
367
450
  */
@@ -791,11 +874,36 @@ function toDate(value) {
791
874
  return /* @__PURE__ */ new Date();
792
875
  }
793
876
  function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
794
- const next = new Date(from);
795
- next.setMonth(next.getMonth() + 1);
796
- next.setDate(1);
797
- next.setHours(0, 0, 0, 0);
798
- 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
+ }
799
907
  }
800
908
 
801
909
  // src/repository/memory/snapshot.ts
@@ -1076,6 +1184,22 @@ function assertTrustworthy(reservation, transition) {
1076
1184
  assertLockedAmount(reservation, transition);
1077
1185
  assertHoldPlaced(reservation, transition);
1078
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
+ }
1079
1203
  function getReservation(store, userId, reservationId) {
1080
1204
  return store.reservations.get(userId)?.get(reservationId);
1081
1205
  }
@@ -1173,16 +1297,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
1173
1297
  const amount = reservation.amount;
1174
1298
  await yieldPoint(store);
1175
1299
  assertReservedCoversHold(credits, userId, reservationId, amount);
1176
- 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) {
1177
1308
  throw new CreditError(
1178
1309
  `Insufficient credits to commit reservation ${reservationId}`,
1179
1310
  CreditErrorCode.INSUFFICIENT_CREDITS,
1180
1311
  { userId, reservationId, required: amount }
1181
1312
  );
1182
1313
  }
1183
- const fromBalance = Math.min(credits.balance, amount);
1184
- const nextBalance = sumAmounts(credits.balance, -fromBalance);
1185
- const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
1314
+ const nextBalance = paid.balance;
1315
+ const nextBonusCredits = paid.bonusCredits;
1186
1316
  const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
1187
1317
  const journal = {
1188
1318
  userId,
@@ -1216,6 +1346,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1216
1346
  credits.bonusCredits = nextBonusCredits;
1217
1347
  credits.reserved = nextReserved;
1218
1348
  credits.monthlyUsed = nextMonthlyUsed;
1349
+ Object.assign(
1350
+ credits,
1351
+ expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1352
+ );
1219
1353
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1220
1354
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
1221
1355
  return {
@@ -1295,19 +1429,26 @@ function finishUnspent(store, reservation, transition) {
1295
1429
  reservation.id,
1296
1430
  reservation.amount
1297
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);
1298
1438
  const journal = {
1299
1439
  userId: reservation.userId,
1300
1440
  operation: `${transition.transition}Reservation`,
1301
1441
  entryType: "credit",
1302
1442
  amount: 0,
1303
- balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
1443
+ balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1304
1444
  source: transition.source,
1305
1445
  reservationId: reservation.id,
1306
1446
  description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1307
1447
  metadata: {
1308
1448
  ...transition.options?.metadata,
1309
1449
  operationType: reservation.operationType,
1310
- amount: reservation.amount
1450
+ amount: reservation.amount,
1451
+ ...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
1311
1452
  },
1312
1453
  idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
1313
1454
  };
@@ -1320,6 +1461,8 @@ function finishUnspent(store, reservation, transition) {
1320
1461
  reservation.status = transition.status;
1321
1462
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1322
1463
  credits.reserved = nextReserved;
1464
+ credits.balance = nextBalance;
1465
+ Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1323
1466
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1324
1467
  return commitTransitionJournal(store, journal, existingEntryId);
1325
1468
  }
@@ -1330,6 +1473,10 @@ function isoOf(value) {
1330
1473
  }
1331
1474
  var InMemoryCreditRepository = class {
1332
1475
  store = new MemoryStore();
1476
+ options;
1477
+ constructor(options = {}) {
1478
+ this.options = options;
1479
+ }
1333
1480
  // ==================== User Credits ====================
1334
1481
  /**
1335
1482
  * A copy, never the live record.
@@ -1346,7 +1493,8 @@ var InMemoryCreditRepository = class {
1346
1493
  return copyRecord(credits) ?? null;
1347
1494
  }
1348
1495
  async initializeUserCredits(userId, tier, initialBalance) {
1349
- return copyRecord(this.seedUser(userId, tier, initialBalance));
1496
+ const existing = this.store.users.get(userId);
1497
+ return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
1350
1498
  }
1351
1499
  /**
1352
1500
  * The synchronous body of `initializeUserCredits`.
@@ -1397,8 +1545,11 @@ var InMemoryCreditRepository = class {
1397
1545
  if (!credits) {
1398
1546
  throw new Error(`User ${userId} not found`);
1399
1547
  }
1548
+ assertValidBalanceUpdate(credits, updates);
1400
1549
  const next = { ...credits };
1401
- if (updates.balance !== void 0) next.balance = updates.balance;
1550
+ if (updates.balance !== void 0) {
1551
+ next.balance = updates.balance;
1552
+ }
1402
1553
  if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
1403
1554
  if (updates.reserved !== void 0) next.reserved = updates.reserved;
1404
1555
  if (updates.tier !== void 0) next.tier = updates.tier;
@@ -1435,6 +1586,9 @@ var InMemoryCreditRepository = class {
1435
1586
  userId,
1436
1587
  operation: "updateUserCredits"
1437
1588
  });
1589
+ if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
1590
+ writeExpiredHoldBacking(this.store, next, 0);
1591
+ }
1438
1592
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1439
1593
  this.store.users.set(userId, next);
1440
1594
  }
@@ -1443,17 +1597,28 @@ var InMemoryCreditRepository = class {
1443
1597
  { monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
1444
1598
  { userId, operation: "updateUserTier" }
1445
1599
  );
1600
+ assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
1446
1601
  const credits = this.store.users.get(userId);
1447
1602
  if (!credits) {
1448
1603
  throw new Error(`User ${userId} not found`);
1449
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
+ }
1450
1616
  credits.tier = input.tier;
1451
1617
  credits.monthlyLimit = input.monthlyLimit;
1452
1618
  if (input.balance !== void 0) {
1453
- credits.balance = Math.max(
1454
- input.balance,
1455
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
1456
- );
1619
+ const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1620
+ credits.balance = planned.balance;
1621
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1457
1622
  }
1458
1623
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1459
1624
  if (input.subscriptionExpiresAt !== void 0) {
@@ -1674,13 +1839,17 @@ var InMemoryCreditRepository = class {
1674
1839
  if (!credits) {
1675
1840
  throw new Error(`User credits not found for userId: ${userId}`);
1676
1841
  }
1677
- 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
+ );
1678
1847
  if (available < amount) {
1679
1848
  throw new Error(
1680
1849
  `Insufficient credits. Available: ${available}, requested: ${amount}`
1681
1850
  );
1682
1851
  }
1683
- const balanceDeduction = Math.min(credits.balance, amount);
1852
+ const balanceDeduction = Math.min(spendable, amount);
1684
1853
  const bonusDeduction = sumAmounts(amount, -balanceDeduction);
1685
1854
  const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
1686
1855
  const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
@@ -1917,7 +2086,11 @@ var InMemoryCreditRepository = class {
1917
2086
  return { expiredCount, creditsReleased, errors };
1918
2087
  }
1919
2088
  // ==================== Atomic Monthly Reset ====================
1920
- 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) {
1921
2094
  const credits = this.store.users.get(userId);
1922
2095
  if (!credits) {
1923
2096
  throw new Error(`User ${userId} not found`);
@@ -1927,17 +2100,41 @@ var InMemoryCreditRepository = class {
1927
2100
  if (currentResetAt !== expected) {
1928
2101
  return { wasReset: false, credits: { ...credits } };
1929
2102
  }
1930
- 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
+ }
1931
2120
  const nextReset = getNextMonthlyReset();
1932
- const target = monthlyResetBalance(tier);
1933
- const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
1934
2121
  const previousBalance = credits.balance;
1935
- 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;
1936
2131
  const change = sumAmounts(newBalance, -previousBalance);
1937
2132
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
1938
2133
  assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
1939
2134
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
1940
2135
  credits.balance = newBalance;
2136
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2137
+ if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
1941
2138
  credits.monthlyUsed = 0;
1942
2139
  credits.monthlyResetAt = nextReset.toISOString();
1943
2140
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -1991,23 +2188,34 @@ var InMemoryCreditRepository = class {
1991
2188
  };
1992
2189
  }
1993
2190
  const defaultTier = getDefaultTier();
1994
- const configured = getConfigMonthlyLimit(defaultTier);
1995
- assertRepresentableTierAmount(configured, "monthlyLimit", {
1996
- userId,
1997
- tier: defaultTier
1998
- });
1999
- 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
+ }
2000
2203
  const previousTier = credits.tier;
2001
2204
  const previousBalance = credits.balance;
2002
- const newBalance = Math.max(
2003
- Math.min(credits.balance, limit),
2004
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
2205
+ const planned = planBalanceCap(
2206
+ credits.balance,
2207
+ limit,
2208
+ credits.reserved,
2209
+ credits.bonusCredits,
2210
+ credits.expiredHoldBacking
2005
2211
  );
2212
+ const newBalance = planned.balance;
2006
2213
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2007
2214
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2008
2215
  credits.tier = defaultTier;
2009
2216
  credits.monthlyLimit = limit;
2010
2217
  credits.balance = newBalance;
2218
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2011
2219
  credits.subscriptionExpiresAt = null;
2012
2220
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2013
2221
  this.store.users.set(userId, credits);
@@ -2065,13 +2273,14 @@ var InMemoryCreditRepository = class {
2065
2273
  return copyRecords(Array.from(userReservations.values()));
2066
2274
  }
2067
2275
  };
2068
- function createInMemoryCreditRepository() {
2069
- return new InMemoryCreditRepository();
2276
+ function createInMemoryCreditRepository(options = {}) {
2277
+ return new InMemoryCreditRepository(options);
2070
2278
  }
2071
2279
  // Annotate the CommonJS export names for ESM import in node:
2072
2280
  0 && (module.exports = {
2073
2281
  InMemoryCreditRepository,
2074
2282
  addCreditsThroughRepository,
2283
+ assertValidBalanceUpdate,
2075
2284
  commitThroughRepository,
2076
2285
  createInMemoryCreditRepository,
2077
2286
  generateId,
@@ -2082,6 +2291,7 @@ function createInMemoryCreditRepository() {
2082
2291
  supportsCreditsV2,
2083
2292
  supportsIdempotentCredit,
2084
2293
  toClientUserCredits,
2085
- toDate
2294
+ toDate,
2295
+ validateBalanceUpdate
2086
2296
  });
2087
2297
  //# sourceMappingURL=index.cjs.map