@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
package/dist/index.cjs CHANGED
@@ -54,12 +54,17 @@ __export(src_exports, {
54
54
  assertRepresentableTierAmount: () => assertRepresentableTierAmount,
55
55
  assertUnkeyedDirectReservation: () => assertUnkeyedDirectReservation,
56
56
  assertUnreferencedDirectTransaction: () => assertUnreferencedDirectTransaction,
57
+ assertValidBalanceTarget: () => assertValidBalanceTarget,
58
+ assertValidBalanceUpdate: () => assertValidBalanceUpdate,
57
59
  assertValidCreditAmount: () => assertValidCreditAmount,
58
60
  assertValidIdempotencyKey: () => assertValidIdempotencyKey,
59
61
  assertValidStoredAmount: () => assertValidStoredAmount,
60
62
  assertValidStoredAmountRaw: () => assertValidStoredAmountRaw,
63
+ assertValidTierBalanceInput: () => assertValidTierBalanceInput,
64
+ attributeExpiredBacking: () => attributeExpiredBacking,
61
65
  backedBalanceFloor: () => backedBalanceFloor,
62
66
  calculateAvailableCredits: () => calculateAvailableCredits,
67
+ cappedBalance: () => cappedBalance,
63
68
  checkAndNotifyLowBalance: () => checkAndNotifyLowBalance,
64
69
  classifyDatabaseError: () => classifyDatabaseError,
65
70
  clearConfigProvider: () => clearConfigProvider,
@@ -85,6 +90,7 @@ __export(src_exports, {
85
90
  createUnsupportedOperationError: () => createUnsupportedOperationError,
86
91
  createUserNotFoundError: () => createUserNotFoundError,
87
92
  describePaymentMismatch: () => describePaymentMismatch,
93
+ expiredHoldBackingPatch: () => expiredHoldBackingPatch,
88
94
  generateId: () => generateId,
89
95
  generateRequestId: () => generateRequestId2,
90
96
  genericDeferred: () => genericDeferred,
@@ -143,6 +149,9 @@ __export(src_exports, {
143
149
  parseApiError: () => parseApiError,
144
150
  parseCreditError: () => parseCreditError,
145
151
  parseTier: () => parseTier,
152
+ planBalanceCap: () => planBalanceCap,
153
+ planBalanceTarget: () => planBalanceTarget,
154
+ planHoldCommit: () => planHoldCommit,
146
155
  registerConfigProvider: () => registerConfigProvider,
147
156
  registerNotificationHandler: () => registerNotificationHandler,
148
157
  releaseReservationWithJournal: () => releaseReservationWithJournal,
@@ -152,6 +161,8 @@ __export(src_exports, {
152
161
  reserveThroughRepository: () => reserveThroughRepository,
153
162
  resetConfig: () => resetConfig,
154
163
  sameAmount: () => sameAmount,
164
+ settleExpiredHoldBacking: () => settleExpiredHoldBacking,
165
+ spendableBalance: () => spendableBalance,
155
166
  storedMonthlyLimit: () => storedMonthlyLimit,
156
167
  sumAmounts: () => sumAmounts,
157
168
  supportsCreditsV2: () => supportsCreditsV2,
@@ -163,6 +174,7 @@ __export(src_exports, {
163
174
  toClientUserCredits: () => toClientUserCredits,
164
175
  toDate: () => toDate,
165
176
  toPortableTimestamp: () => toPortableTimestamp,
177
+ validateBalanceUpdate: () => validateBalanceUpdate,
166
178
  verifyAdminApiKey: () => verifyAdminApiKey,
167
179
  verifyBearerToken: () => verifyBearerToken
168
180
  });
@@ -389,6 +401,86 @@ function backedBalanceFloor(reserved, bonusCredits) {
389
401
  assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
390
402
  return floor;
391
403
  }
404
+ function cappedBalance(balance, cap, reserved, bonusCredits) {
405
+ return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
406
+ }
407
+ function spendableBalance(balance, expiredHoldBacking = 0) {
408
+ return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
409
+ }
410
+ function planBalanceTarget(target, reserved, bonusCredits) {
411
+ const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
412
+ return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
413
+ }
414
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
415
+ const spendable = spendableBalance(balance, expiredHoldBacking);
416
+ const target = Math.min(spendable, cap);
417
+ const kept = Math.max(
418
+ sumAmounts(balance, -spendable),
419
+ sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
420
+ );
421
+ const next = Math.min(balance, sumAmounts(target, kept));
422
+ return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
423
+ }
424
+ function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
425
+ const held = Math.min(expiredHoldBacking, balance);
426
+ const used = Math.min(held, holdBacking);
427
+ return { used, remaining: sumAmounts(held, -used) };
428
+ }
429
+ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
430
+ const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
431
+ const rest = sumAmounts(amount, -backing.used);
432
+ const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
433
+ const fromBonus = sumAmounts(rest, -fromSpendable);
434
+ return {
435
+ covered: fromBonus <= bonusCredits,
436
+ balance: sumAmounts(balance, -backing.used, -fromSpendable),
437
+ bonusCredits: sumAmounts(bonusCredits, -fromBonus),
438
+ expiredHoldBacking: backing.remaining
439
+ };
440
+ }
441
+ function attributeExpiredBacking(holds, total) {
442
+ const placed = holds.filter((hold) => hold.holdPlacedAt).sort(
443
+ (a, b) => a.holdPlacedAt === b.holdPlacedAt ? a.id < b.id ? -1 : 1 : a.holdPlacedAt < b.holdPlacedAt ? -1 : 1
444
+ );
445
+ const shares = /* @__PURE__ */ new Map();
446
+ let left = total;
447
+ for (const hold of placed) {
448
+ const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);
449
+ shares.set(hold.id, kept);
450
+ left = sumAmounts(left, -kept);
451
+ }
452
+ for (const hold of placed) {
453
+ const share = shares.get(hold.id);
454
+ const added = Math.min(sumAmounts(hold.amount, -share), left);
455
+ shares.set(hold.id, sumAmounts(share, added));
456
+ left = sumAmounts(left, -added);
457
+ }
458
+ return shares;
459
+ }
460
+ function expiredHoldBackingPatch(next, stored = 0) {
461
+ return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
462
+ }
463
+ function assertValidBalanceTarget(value, field, context) {
464
+ assertRepresentableAmount(value, field, context);
465
+ if (value < 0) {
466
+ throw new CreditError(
467
+ `${field} cannot be negative (got ${value})`,
468
+ CreditErrorCode.INVALID_AMOUNT,
469
+ { field, value, ...context }
470
+ );
471
+ }
472
+ }
473
+ function assertValidTierBalanceInput(input, context) {
474
+ if (input.balanceCap === void 0) return;
475
+ if (input.balance !== void 0) {
476
+ throw new CreditError(
477
+ "A tier update cannot set both balance and balanceCap",
478
+ CreditErrorCode.INVALID_AMOUNT,
479
+ { balance: input.balance, balanceCap: input.balanceCap, ...context }
480
+ );
481
+ }
482
+ assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
483
+ }
392
484
  function assertRepresentableTierAmount(value, field, context) {
393
485
  if (value === Number.POSITIVE_INFINITY) return;
394
486
  assertRepresentableAmount(value, field, context);
@@ -797,9 +889,9 @@ var creditSystemConfigSchema = import_zod.z.object({
797
889
  */
798
890
  reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
799
891
  /**
800
- * Default credits for new users (free tier)
892
+ * Default credits for new users (free tier). 0 means no signup grant.
801
893
  */
802
- defaultFreeCredits: import_zod.z.number().positive().default(25),
894
+ defaultFreeCredits: import_zod.z.number().min(0).default(25),
803
895
  /**
804
896
  * Grace period in days after subscription expires before downgrade
805
897
  */
@@ -1099,6 +1191,8 @@ function toClientUserCredits(credits) {
1099
1191
  monthlyUsed: credits.monthlyUsed,
1100
1192
  monthlyResetAt: toISOString(credits.monthlyResetAt),
1101
1193
  subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
1194
+ ...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
1195
+ ...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
1102
1196
  createdAt: toISOString(credits.createdAt),
1103
1197
  updatedAt: toISOString(credits.updatedAt)
1104
1198
  };
@@ -1330,11 +1424,36 @@ function toDate2(value) {
1330
1424
  return /* @__PURE__ */ new Date();
1331
1425
  }
1332
1426
  function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
1333
- const next = new Date(from);
1334
- next.setMonth(next.getMonth() + 1);
1335
- next.setDate(1);
1336
- next.setHours(0, 0, 0, 0);
1337
- return next;
1427
+ return new Date(from.getFullYear(), from.getMonth() + 1, 1);
1428
+ }
1429
+ function validateBalanceUpdate(current, updates) {
1430
+ const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
1431
+ const balance = next(current.balance, updates.balance, updates.balanceIncrement);
1432
+ const bonusCredits = next(
1433
+ current.bonusCredits ?? 0,
1434
+ updates.bonusCredits,
1435
+ updates.bonusCreditsIncrement
1436
+ );
1437
+ const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
1438
+ const errors = [];
1439
+ if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
1440
+ const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
1441
+ if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
1442
+ errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
1443
+ }
1444
+ if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
1445
+ if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
1446
+ const covering = sumAmounts(balance, bonusCredits);
1447
+ if (reserved > covering) {
1448
+ errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
1449
+ }
1450
+ return { valid: errors.length === 0, errors };
1451
+ }
1452
+ function assertValidBalanceUpdate(current, updates) {
1453
+ const result = validateBalanceUpdate(current, updates);
1454
+ if (!result.valid) {
1455
+ throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
1456
+ }
1338
1457
  }
1339
1458
 
1340
1459
  // src/repository/memory/snapshot.ts
@@ -1514,6 +1633,22 @@ function assertTrustworthy(reservation, transition) {
1514
1633
  assertLockedAmount(reservation, transition);
1515
1634
  assertHoldPlaced(reservation, transition);
1516
1635
  }
1636
+ function openHolds(store, userId) {
1637
+ return [...store.reservations.get(userId)?.values() ?? []].filter(
1638
+ (reservation) => reservation.status === "reserved"
1639
+ );
1640
+ }
1641
+ function writeExpiredHoldBacking(store, credits, next) {
1642
+ const stored = credits.expiredHoldBacking;
1643
+ if (next <= 0 && !stored) return;
1644
+ const holds = openHolds(store, credits.userId);
1645
+ const shares = attributeExpiredBacking(holds, next);
1646
+ for (const hold of holds) {
1647
+ const share = shares.get(hold.id) ?? 0;
1648
+ if (share !== (hold.expiredBacking ?? 0)) hold.expiredBacking = share;
1649
+ }
1650
+ Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
1651
+ }
1517
1652
  function getReservation(store, userId, reservationId) {
1518
1653
  return store.reservations.get(userId)?.get(reservationId);
1519
1654
  }
@@ -1611,16 +1746,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
1611
1746
  const amount = reservation.amount;
1612
1747
  await yieldPoint(store);
1613
1748
  assertReservedCoversHold(credits, userId, reservationId, amount);
1614
- if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
1749
+ const paid = planHoldCommit(
1750
+ credits.balance,
1751
+ credits.bonusCredits,
1752
+ credits.expiredHoldBacking ?? 0,
1753
+ reservation.expiredBacking ?? 0,
1754
+ amount
1755
+ );
1756
+ if (!paid.covered) {
1615
1757
  throw new CreditError(
1616
1758
  `Insufficient credits to commit reservation ${reservationId}`,
1617
1759
  CreditErrorCode.INSUFFICIENT_CREDITS,
1618
1760
  { userId, reservationId, required: amount }
1619
1761
  );
1620
1762
  }
1621
- const fromBalance = Math.min(credits.balance, amount);
1622
- const nextBalance = sumAmounts(credits.balance, -fromBalance);
1623
- const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
1763
+ const nextBalance = paid.balance;
1764
+ const nextBonusCredits = paid.bonusCredits;
1624
1765
  const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
1625
1766
  const journal = {
1626
1767
  userId,
@@ -1654,6 +1795,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1654
1795
  credits.bonusCredits = nextBonusCredits;
1655
1796
  credits.reserved = nextReserved;
1656
1797
  credits.monthlyUsed = nextMonthlyUsed;
1798
+ Object.assign(
1799
+ credits,
1800
+ expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1801
+ );
1657
1802
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1658
1803
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
1659
1804
  return {
@@ -1733,19 +1878,26 @@ function finishUnspent(store, reservation, transition) {
1733
1878
  reservation.id,
1734
1879
  reservation.amount
1735
1880
  );
1881
+ const backing = settleExpiredHoldBacking(
1882
+ credits.balance,
1883
+ credits.expiredHoldBacking ?? 0,
1884
+ reservation.expiredBacking ?? 0
1885
+ );
1886
+ const nextBalance = sumAmounts(credits.balance, -backing.used);
1736
1887
  const journal = {
1737
1888
  userId: reservation.userId,
1738
1889
  operation: `${transition.transition}Reservation`,
1739
1890
  entryType: "credit",
1740
1891
  amount: 0,
1741
- balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
1892
+ balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1742
1893
  source: transition.source,
1743
1894
  reservationId: reservation.id,
1744
1895
  description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1745
1896
  metadata: {
1746
1897
  ...transition.options?.metadata,
1747
1898
  operationType: reservation.operationType,
1748
- amount: reservation.amount
1899
+ amount: reservation.amount,
1900
+ ...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
1749
1901
  },
1750
1902
  idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
1751
1903
  };
@@ -1758,6 +1910,8 @@ function finishUnspent(store, reservation, transition) {
1758
1910
  reservation.status = transition.status;
1759
1911
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1760
1912
  credits.reserved = nextReserved;
1913
+ credits.balance = nextBalance;
1914
+ Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1761
1915
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1762
1916
  return commitTransitionJournal(store, journal, existingEntryId);
1763
1917
  }
@@ -1768,6 +1922,10 @@ function isoOf(value) {
1768
1922
  }
1769
1923
  var InMemoryCreditRepository = class {
1770
1924
  store = new MemoryStore();
1925
+ options;
1926
+ constructor(options = {}) {
1927
+ this.options = options;
1928
+ }
1771
1929
  // ==================== User Credits ====================
1772
1930
  /**
1773
1931
  * A copy, never the live record.
@@ -1784,7 +1942,8 @@ var InMemoryCreditRepository = class {
1784
1942
  return copyRecord(credits) ?? null;
1785
1943
  }
1786
1944
  async initializeUserCredits(userId, tier, initialBalance) {
1787
- return copyRecord(this.seedUser(userId, tier, initialBalance));
1945
+ const existing = this.store.users.get(userId);
1946
+ return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
1788
1947
  }
1789
1948
  /**
1790
1949
  * The synchronous body of `initializeUserCredits`.
@@ -1835,8 +1994,11 @@ var InMemoryCreditRepository = class {
1835
1994
  if (!credits) {
1836
1995
  throw new Error(`User ${userId} not found`);
1837
1996
  }
1997
+ assertValidBalanceUpdate(credits, updates);
1838
1998
  const next = { ...credits };
1839
- if (updates.balance !== void 0) next.balance = updates.balance;
1999
+ if (updates.balance !== void 0) {
2000
+ next.balance = updates.balance;
2001
+ }
1840
2002
  if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
1841
2003
  if (updates.reserved !== void 0) next.reserved = updates.reserved;
1842
2004
  if (updates.tier !== void 0) next.tier = updates.tier;
@@ -1873,6 +2035,9 @@ var InMemoryCreditRepository = class {
1873
2035
  userId,
1874
2036
  operation: "updateUserCredits"
1875
2037
  });
2038
+ if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
2039
+ writeExpiredHoldBacking(this.store, next, 0);
2040
+ }
1876
2041
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1877
2042
  this.store.users.set(userId, next);
1878
2043
  }
@@ -1881,17 +2046,28 @@ var InMemoryCreditRepository = class {
1881
2046
  { monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
1882
2047
  { userId, operation: "updateUserTier" }
1883
2048
  );
2049
+ assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
1884
2050
  const credits = this.store.users.get(userId);
1885
2051
  if (!credits) {
1886
2052
  throw new Error(`User ${userId} not found`);
1887
2053
  }
2054
+ if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
2055
+ const capped = planBalanceCap(
2056
+ credits.balance,
2057
+ input.balanceCap,
2058
+ credits.reserved,
2059
+ credits.bonusCredits,
2060
+ credits.expiredHoldBacking
2061
+ );
2062
+ credits.balance = capped.balance;
2063
+ writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
2064
+ }
1888
2065
  credits.tier = input.tier;
1889
2066
  credits.monthlyLimit = input.monthlyLimit;
1890
2067
  if (input.balance !== void 0) {
1891
- credits.balance = Math.max(
1892
- input.balance,
1893
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
1894
- );
2068
+ const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
2069
+ credits.balance = planned.balance;
2070
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1895
2071
  }
1896
2072
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1897
2073
  if (input.subscriptionExpiresAt !== void 0) {
@@ -2112,13 +2288,17 @@ var InMemoryCreditRepository = class {
2112
2288
  if (!credits) {
2113
2289
  throw new Error(`User credits not found for userId: ${userId}`);
2114
2290
  }
2115
- const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
2291
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
2292
+ const available = Math.min(
2293
+ sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
2294
+ sumAmounts(spendable, credits.bonusCredits)
2295
+ );
2116
2296
  if (available < amount) {
2117
2297
  throw new Error(
2118
2298
  `Insufficient credits. Available: ${available}, requested: ${amount}`
2119
2299
  );
2120
2300
  }
2121
- const balanceDeduction = Math.min(credits.balance, amount);
2301
+ const balanceDeduction = Math.min(spendable, amount);
2122
2302
  const bonusDeduction = sumAmounts(amount, -balanceDeduction);
2123
2303
  const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
2124
2304
  const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
@@ -2355,7 +2535,11 @@ var InMemoryCreditRepository = class {
2355
2535
  return { expiredCount, creditsReleased, errors };
2356
2536
  }
2357
2537
  // ==================== Atomic Monthly Reset ====================
2358
- async atomicMonthlyReset(userId, tier, expectedResetAt) {
2538
+ /**
2539
+ * @param _tier - Not trusted. The caller read it earlier and the account may
2540
+ * have changed tier since, so the reset uses the tier stored on the account.
2541
+ */
2542
+ async atomicMonthlyReset(userId, _tier, expectedResetAt) {
2359
2543
  const credits = this.store.users.get(userId);
2360
2544
  if (!credits) {
2361
2545
  throw new Error(`User ${userId} not found`);
@@ -2365,17 +2549,41 @@ var InMemoryCreditRepository = class {
2365
2549
  if (currentResetAt !== expected) {
2366
2550
  return { wasReset: false, credits: { ...credits } };
2367
2551
  }
2368
- assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2552
+ const tier = credits.tier;
2553
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
2554
+ const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
2555
+ if (decision === null) {
2556
+ return { wasReset: false, credits: { ...credits } };
2557
+ }
2558
+ if (decision) {
2559
+ assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
2560
+ if (decision.monthlyLimit !== void 0) {
2561
+ assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
2562
+ userId,
2563
+ tier
2564
+ });
2565
+ }
2566
+ } else {
2567
+ assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2568
+ }
2369
2569
  const nextReset = getNextMonthlyReset();
2370
- const target = monthlyResetBalance(tier);
2371
- const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
2372
2570
  const previousBalance = credits.balance;
2373
- const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
2571
+ let target;
2572
+ if (decision) {
2573
+ target = decision.balance;
2574
+ } else {
2575
+ const configured = monthlyResetBalance(tier);
2576
+ target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
2577
+ }
2578
+ const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
2579
+ const newBalance = planned.balance;
2374
2580
  const change = sumAmounts(newBalance, -previousBalance);
2375
2581
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2376
2582
  assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
2377
2583
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2378
2584
  credits.balance = newBalance;
2585
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2586
+ if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
2379
2587
  credits.monthlyUsed = 0;
2380
2588
  credits.monthlyResetAt = nextReset.toISOString();
2381
2589
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -2429,23 +2637,34 @@ var InMemoryCreditRepository = class {
2429
2637
  };
2430
2638
  }
2431
2639
  const defaultTier = getDefaultTier();
2432
- const configured = getConfigMonthlyLimit(defaultTier);
2433
- assertRepresentableTierAmount(configured, "monthlyLimit", {
2434
- userId,
2435
- tier: defaultTier
2436
- });
2437
- const limit = storedMonthlyLimit(configured);
2640
+ let limit;
2641
+ if (this.options.getDowngradeBalanceCap) {
2642
+ limit = this.options.getDowngradeBalanceCap();
2643
+ assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
2644
+ } else {
2645
+ const configured = getConfigMonthlyLimit(defaultTier);
2646
+ assertRepresentableTierAmount(configured, "monthlyLimit", {
2647
+ userId,
2648
+ tier: defaultTier
2649
+ });
2650
+ limit = storedMonthlyLimit(configured);
2651
+ }
2438
2652
  const previousTier = credits.tier;
2439
2653
  const previousBalance = credits.balance;
2440
- const newBalance = Math.max(
2441
- Math.min(credits.balance, limit),
2442
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
2654
+ const planned = planBalanceCap(
2655
+ credits.balance,
2656
+ limit,
2657
+ credits.reserved,
2658
+ credits.bonusCredits,
2659
+ credits.expiredHoldBacking
2443
2660
  );
2661
+ const newBalance = planned.balance;
2444
2662
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2445
2663
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2446
2664
  credits.tier = defaultTier;
2447
2665
  credits.monthlyLimit = limit;
2448
2666
  credits.balance = newBalance;
2667
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2449
2668
  credits.subscriptionExpiresAt = null;
2450
2669
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2451
2670
  this.store.users.set(userId, credits);
@@ -2503,8 +2722,8 @@ var InMemoryCreditRepository = class {
2503
2722
  return copyRecords(Array.from(userReservations.values()));
2504
2723
  }
2505
2724
  };
2506
- function createInMemoryCreditRepository() {
2507
- return new InMemoryCreditRepository();
2725
+ function createInMemoryCreditRepository(options = {}) {
2726
+ return new InMemoryCreditRepository(options);
2508
2727
  }
2509
2728
 
2510
2729
  // src/auth/api-key.ts
@@ -2667,9 +2886,11 @@ var CreditsService = class {
2667
2886
  source: "monthly_reset",
2668
2887
  referenceId: `reset-${Date.now()}`,
2669
2888
  referenceType: "reset",
2670
- description: `Monthly credit reset for ${data.tier} tier.`,
2889
+ // The tier the reset ran on, which a concurrent downgrade may have
2890
+ // changed since `data` was read.
2891
+ description: `Monthly credit reset for ${resetResult.credits.tier} tier.`,
2671
2892
  metadata: {
2672
- tier: data.tier,
2893
+ tier: resetResult.credits.tier,
2673
2894
  previousBalance: data.balance,
2674
2895
  newBalance: resetResult.credits.balance
2675
2896
  }
@@ -3956,12 +4177,17 @@ function createDummyReservation2(userId, operationType) {
3956
4177
  assertRepresentableTierAmount,
3957
4178
  assertUnkeyedDirectReservation,
3958
4179
  assertUnreferencedDirectTransaction,
4180
+ assertValidBalanceTarget,
4181
+ assertValidBalanceUpdate,
3959
4182
  assertValidCreditAmount,
3960
4183
  assertValidIdempotencyKey,
3961
4184
  assertValidStoredAmount,
3962
4185
  assertValidStoredAmountRaw,
4186
+ assertValidTierBalanceInput,
4187
+ attributeExpiredBacking,
3963
4188
  backedBalanceFloor,
3964
4189
  calculateAvailableCredits,
4190
+ cappedBalance,
3965
4191
  checkAndNotifyLowBalance,
3966
4192
  classifyDatabaseError,
3967
4193
  clearConfigProvider,
@@ -3987,6 +4213,7 @@ function createDummyReservation2(userId, operationType) {
3987
4213
  createUnsupportedOperationError,
3988
4214
  createUserNotFoundError,
3989
4215
  describePaymentMismatch,
4216
+ expiredHoldBackingPatch,
3990
4217
  generateId,
3991
4218
  generateRequestId,
3992
4219
  genericDeferred,
@@ -4045,6 +4272,9 @@ function createDummyReservation2(userId, operationType) {
4045
4272
  parseApiError,
4046
4273
  parseCreditError,
4047
4274
  parseTier,
4275
+ planBalanceCap,
4276
+ planBalanceTarget,
4277
+ planHoldCommit,
4048
4278
  registerConfigProvider,
4049
4279
  registerNotificationHandler,
4050
4280
  releaseReservationWithJournal,
@@ -4054,6 +4284,8 @@ function createDummyReservation2(userId, operationType) {
4054
4284
  reserveThroughRepository,
4055
4285
  resetConfig,
4056
4286
  sameAmount,
4287
+ settleExpiredHoldBacking,
4288
+ spendableBalance,
4057
4289
  storedMonthlyLimit,
4058
4290
  sumAmounts,
4059
4291
  supportsCreditsV2,
@@ -4065,6 +4297,7 @@ function createDummyReservation2(userId, operationType) {
4065
4297
  toClientUserCredits,
4066
4298
  toDate,
4067
4299
  toPortableTimestamp,
4300
+ validateBalanceUpdate,
4068
4301
  verifyAdminApiKey,
4069
4302
  verifyBearerToken
4070
4303
  });