@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
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);
@@ -769,11 +861,13 @@ var creditSystemConfigSchema = import_zod.z.object({
769
861
  /**
770
862
  * Operation costs in credits
771
863
  * Keys are operation type names (any non-empty string)
772
- * Values are positive numbers representing credit cost
864
+ * Values are non-negative numbers representing credit cost. 0 marks a free
865
+ * operation: `withCredits` runs it without touching the ledger. Amounts that
866
+ * move credits stay strictly positive, so `reserveCredits` refuses a 0.
773
867
  */
774
868
  operationCosts: import_zod.z.record(
775
869
  import_zod.z.string().min(1),
776
- import_zod.z.number().positive()
870
+ import_zod.z.number().nonnegative()
777
871
  ),
778
872
  /**
779
873
  * Display labels for operation types (English canonical labels).
@@ -795,9 +889,9 @@ var creditSystemConfigSchema = import_zod.z.object({
795
889
  */
796
890
  reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
797
891
  /**
798
- * Default credits for new users (free tier)
892
+ * Default credits for new users (free tier). 0 means no signup grant.
799
893
  */
800
- defaultFreeCredits: import_zod.z.number().positive().default(25),
894
+ defaultFreeCredits: import_zod.z.number().min(0).default(25),
801
895
  /**
802
896
  * Grace period in days after subscription expires before downgrade
803
897
  */
@@ -1097,6 +1191,8 @@ function toClientUserCredits(credits) {
1097
1191
  monthlyUsed: credits.monthlyUsed,
1098
1192
  monthlyResetAt: toISOString(credits.monthlyResetAt),
1099
1193
  subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
1194
+ ...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
1195
+ ...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
1100
1196
  createdAt: toISOString(credits.createdAt),
1101
1197
  updatedAt: toISOString(credits.updatedAt)
1102
1198
  };
@@ -1328,11 +1424,36 @@ function toDate2(value) {
1328
1424
  return /* @__PURE__ */ new Date();
1329
1425
  }
1330
1426
  function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
1331
- const next = new Date(from);
1332
- next.setMonth(next.getMonth() + 1);
1333
- next.setDate(1);
1334
- next.setHours(0, 0, 0, 0);
1335
- 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
+ }
1336
1457
  }
1337
1458
 
1338
1459
  // src/repository/memory/snapshot.ts
@@ -1512,6 +1633,22 @@ function assertTrustworthy(reservation, transition) {
1512
1633
  assertLockedAmount(reservation, transition);
1513
1634
  assertHoldPlaced(reservation, transition);
1514
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
+ }
1515
1652
  function getReservation(store, userId, reservationId) {
1516
1653
  return store.reservations.get(userId)?.get(reservationId);
1517
1654
  }
@@ -1609,16 +1746,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
1609
1746
  const amount = reservation.amount;
1610
1747
  await yieldPoint(store);
1611
1748
  assertReservedCoversHold(credits, userId, reservationId, amount);
1612
- 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) {
1613
1757
  throw new CreditError(
1614
1758
  `Insufficient credits to commit reservation ${reservationId}`,
1615
1759
  CreditErrorCode.INSUFFICIENT_CREDITS,
1616
1760
  { userId, reservationId, required: amount }
1617
1761
  );
1618
1762
  }
1619
- const fromBalance = Math.min(credits.balance, amount);
1620
- const nextBalance = sumAmounts(credits.balance, -fromBalance);
1621
- const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
1763
+ const nextBalance = paid.balance;
1764
+ const nextBonusCredits = paid.bonusCredits;
1622
1765
  const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
1623
1766
  const journal = {
1624
1767
  userId,
@@ -1652,6 +1795,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1652
1795
  credits.bonusCredits = nextBonusCredits;
1653
1796
  credits.reserved = nextReserved;
1654
1797
  credits.monthlyUsed = nextMonthlyUsed;
1798
+ Object.assign(
1799
+ credits,
1800
+ expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1801
+ );
1655
1802
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1656
1803
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
1657
1804
  return {
@@ -1731,19 +1878,26 @@ function finishUnspent(store, reservation, transition) {
1731
1878
  reservation.id,
1732
1879
  reservation.amount
1733
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);
1734
1887
  const journal = {
1735
1888
  userId: reservation.userId,
1736
1889
  operation: `${transition.transition}Reservation`,
1737
1890
  entryType: "credit",
1738
1891
  amount: 0,
1739
- balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
1892
+ balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1740
1893
  source: transition.source,
1741
1894
  reservationId: reservation.id,
1742
1895
  description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1743
1896
  metadata: {
1744
1897
  ...transition.options?.metadata,
1745
1898
  operationType: reservation.operationType,
1746
- amount: reservation.amount
1899
+ amount: reservation.amount,
1900
+ ...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
1747
1901
  },
1748
1902
  idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
1749
1903
  };
@@ -1756,6 +1910,8 @@ function finishUnspent(store, reservation, transition) {
1756
1910
  reservation.status = transition.status;
1757
1911
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1758
1912
  credits.reserved = nextReserved;
1913
+ credits.balance = nextBalance;
1914
+ Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1759
1915
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1760
1916
  return commitTransitionJournal(store, journal, existingEntryId);
1761
1917
  }
@@ -1766,6 +1922,10 @@ function isoOf(value) {
1766
1922
  }
1767
1923
  var InMemoryCreditRepository = class {
1768
1924
  store = new MemoryStore();
1925
+ options;
1926
+ constructor(options = {}) {
1927
+ this.options = options;
1928
+ }
1769
1929
  // ==================== User Credits ====================
1770
1930
  /**
1771
1931
  * A copy, never the live record.
@@ -1782,7 +1942,8 @@ var InMemoryCreditRepository = class {
1782
1942
  return copyRecord(credits) ?? null;
1783
1943
  }
1784
1944
  async initializeUserCredits(userId, tier, initialBalance) {
1785
- return copyRecord(this.seedUser(userId, tier, initialBalance));
1945
+ const existing = this.store.users.get(userId);
1946
+ return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
1786
1947
  }
1787
1948
  /**
1788
1949
  * The synchronous body of `initializeUserCredits`.
@@ -1833,8 +1994,11 @@ var InMemoryCreditRepository = class {
1833
1994
  if (!credits) {
1834
1995
  throw new Error(`User ${userId} not found`);
1835
1996
  }
1997
+ assertValidBalanceUpdate(credits, updates);
1836
1998
  const next = { ...credits };
1837
- if (updates.balance !== void 0) next.balance = updates.balance;
1999
+ if (updates.balance !== void 0) {
2000
+ next.balance = updates.balance;
2001
+ }
1838
2002
  if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
1839
2003
  if (updates.reserved !== void 0) next.reserved = updates.reserved;
1840
2004
  if (updates.tier !== void 0) next.tier = updates.tier;
@@ -1871,6 +2035,9 @@ var InMemoryCreditRepository = class {
1871
2035
  userId,
1872
2036
  operation: "updateUserCredits"
1873
2037
  });
2038
+ if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
2039
+ writeExpiredHoldBacking(this.store, next, 0);
2040
+ }
1874
2041
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1875
2042
  this.store.users.set(userId, next);
1876
2043
  }
@@ -1879,17 +2046,28 @@ var InMemoryCreditRepository = class {
1879
2046
  { monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
1880
2047
  { userId, operation: "updateUserTier" }
1881
2048
  );
2049
+ assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
1882
2050
  const credits = this.store.users.get(userId);
1883
2051
  if (!credits) {
1884
2052
  throw new Error(`User ${userId} not found`);
1885
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
+ }
1886
2065
  credits.tier = input.tier;
1887
2066
  credits.monthlyLimit = input.monthlyLimit;
1888
2067
  if (input.balance !== void 0) {
1889
- credits.balance = Math.max(
1890
- input.balance,
1891
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
1892
- );
2068
+ const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
2069
+ credits.balance = planned.balance;
2070
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1893
2071
  }
1894
2072
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1895
2073
  if (input.subscriptionExpiresAt !== void 0) {
@@ -2110,13 +2288,17 @@ var InMemoryCreditRepository = class {
2110
2288
  if (!credits) {
2111
2289
  throw new Error(`User credits not found for userId: ${userId}`);
2112
2290
  }
2113
- 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
+ );
2114
2296
  if (available < amount) {
2115
2297
  throw new Error(
2116
2298
  `Insufficient credits. Available: ${available}, requested: ${amount}`
2117
2299
  );
2118
2300
  }
2119
- const balanceDeduction = Math.min(credits.balance, amount);
2301
+ const balanceDeduction = Math.min(spendable, amount);
2120
2302
  const bonusDeduction = sumAmounts(amount, -balanceDeduction);
2121
2303
  const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
2122
2304
  const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
@@ -2353,7 +2535,11 @@ var InMemoryCreditRepository = class {
2353
2535
  return { expiredCount, creditsReleased, errors };
2354
2536
  }
2355
2537
  // ==================== Atomic Monthly Reset ====================
2356
- 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) {
2357
2543
  const credits = this.store.users.get(userId);
2358
2544
  if (!credits) {
2359
2545
  throw new Error(`User ${userId} not found`);
@@ -2363,17 +2549,41 @@ var InMemoryCreditRepository = class {
2363
2549
  if (currentResetAt !== expected) {
2364
2550
  return { wasReset: false, credits: { ...credits } };
2365
2551
  }
2366
- 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
+ }
2367
2569
  const nextReset = getNextMonthlyReset();
2368
- const target = monthlyResetBalance(tier);
2369
- const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
2370
2570
  const previousBalance = credits.balance;
2371
- 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;
2372
2580
  const change = sumAmounts(newBalance, -previousBalance);
2373
2581
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2374
2582
  assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
2375
2583
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2376
2584
  credits.balance = newBalance;
2585
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2586
+ if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
2377
2587
  credits.monthlyUsed = 0;
2378
2588
  credits.monthlyResetAt = nextReset.toISOString();
2379
2589
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -2427,23 +2637,34 @@ var InMemoryCreditRepository = class {
2427
2637
  };
2428
2638
  }
2429
2639
  const defaultTier = getDefaultTier();
2430
- const configured = getConfigMonthlyLimit(defaultTier);
2431
- assertRepresentableTierAmount(configured, "monthlyLimit", {
2432
- userId,
2433
- tier: defaultTier
2434
- });
2435
- 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
+ }
2436
2652
  const previousTier = credits.tier;
2437
2653
  const previousBalance = credits.balance;
2438
- const newBalance = Math.max(
2439
- Math.min(credits.balance, limit),
2440
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
2654
+ const planned = planBalanceCap(
2655
+ credits.balance,
2656
+ limit,
2657
+ credits.reserved,
2658
+ credits.bonusCredits,
2659
+ credits.expiredHoldBacking
2441
2660
  );
2661
+ const newBalance = planned.balance;
2442
2662
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2443
2663
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2444
2664
  credits.tier = defaultTier;
2445
2665
  credits.monthlyLimit = limit;
2446
2666
  credits.balance = newBalance;
2667
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2447
2668
  credits.subscriptionExpiresAt = null;
2448
2669
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2449
2670
  this.store.users.set(userId, credits);
@@ -2501,8 +2722,8 @@ var InMemoryCreditRepository = class {
2501
2722
  return copyRecords(Array.from(userReservations.values()));
2502
2723
  }
2503
2724
  };
2504
- function createInMemoryCreditRepository() {
2505
- return new InMemoryCreditRepository();
2725
+ function createInMemoryCreditRepository(options = {}) {
2726
+ return new InMemoryCreditRepository(options);
2506
2727
  }
2507
2728
 
2508
2729
  // src/auth/api-key.ts
@@ -2665,9 +2886,11 @@ var CreditsService = class {
2665
2886
  source: "monthly_reset",
2666
2887
  referenceId: `reset-${Date.now()}`,
2667
2888
  referenceType: "reset",
2668
- 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.`,
2669
2892
  metadata: {
2670
- tier: data.tier,
2893
+ tier: resetResult.credits.tier,
2671
2894
  previousBalance: data.balance,
2672
2895
  newBalance: resetResult.credits.balance
2673
2896
  }
@@ -3106,9 +3329,11 @@ function createGenericAdapter(config) {
3106
3329
  return handler(user, data, dummyReservation);
3107
3330
  }
3108
3331
  let reservation;
3332
+ let isFree = false;
3109
3333
  try {
3110
3334
  const cost = options.customCost ?? getOperationCost2(options.operationType);
3111
- reservation = await reserveCreditsForOperation(
3335
+ isFree = options.customCost === void 0 && cost === 0;
3336
+ reservation = isFree ? { ...createDummyReservation(user.id, options.operationType), id: "free-operation" } : await reserveCreditsForOperation(
3112
3337
  repository,
3113
3338
  user.id,
3114
3339
  cost,
@@ -3131,7 +3356,7 @@ function createGenericAdapter(config) {
3131
3356
  try {
3132
3357
  const result = await handler(user, data, reservation);
3133
3358
  if (result.success) {
3134
- await commitReservationWithJournal(repository, user.id, reservation.id);
3359
+ if (!isFree) await commitReservationWithJournal(repository, user.id, reservation.id);
3135
3360
  logUsage({
3136
3361
  userId: user.id,
3137
3362
  operationType: options.operationType,
@@ -3142,7 +3367,7 @@ function createGenericAdapter(config) {
3142
3367
  requestId
3143
3368
  });
3144
3369
  } else {
3145
- await releaseReservationWithJournal(repository, user.id, reservation.id);
3370
+ if (!isFree) await releaseReservationWithJournal(repository, user.id, reservation.id);
3146
3371
  logUsage({
3147
3372
  userId: user.id,
3148
3373
  operationType: options.operationType,
@@ -3157,7 +3382,7 @@ function createGenericAdapter(config) {
3157
3382
  return result;
3158
3383
  } catch (error) {
3159
3384
  const message = error instanceof Error ? error.message : "An unexpected error occurred";
3160
- await releaseReservationWithJournal(repository, user.id, reservation.id);
3385
+ if (!isFree) await releaseReservationWithJournal(repository, user.id, reservation.id);
3161
3386
  logUsage({
3162
3387
  userId: user.id,
3163
3388
  operationType: options.operationType,
@@ -3952,12 +4177,17 @@ function createDummyReservation2(userId, operationType) {
3952
4177
  assertRepresentableTierAmount,
3953
4178
  assertUnkeyedDirectReservation,
3954
4179
  assertUnreferencedDirectTransaction,
4180
+ assertValidBalanceTarget,
4181
+ assertValidBalanceUpdate,
3955
4182
  assertValidCreditAmount,
3956
4183
  assertValidIdempotencyKey,
3957
4184
  assertValidStoredAmount,
3958
4185
  assertValidStoredAmountRaw,
4186
+ assertValidTierBalanceInput,
4187
+ attributeExpiredBacking,
3959
4188
  backedBalanceFloor,
3960
4189
  calculateAvailableCredits,
4190
+ cappedBalance,
3961
4191
  checkAndNotifyLowBalance,
3962
4192
  classifyDatabaseError,
3963
4193
  clearConfigProvider,
@@ -3983,6 +4213,7 @@ function createDummyReservation2(userId, operationType) {
3983
4213
  createUnsupportedOperationError,
3984
4214
  createUserNotFoundError,
3985
4215
  describePaymentMismatch,
4216
+ expiredHoldBackingPatch,
3986
4217
  generateId,
3987
4218
  generateRequestId,
3988
4219
  genericDeferred,
@@ -4041,6 +4272,9 @@ function createDummyReservation2(userId, operationType) {
4041
4272
  parseApiError,
4042
4273
  parseCreditError,
4043
4274
  parseTier,
4275
+ planBalanceCap,
4276
+ planBalanceTarget,
4277
+ planHoldCommit,
4044
4278
  registerConfigProvider,
4045
4279
  registerNotificationHandler,
4046
4280
  releaseReservationWithJournal,
@@ -4050,6 +4284,8 @@ function createDummyReservation2(userId, operationType) {
4050
4284
  reserveThroughRepository,
4051
4285
  resetConfig,
4052
4286
  sameAmount,
4287
+ settleExpiredHoldBacking,
4288
+ spendableBalance,
4053
4289
  storedMonthlyLimit,
4054
4290
  sumAmounts,
4055
4291
  supportsCreditsV2,
@@ -4061,6 +4297,7 @@ function createDummyReservation2(userId, operationType) {
4061
4297
  toClientUserCredits,
4062
4298
  toDate,
4063
4299
  toPortableTimestamp,
4300
+ validateBalanceUpdate,
4064
4301
  verifyAdminApiKey,
4065
4302
  verifyBearerToken
4066
4303
  });