@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.d.cts CHANGED
@@ -1,10 +1,10 @@
1
- import { S as SubscriptionTier, T as TierConfig, a as PortableReservation } from './types-Dzc443vy.cjs';
2
- export { A as AIProviderType, B as BuiltinTier, g as CreditCheckResult, C as CreditOperationType, b as CreditSource, D as DeductCreditsResult, J as JournalReferenceType, M as MonthlyResetResult, f as PortableJournalEntry, P as PortableTransaction, d as PortableUsageLog, c as PortableUserCredits, R as ReservationStatus, h as ResourceType, g as SDKCreditCheckResult, e as SubscriptionExpiryResult, i as TransactionType, U as UsageHistoryEntry, j as UsageHistoryResponse, c as UserCredits, W as WithCreditsOptions, k as calculateAvailableCredits, t as toDate, l as toPortableTimestamp } from './types-Dzc443vy.cjs';
3
- export { A as AddCreditsAtomicOptions, a as AddCreditsOutcome, b as AddCreditsV2Input, C as CommitOutcome, c as CreateJournalEntryInput, d as CreateReservationInput, e as CreateTransactionInput, f as CreateUsageLogInput, g as CreditBalanceUpdate, h as CreditRepositoryFactory, E as ExpireOutcome, i as ExpireReservationV2Options, I as ICreditRepository, j as ICreditRepositoryCreditsV2, k as ICreditRepositoryV2, J as JournalEntryQuery, R as ReleaseOutcome, l as ReservationTransitionOptions, m as ReserveCreditsV2Input, n as ReserveOutcome, T as TerminalReservationStatus, o as TierUpdateInput, U as UsageLogQuery, p as isCreditedOutcome, q as isReservedOutcome, r as isWinningOutcome, s as reservationJournalKey, t as supportsCreditsV2, u as supportsIdempotentCredit, v as toClientUserCredits } from './types-wtksMa4n.cjs';
4
- export { CREDIT_AMOUNT_MAX, CREDIT_AMOUNT_SCALE, CreditError, CreditErrorCode, CreditErrorCodeType, PaymentEventPayload, RESERVATION_STATUSES, RESERVED_JOURNAL_KEY_PREFIX, ReservationIntegrityView, StoredPaymentEvent, TERMINAL_RESERVATION_STATUSES, assertDirectStatusWriteAllowed, assertHoldPlaced, assertKnownReservationStatus, assertPublicJournalKey, assertRepresentableAmount, assertRepresentableFields, assertRepresentableTierAmount, assertUnkeyedDirectReservation, assertUnreferencedDirectTransaction, assertValidCreditAmount, assertValidIdempotencyKey, assertValidStoredAmount, assertValidStoredAmountRaw, backedBalanceFloor, classifyDatabaseError, commitReservationWithJournal, createIdempotencyConflictError, createInsufficientCreditsError, createInvalidAmountError, createInvalidOperationTypeError, createPaymentRefConflictError, createReservationAlreadyProcessedError, createReservationExpiredError, createReservationNotFoundError, createTransientError, createUnsupportedOperationError, createUserNotFoundError, describePaymentMismatch, getSqlState, hasPlacedHold, isCreditError, isIdempotencyConflictError, isInsufficientCreditsError, isRepresentableAmount, isReservationStatus, isReservedJournalKey, isTerminalReservationStatus, isTransientDatabaseError, isTransientError, isUniqueViolation, isValidCreditAmount, isValidIdempotencyKey, normalizePaymentRef, numericToCents, releaseReservationWithJournal, reserveCreditsForOperation, sameAmount, storedMonthlyLimit, sumAmounts, terminalStatusOf, toCents } from './core/index.cjs';
1
+ import { S as SubscriptionTier, T as TierConfig, a as PortableReservation } from './types-MaViK5tD.cjs';
2
+ export { A as AIProviderType, B as BuiltinTier, g as CreditCheckResult, C as CreditOperationType, b as CreditSource, D as DeductCreditsResult, J as JournalReferenceType, M as MonthlyResetResult, f as PortableJournalEntry, P as PortableTransaction, d as PortableUsageLog, c as PortableUserCredits, R as ReservationStatus, h as ResourceType, g as SDKCreditCheckResult, e as SubscriptionExpiryResult, i as TransactionType, U as UsageHistoryEntry, j as UsageHistoryResponse, c as UserCredits, W as WithCreditsOptions, k as calculateAvailableCredits, t as toDate, l as toPortableTimestamp } from './types-MaViK5tD.cjs';
3
+ export { A as AddCreditsAtomicOptions, a as AddCreditsOutcome, b as AddCreditsV2Input, C as CommitOutcome, c as CreateJournalEntryInput, d as CreateReservationInput, e as CreateTransactionInput, f as CreateUsageLogInput, g as CreditBalanceUpdate, h as CreditPolicyOptions, i as CreditRepositoryFactory, E as ExpireOutcome, j as ExpireReservationV2Options, I as ICreditRepository, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, J as JournalEntryQuery, R as ReleaseOutcome, m as ReservationTransitionOptions, n as ReserveCreditsV2Input, o as ReserveOutcome, T as TerminalReservationStatus, p as TierUpdateInput, U as UsageLogQuery, q as isCreditedOutcome, r as isReservedOutcome, s as isWinningOutcome, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from './types-XSjNXO5l.cjs';
4
+ export { CREDIT_AMOUNT_MAX, CREDIT_AMOUNT_SCALE, CreditError, CreditErrorCode, CreditErrorCodeType, PaymentEventPayload, RESERVATION_STATUSES, RESERVED_JOURNAL_KEY_PREFIX, ReservationIntegrityView, StoredPaymentEvent, TERMINAL_RESERVATION_STATUSES, assertDirectStatusWriteAllowed, assertHoldPlaced, assertKnownReservationStatus, assertPublicJournalKey, assertRepresentableAmount, assertRepresentableFields, assertRepresentableTierAmount, assertUnkeyedDirectReservation, assertUnreferencedDirectTransaction, assertValidBalanceTarget, assertValidCreditAmount, assertValidIdempotencyKey, assertValidStoredAmount, assertValidStoredAmountRaw, assertValidTierBalanceInput, attributeExpiredBacking, backedBalanceFloor, cappedBalance, classifyDatabaseError, commitReservationWithJournal, createIdempotencyConflictError, createInsufficientCreditsError, createInvalidAmountError, createInvalidOperationTypeError, createPaymentRefConflictError, createReservationAlreadyProcessedError, createReservationExpiredError, createReservationNotFoundError, createTransientError, createUnsupportedOperationError, createUserNotFoundError, describePaymentMismatch, expiredHoldBackingPatch, getSqlState, hasPlacedHold, isCreditError, isIdempotencyConflictError, isInsufficientCreditsError, isRepresentableAmount, isReservationStatus, isReservedJournalKey, isTerminalReservationStatus, isTransientDatabaseError, isTransientError, isUniqueViolation, isValidCreditAmount, isValidIdempotencyKey, normalizePaymentRef, numericToCents, planBalanceCap, planBalanceTarget, planHoldCommit, releaseReservationWithJournal, reserveCreditsForOperation, sameAmount, settleExpiredHoldBacking, spendableBalance, storedMonthlyLimit, sumAmounts, terminalStatusOf, toCents } from './core/index.cjs';
5
5
  export { D as DeferredExecutor, c as createDeferredExecutor, g as genericDeferred, n as noopDeferred, s as synchronousDeferred } from './deferred-C7rrRfkm.cjs';
6
6
  import { z } from 'zod';
7
- export { InMemoryCreditRepository, addCreditsThroughRepository, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository } from './repository/index.cjs';
7
+ export { BalanceValidationResult, InMemoryCreditRepository, addCreditsThroughRepository, assertValidBalanceUpdate, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, validateBalanceUpdate } from './repository/index.cjs';
8
8
  export { C as CreditsAuthProviderFactory, a as CreditsUser, I as ICreditsAuthProvider } from './types-OZU3I5OM.cjs';
9
9
  export { ApiKeyCreditsProvider, createApiKeyProvider, verifyAdminApiKey, verifyBearerToken } from './auth/index.cjs';
10
10
  export { CreditsService, DeductCreditsOptions, LowBalanceNotificationCallback, ReserveCreditsOptions, SubscriptionExpiredNotificationCallback, createCreditsService } from './service/index.cjs';
@@ -61,7 +61,7 @@ declare const creditSystemConfigSchema: z.ZodObject<{
61
61
  */
62
62
  reservationExpiryMs: z.ZodDefault<z.ZodNumber>;
63
63
  /**
64
- * Default credits for new users (free tier)
64
+ * Default credits for new users (free tier). 0 means no signup grant.
65
65
  */
66
66
  defaultFreeCredits: z.ZodDefault<z.ZodNumber>;
67
67
  /**
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { S as SubscriptionTier, T as TierConfig, a as PortableReservation } from './types-Dzc443vy.js';
2
- export { A as AIProviderType, B as BuiltinTier, g as CreditCheckResult, C as CreditOperationType, b as CreditSource, D as DeductCreditsResult, J as JournalReferenceType, M as MonthlyResetResult, f as PortableJournalEntry, P as PortableTransaction, d as PortableUsageLog, c as PortableUserCredits, R as ReservationStatus, h as ResourceType, g as SDKCreditCheckResult, e as SubscriptionExpiryResult, i as TransactionType, U as UsageHistoryEntry, j as UsageHistoryResponse, c as UserCredits, W as WithCreditsOptions, k as calculateAvailableCredits, t as toDate, l as toPortableTimestamp } from './types-Dzc443vy.js';
3
- export { A as AddCreditsAtomicOptions, a as AddCreditsOutcome, b as AddCreditsV2Input, C as CommitOutcome, c as CreateJournalEntryInput, d as CreateReservationInput, e as CreateTransactionInput, f as CreateUsageLogInput, g as CreditBalanceUpdate, h as CreditRepositoryFactory, E as ExpireOutcome, i as ExpireReservationV2Options, I as ICreditRepository, j as ICreditRepositoryCreditsV2, k as ICreditRepositoryV2, J as JournalEntryQuery, R as ReleaseOutcome, l as ReservationTransitionOptions, m as ReserveCreditsV2Input, n as ReserveOutcome, T as TerminalReservationStatus, o as TierUpdateInput, U as UsageLogQuery, p as isCreditedOutcome, q as isReservedOutcome, r as isWinningOutcome, s as reservationJournalKey, t as supportsCreditsV2, u as supportsIdempotentCredit, v as toClientUserCredits } from './types-BhCdep_t.js';
4
- export { CREDIT_AMOUNT_MAX, CREDIT_AMOUNT_SCALE, CreditError, CreditErrorCode, CreditErrorCodeType, PaymentEventPayload, RESERVATION_STATUSES, RESERVED_JOURNAL_KEY_PREFIX, ReservationIntegrityView, StoredPaymentEvent, TERMINAL_RESERVATION_STATUSES, assertDirectStatusWriteAllowed, assertHoldPlaced, assertKnownReservationStatus, assertPublicJournalKey, assertRepresentableAmount, assertRepresentableFields, assertRepresentableTierAmount, assertUnkeyedDirectReservation, assertUnreferencedDirectTransaction, assertValidCreditAmount, assertValidIdempotencyKey, assertValidStoredAmount, assertValidStoredAmountRaw, backedBalanceFloor, classifyDatabaseError, commitReservationWithJournal, createIdempotencyConflictError, createInsufficientCreditsError, createInvalidAmountError, createInvalidOperationTypeError, createPaymentRefConflictError, createReservationAlreadyProcessedError, createReservationExpiredError, createReservationNotFoundError, createTransientError, createUnsupportedOperationError, createUserNotFoundError, describePaymentMismatch, getSqlState, hasPlacedHold, isCreditError, isIdempotencyConflictError, isInsufficientCreditsError, isRepresentableAmount, isReservationStatus, isReservedJournalKey, isTerminalReservationStatus, isTransientDatabaseError, isTransientError, isUniqueViolation, isValidCreditAmount, isValidIdempotencyKey, normalizePaymentRef, numericToCents, releaseReservationWithJournal, reserveCreditsForOperation, sameAmount, storedMonthlyLimit, sumAmounts, terminalStatusOf, toCents } from './core/index.js';
1
+ import { S as SubscriptionTier, T as TierConfig, a as PortableReservation } from './types-MaViK5tD.js';
2
+ export { A as AIProviderType, B as BuiltinTier, g as CreditCheckResult, C as CreditOperationType, b as CreditSource, D as DeductCreditsResult, J as JournalReferenceType, M as MonthlyResetResult, f as PortableJournalEntry, P as PortableTransaction, d as PortableUsageLog, c as PortableUserCredits, R as ReservationStatus, h as ResourceType, g as SDKCreditCheckResult, e as SubscriptionExpiryResult, i as TransactionType, U as UsageHistoryEntry, j as UsageHistoryResponse, c as UserCredits, W as WithCreditsOptions, k as calculateAvailableCredits, t as toDate, l as toPortableTimestamp } from './types-MaViK5tD.js';
3
+ export { A as AddCreditsAtomicOptions, a as AddCreditsOutcome, b as AddCreditsV2Input, C as CommitOutcome, c as CreateJournalEntryInput, d as CreateReservationInput, e as CreateTransactionInput, f as CreateUsageLogInput, g as CreditBalanceUpdate, h as CreditPolicyOptions, i as CreditRepositoryFactory, E as ExpireOutcome, j as ExpireReservationV2Options, I as ICreditRepository, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, J as JournalEntryQuery, R as ReleaseOutcome, m as ReservationTransitionOptions, n as ReserveCreditsV2Input, o as ReserveOutcome, T as TerminalReservationStatus, p as TierUpdateInput, U as UsageLogQuery, q as isCreditedOutcome, r as isReservedOutcome, s as isWinningOutcome, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from './types-3c-IAwZI.js';
4
+ export { CREDIT_AMOUNT_MAX, CREDIT_AMOUNT_SCALE, CreditError, CreditErrorCode, CreditErrorCodeType, PaymentEventPayload, RESERVATION_STATUSES, RESERVED_JOURNAL_KEY_PREFIX, ReservationIntegrityView, StoredPaymentEvent, TERMINAL_RESERVATION_STATUSES, assertDirectStatusWriteAllowed, assertHoldPlaced, assertKnownReservationStatus, assertPublicJournalKey, assertRepresentableAmount, assertRepresentableFields, assertRepresentableTierAmount, assertUnkeyedDirectReservation, assertUnreferencedDirectTransaction, assertValidBalanceTarget, assertValidCreditAmount, assertValidIdempotencyKey, assertValidStoredAmount, assertValidStoredAmountRaw, assertValidTierBalanceInput, attributeExpiredBacking, backedBalanceFloor, cappedBalance, classifyDatabaseError, commitReservationWithJournal, createIdempotencyConflictError, createInsufficientCreditsError, createInvalidAmountError, createInvalidOperationTypeError, createPaymentRefConflictError, createReservationAlreadyProcessedError, createReservationExpiredError, createReservationNotFoundError, createTransientError, createUnsupportedOperationError, createUserNotFoundError, describePaymentMismatch, expiredHoldBackingPatch, getSqlState, hasPlacedHold, isCreditError, isIdempotencyConflictError, isInsufficientCreditsError, isRepresentableAmount, isReservationStatus, isReservedJournalKey, isTerminalReservationStatus, isTransientDatabaseError, isTransientError, isUniqueViolation, isValidCreditAmount, isValidIdempotencyKey, normalizePaymentRef, numericToCents, planBalanceCap, planBalanceTarget, planHoldCommit, releaseReservationWithJournal, reserveCreditsForOperation, sameAmount, settleExpiredHoldBacking, spendableBalance, storedMonthlyLimit, sumAmounts, terminalStatusOf, toCents } from './core/index.js';
5
5
  export { D as DeferredExecutor, c as createDeferredExecutor, g as genericDeferred, n as noopDeferred, s as synchronousDeferred } from './deferred-C7rrRfkm.js';
6
6
  import { z } from 'zod';
7
- export { InMemoryCreditRepository, addCreditsThroughRepository, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository } from './repository/index.js';
7
+ export { BalanceValidationResult, InMemoryCreditRepository, addCreditsThroughRepository, assertValidBalanceUpdate, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, validateBalanceUpdate } from './repository/index.js';
8
8
  export { C as CreditsAuthProviderFactory, a as CreditsUser, I as ICreditsAuthProvider } from './types-OZU3I5OM.js';
9
9
  export { ApiKeyCreditsProvider, createApiKeyProvider, verifyAdminApiKey, verifyBearerToken } from './auth/index.js';
10
10
  export { CreditsService, DeductCreditsOptions, LowBalanceNotificationCallback, ReserveCreditsOptions, SubscriptionExpiredNotificationCallback, createCreditsService } from './service/index.js';
@@ -61,7 +61,7 @@ declare const creditSystemConfigSchema: z.ZodObject<{
61
61
  */
62
62
  reservationExpiryMs: z.ZodDefault<z.ZodNumber>;
63
63
  /**
64
- * Default credits for new users (free tier)
64
+ * Default credits for new users (free tier). 0 means no signup grant.
65
65
  */
66
66
  defaultFreeCredits: z.ZodDefault<z.ZodNumber>;
67
67
  /**
package/dist/index.js CHANGED
@@ -219,6 +219,86 @@ function backedBalanceFloor(reserved, bonusCredits) {
219
219
  assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
220
220
  return floor;
221
221
  }
222
+ function cappedBalance(balance, cap, reserved, bonusCredits) {
223
+ return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
224
+ }
225
+ function spendableBalance(balance, expiredHoldBacking = 0) {
226
+ return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
227
+ }
228
+ function planBalanceTarget(target, reserved, bonusCredits) {
229
+ const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
230
+ return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
231
+ }
232
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
233
+ const spendable = spendableBalance(balance, expiredHoldBacking);
234
+ const target = Math.min(spendable, cap);
235
+ const kept = Math.max(
236
+ sumAmounts(balance, -spendable),
237
+ sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
238
+ );
239
+ const next = Math.min(balance, sumAmounts(target, kept));
240
+ return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
241
+ }
242
+ function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
243
+ const held = Math.min(expiredHoldBacking, balance);
244
+ const used = Math.min(held, holdBacking);
245
+ return { used, remaining: sumAmounts(held, -used) };
246
+ }
247
+ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
248
+ const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
249
+ const rest = sumAmounts(amount, -backing.used);
250
+ const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
251
+ const fromBonus = sumAmounts(rest, -fromSpendable);
252
+ return {
253
+ covered: fromBonus <= bonusCredits,
254
+ balance: sumAmounts(balance, -backing.used, -fromSpendable),
255
+ bonusCredits: sumAmounts(bonusCredits, -fromBonus),
256
+ expiredHoldBacking: backing.remaining
257
+ };
258
+ }
259
+ function attributeExpiredBacking(holds, total) {
260
+ const placed = holds.filter((hold) => hold.holdPlacedAt).sort(
261
+ (a, b) => a.holdPlacedAt === b.holdPlacedAt ? a.id < b.id ? -1 : 1 : a.holdPlacedAt < b.holdPlacedAt ? -1 : 1
262
+ );
263
+ const shares = /* @__PURE__ */ new Map();
264
+ let left = total;
265
+ for (const hold of placed) {
266
+ const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);
267
+ shares.set(hold.id, kept);
268
+ left = sumAmounts(left, -kept);
269
+ }
270
+ for (const hold of placed) {
271
+ const share = shares.get(hold.id);
272
+ const added = Math.min(sumAmounts(hold.amount, -share), left);
273
+ shares.set(hold.id, sumAmounts(share, added));
274
+ left = sumAmounts(left, -added);
275
+ }
276
+ return shares;
277
+ }
278
+ function expiredHoldBackingPatch(next, stored = 0) {
279
+ return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
280
+ }
281
+ function assertValidBalanceTarget(value, field, context) {
282
+ assertRepresentableAmount(value, field, context);
283
+ if (value < 0) {
284
+ throw new CreditError(
285
+ `${field} cannot be negative (got ${value})`,
286
+ CreditErrorCode.INVALID_AMOUNT,
287
+ { field, value, ...context }
288
+ );
289
+ }
290
+ }
291
+ function assertValidTierBalanceInput(input, context) {
292
+ if (input.balanceCap === void 0) return;
293
+ if (input.balance !== void 0) {
294
+ throw new CreditError(
295
+ "A tier update cannot set both balance and balanceCap",
296
+ CreditErrorCode.INVALID_AMOUNT,
297
+ { balance: input.balance, balanceCap: input.balanceCap, ...context }
298
+ );
299
+ }
300
+ assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
301
+ }
222
302
  function assertRepresentableTierAmount(value, field, context) {
223
303
  if (value === Number.POSITIVE_INFINITY) return;
224
304
  assertRepresentableAmount(value, field, context);
@@ -627,9 +707,9 @@ var creditSystemConfigSchema = z.object({
627
707
  */
628
708
  reservationExpiryMs: z.number().positive().default(5 * 60 * 1e3),
629
709
  /**
630
- * Default credits for new users (free tier)
710
+ * Default credits for new users (free tier). 0 means no signup grant.
631
711
  */
632
- defaultFreeCredits: z.number().positive().default(25),
712
+ defaultFreeCredits: z.number().min(0).default(25),
633
713
  /**
634
714
  * Grace period in days after subscription expires before downgrade
635
715
  */
@@ -929,6 +1009,8 @@ function toClientUserCredits(credits) {
929
1009
  monthlyUsed: credits.monthlyUsed,
930
1010
  monthlyResetAt: toISOString(credits.monthlyResetAt),
931
1011
  subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
1012
+ ...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
1013
+ ...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
932
1014
  createdAt: toISOString(credits.createdAt),
933
1015
  updatedAt: toISOString(credits.updatedAt)
934
1016
  };
@@ -1160,11 +1242,36 @@ function toDate2(value) {
1160
1242
  return /* @__PURE__ */ new Date();
1161
1243
  }
1162
1244
  function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
1163
- const next = new Date(from);
1164
- next.setMonth(next.getMonth() + 1);
1165
- next.setDate(1);
1166
- next.setHours(0, 0, 0, 0);
1167
- return next;
1245
+ return new Date(from.getFullYear(), from.getMonth() + 1, 1);
1246
+ }
1247
+ function validateBalanceUpdate(current, updates) {
1248
+ const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
1249
+ const balance = next(current.balance, updates.balance, updates.balanceIncrement);
1250
+ const bonusCredits = next(
1251
+ current.bonusCredits ?? 0,
1252
+ updates.bonusCredits,
1253
+ updates.bonusCreditsIncrement
1254
+ );
1255
+ const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
1256
+ const errors = [];
1257
+ if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
1258
+ const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
1259
+ if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
1260
+ errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
1261
+ }
1262
+ if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
1263
+ if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
1264
+ const covering = sumAmounts(balance, bonusCredits);
1265
+ if (reserved > covering) {
1266
+ errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
1267
+ }
1268
+ return { valid: errors.length === 0, errors };
1269
+ }
1270
+ function assertValidBalanceUpdate(current, updates) {
1271
+ const result = validateBalanceUpdate(current, updates);
1272
+ if (!result.valid) {
1273
+ throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
1274
+ }
1168
1275
  }
1169
1276
 
1170
1277
  // src/repository/memory/snapshot.ts
@@ -1344,6 +1451,22 @@ function assertTrustworthy(reservation, transition) {
1344
1451
  assertLockedAmount(reservation, transition);
1345
1452
  assertHoldPlaced(reservation, transition);
1346
1453
  }
1454
+ function openHolds(store, userId) {
1455
+ return [...store.reservations.get(userId)?.values() ?? []].filter(
1456
+ (reservation) => reservation.status === "reserved"
1457
+ );
1458
+ }
1459
+ function writeExpiredHoldBacking(store, credits, next) {
1460
+ const stored = credits.expiredHoldBacking;
1461
+ if (next <= 0 && !stored) return;
1462
+ const holds = openHolds(store, credits.userId);
1463
+ const shares = attributeExpiredBacking(holds, next);
1464
+ for (const hold of holds) {
1465
+ const share = shares.get(hold.id) ?? 0;
1466
+ if (share !== (hold.expiredBacking ?? 0)) hold.expiredBacking = share;
1467
+ }
1468
+ Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
1469
+ }
1347
1470
  function getReservation(store, userId, reservationId) {
1348
1471
  return store.reservations.get(userId)?.get(reservationId);
1349
1472
  }
@@ -1441,16 +1564,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
1441
1564
  const amount = reservation.amount;
1442
1565
  await yieldPoint(store);
1443
1566
  assertReservedCoversHold(credits, userId, reservationId, amount);
1444
- if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
1567
+ const paid = planHoldCommit(
1568
+ credits.balance,
1569
+ credits.bonusCredits,
1570
+ credits.expiredHoldBacking ?? 0,
1571
+ reservation.expiredBacking ?? 0,
1572
+ amount
1573
+ );
1574
+ if (!paid.covered) {
1445
1575
  throw new CreditError(
1446
1576
  `Insufficient credits to commit reservation ${reservationId}`,
1447
1577
  CreditErrorCode.INSUFFICIENT_CREDITS,
1448
1578
  { userId, reservationId, required: amount }
1449
1579
  );
1450
1580
  }
1451
- const fromBalance = Math.min(credits.balance, amount);
1452
- const nextBalance = sumAmounts(credits.balance, -fromBalance);
1453
- const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
1581
+ const nextBalance = paid.balance;
1582
+ const nextBonusCredits = paid.bonusCredits;
1454
1583
  const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
1455
1584
  const journal = {
1456
1585
  userId,
@@ -1484,6 +1613,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1484
1613
  credits.bonusCredits = nextBonusCredits;
1485
1614
  credits.reserved = nextReserved;
1486
1615
  credits.monthlyUsed = nextMonthlyUsed;
1616
+ Object.assign(
1617
+ credits,
1618
+ expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1619
+ );
1487
1620
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1488
1621
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
1489
1622
  return {
@@ -1563,19 +1696,26 @@ function finishUnspent(store, reservation, transition) {
1563
1696
  reservation.id,
1564
1697
  reservation.amount
1565
1698
  );
1699
+ const backing = settleExpiredHoldBacking(
1700
+ credits.balance,
1701
+ credits.expiredHoldBacking ?? 0,
1702
+ reservation.expiredBacking ?? 0
1703
+ );
1704
+ const nextBalance = sumAmounts(credits.balance, -backing.used);
1566
1705
  const journal = {
1567
1706
  userId: reservation.userId,
1568
1707
  operation: `${transition.transition}Reservation`,
1569
1708
  entryType: "credit",
1570
1709
  amount: 0,
1571
- balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
1710
+ balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1572
1711
  source: transition.source,
1573
1712
  reservationId: reservation.id,
1574
1713
  description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1575
1714
  metadata: {
1576
1715
  ...transition.options?.metadata,
1577
1716
  operationType: reservation.operationType,
1578
- amount: reservation.amount
1717
+ amount: reservation.amount,
1718
+ ...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
1579
1719
  },
1580
1720
  idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
1581
1721
  };
@@ -1588,6 +1728,8 @@ function finishUnspent(store, reservation, transition) {
1588
1728
  reservation.status = transition.status;
1589
1729
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1590
1730
  credits.reserved = nextReserved;
1731
+ credits.balance = nextBalance;
1732
+ Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1591
1733
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1592
1734
  return commitTransitionJournal(store, journal, existingEntryId);
1593
1735
  }
@@ -1598,6 +1740,10 @@ function isoOf(value) {
1598
1740
  }
1599
1741
  var InMemoryCreditRepository = class {
1600
1742
  store = new MemoryStore();
1743
+ options;
1744
+ constructor(options = {}) {
1745
+ this.options = options;
1746
+ }
1601
1747
  // ==================== User Credits ====================
1602
1748
  /**
1603
1749
  * A copy, never the live record.
@@ -1614,7 +1760,8 @@ var InMemoryCreditRepository = class {
1614
1760
  return copyRecord(credits) ?? null;
1615
1761
  }
1616
1762
  async initializeUserCredits(userId, tier, initialBalance) {
1617
- return copyRecord(this.seedUser(userId, tier, initialBalance));
1763
+ const existing = this.store.users.get(userId);
1764
+ return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
1618
1765
  }
1619
1766
  /**
1620
1767
  * The synchronous body of `initializeUserCredits`.
@@ -1665,8 +1812,11 @@ var InMemoryCreditRepository = class {
1665
1812
  if (!credits) {
1666
1813
  throw new Error(`User ${userId} not found`);
1667
1814
  }
1815
+ assertValidBalanceUpdate(credits, updates);
1668
1816
  const next = { ...credits };
1669
- if (updates.balance !== void 0) next.balance = updates.balance;
1817
+ if (updates.balance !== void 0) {
1818
+ next.balance = updates.balance;
1819
+ }
1670
1820
  if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
1671
1821
  if (updates.reserved !== void 0) next.reserved = updates.reserved;
1672
1822
  if (updates.tier !== void 0) next.tier = updates.tier;
@@ -1703,6 +1853,9 @@ var InMemoryCreditRepository = class {
1703
1853
  userId,
1704
1854
  operation: "updateUserCredits"
1705
1855
  });
1856
+ if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
1857
+ writeExpiredHoldBacking(this.store, next, 0);
1858
+ }
1706
1859
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1707
1860
  this.store.users.set(userId, next);
1708
1861
  }
@@ -1711,17 +1864,28 @@ var InMemoryCreditRepository = class {
1711
1864
  { monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
1712
1865
  { userId, operation: "updateUserTier" }
1713
1866
  );
1867
+ assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
1714
1868
  const credits = this.store.users.get(userId);
1715
1869
  if (!credits) {
1716
1870
  throw new Error(`User ${userId} not found`);
1717
1871
  }
1872
+ if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1873
+ const capped = planBalanceCap(
1874
+ credits.balance,
1875
+ input.balanceCap,
1876
+ credits.reserved,
1877
+ credits.bonusCredits,
1878
+ credits.expiredHoldBacking
1879
+ );
1880
+ credits.balance = capped.balance;
1881
+ writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
1882
+ }
1718
1883
  credits.tier = input.tier;
1719
1884
  credits.monthlyLimit = input.monthlyLimit;
1720
1885
  if (input.balance !== void 0) {
1721
- credits.balance = Math.max(
1722
- input.balance,
1723
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
1724
- );
1886
+ const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1887
+ credits.balance = planned.balance;
1888
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1725
1889
  }
1726
1890
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1727
1891
  if (input.subscriptionExpiresAt !== void 0) {
@@ -1942,13 +2106,17 @@ var InMemoryCreditRepository = class {
1942
2106
  if (!credits) {
1943
2107
  throw new Error(`User credits not found for userId: ${userId}`);
1944
2108
  }
1945
- const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
2109
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
2110
+ const available = Math.min(
2111
+ sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
2112
+ sumAmounts(spendable, credits.bonusCredits)
2113
+ );
1946
2114
  if (available < amount) {
1947
2115
  throw new Error(
1948
2116
  `Insufficient credits. Available: ${available}, requested: ${amount}`
1949
2117
  );
1950
2118
  }
1951
- const balanceDeduction = Math.min(credits.balance, amount);
2119
+ const balanceDeduction = Math.min(spendable, amount);
1952
2120
  const bonusDeduction = sumAmounts(amount, -balanceDeduction);
1953
2121
  const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
1954
2122
  const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
@@ -2185,7 +2353,11 @@ var InMemoryCreditRepository = class {
2185
2353
  return { expiredCount, creditsReleased, errors };
2186
2354
  }
2187
2355
  // ==================== Atomic Monthly Reset ====================
2188
- async atomicMonthlyReset(userId, tier, expectedResetAt) {
2356
+ /**
2357
+ * @param _tier - Not trusted. The caller read it earlier and the account may
2358
+ * have changed tier since, so the reset uses the tier stored on the account.
2359
+ */
2360
+ async atomicMonthlyReset(userId, _tier, expectedResetAt) {
2189
2361
  const credits = this.store.users.get(userId);
2190
2362
  if (!credits) {
2191
2363
  throw new Error(`User ${userId} not found`);
@@ -2195,17 +2367,41 @@ var InMemoryCreditRepository = class {
2195
2367
  if (currentResetAt !== expected) {
2196
2368
  return { wasReset: false, credits: { ...credits } };
2197
2369
  }
2198
- assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2370
+ const tier = credits.tier;
2371
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
2372
+ const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
2373
+ if (decision === null) {
2374
+ return { wasReset: false, credits: { ...credits } };
2375
+ }
2376
+ if (decision) {
2377
+ assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
2378
+ if (decision.monthlyLimit !== void 0) {
2379
+ assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
2380
+ userId,
2381
+ tier
2382
+ });
2383
+ }
2384
+ } else {
2385
+ assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2386
+ }
2199
2387
  const nextReset = getNextMonthlyReset();
2200
- const target = monthlyResetBalance(tier);
2201
- const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
2202
2388
  const previousBalance = credits.balance;
2203
- const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
2389
+ let target;
2390
+ if (decision) {
2391
+ target = decision.balance;
2392
+ } else {
2393
+ const configured = monthlyResetBalance(tier);
2394
+ target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
2395
+ }
2396
+ const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
2397
+ const newBalance = planned.balance;
2204
2398
  const change = sumAmounts(newBalance, -previousBalance);
2205
2399
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2206
2400
  assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
2207
2401
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2208
2402
  credits.balance = newBalance;
2403
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2404
+ if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
2209
2405
  credits.monthlyUsed = 0;
2210
2406
  credits.monthlyResetAt = nextReset.toISOString();
2211
2407
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -2259,23 +2455,34 @@ var InMemoryCreditRepository = class {
2259
2455
  };
2260
2456
  }
2261
2457
  const defaultTier = getDefaultTier();
2262
- const configured = getConfigMonthlyLimit(defaultTier);
2263
- assertRepresentableTierAmount(configured, "monthlyLimit", {
2264
- userId,
2265
- tier: defaultTier
2266
- });
2267
- const limit = storedMonthlyLimit(configured);
2458
+ let limit;
2459
+ if (this.options.getDowngradeBalanceCap) {
2460
+ limit = this.options.getDowngradeBalanceCap();
2461
+ assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
2462
+ } else {
2463
+ const configured = getConfigMonthlyLimit(defaultTier);
2464
+ assertRepresentableTierAmount(configured, "monthlyLimit", {
2465
+ userId,
2466
+ tier: defaultTier
2467
+ });
2468
+ limit = storedMonthlyLimit(configured);
2469
+ }
2268
2470
  const previousTier = credits.tier;
2269
2471
  const previousBalance = credits.balance;
2270
- const newBalance = Math.max(
2271
- Math.min(credits.balance, limit),
2272
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
2472
+ const planned = planBalanceCap(
2473
+ credits.balance,
2474
+ limit,
2475
+ credits.reserved,
2476
+ credits.bonusCredits,
2477
+ credits.expiredHoldBacking
2273
2478
  );
2479
+ const newBalance = planned.balance;
2274
2480
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2275
2481
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2276
2482
  credits.tier = defaultTier;
2277
2483
  credits.monthlyLimit = limit;
2278
2484
  credits.balance = newBalance;
2485
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2279
2486
  credits.subscriptionExpiresAt = null;
2280
2487
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2281
2488
  this.store.users.set(userId, credits);
@@ -2333,8 +2540,8 @@ var InMemoryCreditRepository = class {
2333
2540
  return copyRecords(Array.from(userReservations.values()));
2334
2541
  }
2335
2542
  };
2336
- function createInMemoryCreditRepository() {
2337
- return new InMemoryCreditRepository();
2543
+ function createInMemoryCreditRepository(options = {}) {
2544
+ return new InMemoryCreditRepository(options);
2338
2545
  }
2339
2546
 
2340
2547
  // src/auth/api-key.ts
@@ -2497,9 +2704,11 @@ var CreditsService = class {
2497
2704
  source: "monthly_reset",
2498
2705
  referenceId: `reset-${Date.now()}`,
2499
2706
  referenceType: "reset",
2500
- description: `Monthly credit reset for ${data.tier} tier.`,
2707
+ // The tier the reset ran on, which a concurrent downgrade may have
2708
+ // changed since `data` was read.
2709
+ description: `Monthly credit reset for ${resetResult.credits.tier} tier.`,
2501
2710
  metadata: {
2502
- tier: data.tier,
2711
+ tier: resetResult.credits.tier,
2503
2712
  previousBalance: data.balance,
2504
2713
  newBalance: resetResult.credits.balance
2505
2714
  }
@@ -3785,12 +3994,17 @@ export {
3785
3994
  assertRepresentableTierAmount,
3786
3995
  assertUnkeyedDirectReservation,
3787
3996
  assertUnreferencedDirectTransaction,
3997
+ assertValidBalanceTarget,
3998
+ assertValidBalanceUpdate,
3788
3999
  assertValidCreditAmount,
3789
4000
  assertValidIdempotencyKey,
3790
4001
  assertValidStoredAmount,
3791
4002
  assertValidStoredAmountRaw,
4003
+ assertValidTierBalanceInput,
4004
+ attributeExpiredBacking,
3792
4005
  backedBalanceFloor,
3793
4006
  calculateAvailableCredits,
4007
+ cappedBalance,
3794
4008
  checkAndNotifyLowBalance,
3795
4009
  classifyDatabaseError,
3796
4010
  clearConfigProvider,
@@ -3816,6 +4030,7 @@ export {
3816
4030
  createUnsupportedOperationError,
3817
4031
  createUserNotFoundError,
3818
4032
  describePaymentMismatch,
4033
+ expiredHoldBackingPatch,
3819
4034
  generateId,
3820
4035
  generateRequestId2 as generateRequestId,
3821
4036
  genericDeferred,
@@ -3874,6 +4089,9 @@ export {
3874
4089
  parseApiError,
3875
4090
  parseCreditError,
3876
4091
  parseTier,
4092
+ planBalanceCap,
4093
+ planBalanceTarget,
4094
+ planHoldCommit,
3877
4095
  registerConfigProvider,
3878
4096
  registerNotificationHandler,
3879
4097
  releaseReservationWithJournal,
@@ -3883,6 +4101,8 @@ export {
3883
4101
  reserveThroughRepository,
3884
4102
  resetConfig,
3885
4103
  sameAmount,
4104
+ settleExpiredHoldBacking,
4105
+ spendableBalance,
3886
4106
  storedMonthlyLimit,
3887
4107
  sumAmounts,
3888
4108
  supportsCreditsV2,
@@ -3894,6 +4114,7 @@ export {
3894
4114
  toClientUserCredits,
3895
4115
  toDate,
3896
4116
  toPortableTimestamp,
4117
+ validateBalanceUpdate,
3897
4118
  verifyAdminApiKey,
3898
4119
  verifyBearerToken
3899
4120
  };