@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
@@ -1,6 +1,6 @@
1
- import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, l as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, m as ReserveCreditsV2Input, n as ReserveOutcome, g as CreditBalanceUpdate, o as TierUpdateInput, d as CreateReservationInput, i as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-wtksMa4n.cjs';
2
- export { h as CreditRepositoryFactory, j as ICreditRepositoryCreditsV2, k as ICreditRepositoryV2, s as reservationJournalKey, t as supportsCreditsV2, u as supportsIdempotentCredit, v as toClientUserCredits } from '../types-wtksMa4n.cjs';
3
- import { c as PortableUserCredits, S as SubscriptionTier, a as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-Dzc443vy.cjs';
1
+ import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, m as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, n as ReserveCreditsV2Input, o as ReserveOutcome, g as CreditBalanceUpdate, h as CreditPolicyOptions, p as TierUpdateInput, d as CreateReservationInput, j as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-XSjNXO5l.cjs';
2
+ export { i as CreditRepositoryFactory, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from '../types-XSjNXO5l.cjs';
3
+ import { c as PortableUserCredits, S as SubscriptionTier, a as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-MaViK5tD.cjs';
4
4
 
5
5
  /**
6
6
  * One reservation flow for both repository generations.
@@ -62,6 +62,7 @@ declare function addCreditsThroughRepository(repository: ICreditRepository, inpu
62
62
  * These are pure functions with no server-only dependencies,
63
63
  * making them safe to use in any repository implementation.
64
64
  */
65
+
65
66
  /**
66
67
  * Generate a unique ID
67
68
  * Uses timestamp + random string for uniqueness
@@ -77,6 +78,27 @@ declare function toDate(value: Date | string | unknown): Date;
77
78
  * Used for calculating monthly reset dates
78
79
  */
79
80
  declare function getNextMonthlyReset(from?: Date): Date;
81
+ /** Result of validating a balance update. */
82
+ interface BalanceValidationResult {
83
+ valid: boolean;
84
+ errors: string[];
85
+ }
86
+ /**
87
+ * Whether an update leaves a valid account: no negative field, and `reserved` covered
88
+ * by `balance + bonusCredits`, which is the floor outstanding holds put under the
89
+ * balance. An increment is applied on top of the absolute value when a call names both.
90
+ *
91
+ * An update is a delta the caller asked for, so one that cannot be honoured is refused,
92
+ * as a deduct is. Only tier writes and resets stop at the floor: they state a target.
93
+ *
94
+ * A negative balance increment is a charge, so it draws only on the balance above
95
+ * `expiredHoldBacking`: those credits are expired and leave when their hold closes, so
96
+ * charging them would hand the charge back. An absolute balance replaces the field and
97
+ * the marks with it, so only an increment on the stored balance meets this limit.
98
+ */
99
+ declare function validateBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): BalanceValidationResult;
100
+ /** Refuse an update {@link validateBalanceUpdate} finds invalid. */
101
+ declare function assertValidBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): void;
80
102
 
81
103
  /**
82
104
  * In-Memory Credit Repository Implementation
@@ -92,6 +114,8 @@ declare function getNextMonthlyReset(from?: Date): Date;
92
114
 
93
115
  declare class InMemoryCreditRepository implements ICreditRepository {
94
116
  private readonly store;
117
+ private readonly options;
118
+ constructor(options?: CreditPolicyOptions);
95
119
  /**
96
120
  * A copy, never the live record.
97
121
  *
@@ -192,7 +216,11 @@ declare class InMemoryCreditRepository implements ICreditRepository {
192
216
  creditsReleased: number;
193
217
  errors: string[];
194
218
  }>;
195
- atomicMonthlyReset(userId: string, tier: SubscriptionTier, expectedResetAt: Date | string): Promise<MonthlyResetResult>;
219
+ /**
220
+ * @param _tier - Not trusted. The caller read it earlier and the account may
221
+ * have changed tier since, so the reset uses the tier stored on the account.
222
+ */
223
+ atomicMonthlyReset(userId: string, _tier: SubscriptionTier, expectedResetAt: Date | string): Promise<MonthlyResetResult>;
196
224
  checkAndHandleSubscriptionExpiry(userId: string, gracePeriodDays?: number): Promise<SubscriptionExpiryResult>;
197
225
  /**
198
226
  * Clear all data (useful for testing)
@@ -221,6 +249,6 @@ declare class InMemoryCreditRepository implements ICreditRepository {
221
249
  * Create a new in-memory repository instance
222
250
  * Each call creates a fresh, isolated instance
223
251
  */
224
- declare function createInMemoryCreditRepository(): InMemoryCreditRepository;
252
+ declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
225
253
 
226
- export { AddCreditsAtomicOptions, AddCreditsV2Input, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate };
254
+ export { AddCreditsAtomicOptions, AddCreditsV2Input, type BalanceValidationResult, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, assertValidBalanceUpdate, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate, validateBalanceUpdate };
@@ -1,6 +1,6 @@
1
- import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, l as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, m as ReserveCreditsV2Input, n as ReserveOutcome, g as CreditBalanceUpdate, o as TierUpdateInput, d as CreateReservationInput, i as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-BhCdep_t.js';
2
- export { h as CreditRepositoryFactory, j as ICreditRepositoryCreditsV2, k as ICreditRepositoryV2, s as reservationJournalKey, t as supportsCreditsV2, u as supportsIdempotentCredit, v as toClientUserCredits } from '../types-BhCdep_t.js';
3
- import { c as PortableUserCredits, S as SubscriptionTier, a as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-Dzc443vy.js';
1
+ import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, m as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, n as ReserveCreditsV2Input, o as ReserveOutcome, g as CreditBalanceUpdate, h as CreditPolicyOptions, p as TierUpdateInput, d as CreateReservationInput, j as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-3c-IAwZI.js';
2
+ export { i as CreditRepositoryFactory, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from '../types-3c-IAwZI.js';
3
+ import { c as PortableUserCredits, S as SubscriptionTier, a as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-MaViK5tD.js';
4
4
 
5
5
  /**
6
6
  * One reservation flow for both repository generations.
@@ -62,6 +62,7 @@ declare function addCreditsThroughRepository(repository: ICreditRepository, inpu
62
62
  * These are pure functions with no server-only dependencies,
63
63
  * making them safe to use in any repository implementation.
64
64
  */
65
+
65
66
  /**
66
67
  * Generate a unique ID
67
68
  * Uses timestamp + random string for uniqueness
@@ -77,6 +78,27 @@ declare function toDate(value: Date | string | unknown): Date;
77
78
  * Used for calculating monthly reset dates
78
79
  */
79
80
  declare function getNextMonthlyReset(from?: Date): Date;
81
+ /** Result of validating a balance update. */
82
+ interface BalanceValidationResult {
83
+ valid: boolean;
84
+ errors: string[];
85
+ }
86
+ /**
87
+ * Whether an update leaves a valid account: no negative field, and `reserved` covered
88
+ * by `balance + bonusCredits`, which is the floor outstanding holds put under the
89
+ * balance. An increment is applied on top of the absolute value when a call names both.
90
+ *
91
+ * An update is a delta the caller asked for, so one that cannot be honoured is refused,
92
+ * as a deduct is. Only tier writes and resets stop at the floor: they state a target.
93
+ *
94
+ * A negative balance increment is a charge, so it draws only on the balance above
95
+ * `expiredHoldBacking`: those credits are expired and leave when their hold closes, so
96
+ * charging them would hand the charge back. An absolute balance replaces the field and
97
+ * the marks with it, so only an increment on the stored balance meets this limit.
98
+ */
99
+ declare function validateBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): BalanceValidationResult;
100
+ /** Refuse an update {@link validateBalanceUpdate} finds invalid. */
101
+ declare function assertValidBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): void;
80
102
 
81
103
  /**
82
104
  * In-Memory Credit Repository Implementation
@@ -92,6 +114,8 @@ declare function getNextMonthlyReset(from?: Date): Date;
92
114
 
93
115
  declare class InMemoryCreditRepository implements ICreditRepository {
94
116
  private readonly store;
117
+ private readonly options;
118
+ constructor(options?: CreditPolicyOptions);
95
119
  /**
96
120
  * A copy, never the live record.
97
121
  *
@@ -192,7 +216,11 @@ declare class InMemoryCreditRepository implements ICreditRepository {
192
216
  creditsReleased: number;
193
217
  errors: string[];
194
218
  }>;
195
- atomicMonthlyReset(userId: string, tier: SubscriptionTier, expectedResetAt: Date | string): Promise<MonthlyResetResult>;
219
+ /**
220
+ * @param _tier - Not trusted. The caller read it earlier and the account may
221
+ * have changed tier since, so the reset uses the tier stored on the account.
222
+ */
223
+ atomicMonthlyReset(userId: string, _tier: SubscriptionTier, expectedResetAt: Date | string): Promise<MonthlyResetResult>;
196
224
  checkAndHandleSubscriptionExpiry(userId: string, gracePeriodDays?: number): Promise<SubscriptionExpiryResult>;
197
225
  /**
198
226
  * Clear all data (useful for testing)
@@ -221,6 +249,6 @@ declare class InMemoryCreditRepository implements ICreditRepository {
221
249
  * Create a new in-memory repository instance
222
250
  * Each call creates a fresh, isolated instance
223
251
  */
224
- declare function createInMemoryCreditRepository(): InMemoryCreditRepository;
252
+ declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
225
253
 
226
- export { AddCreditsAtomicOptions, AddCreditsV2Input, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate };
254
+ export { AddCreditsAtomicOptions, AddCreditsV2Input, type BalanceValidationResult, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, assertValidBalanceUpdate, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate, validateBalanceUpdate };
@@ -16,6 +16,8 @@ function toClientUserCredits(credits) {
16
16
  monthlyUsed: credits.monthlyUsed,
17
17
  monthlyResetAt: toISOString(credits.monthlyResetAt),
18
18
  subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
19
+ ...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
20
+ ...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
19
21
  createdAt: toISOString(credits.createdAt),
20
22
  updatedAt: toISOString(credits.updatedAt)
21
23
  };
@@ -106,6 +108,83 @@ function backedBalanceFloor(reserved, bonusCredits) {
106
108
  assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
107
109
  return floor;
108
110
  }
111
+ function spendableBalance(balance, expiredHoldBacking = 0) {
112
+ return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
113
+ }
114
+ function planBalanceTarget(target, reserved, bonusCredits) {
115
+ const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
116
+ return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
117
+ }
118
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
119
+ const spendable = spendableBalance(balance, expiredHoldBacking);
120
+ const target = Math.min(spendable, cap);
121
+ const kept = Math.max(
122
+ sumAmounts(balance, -spendable),
123
+ sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
124
+ );
125
+ const next = Math.min(balance, sumAmounts(target, kept));
126
+ return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
127
+ }
128
+ function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
129
+ const held = Math.min(expiredHoldBacking, balance);
130
+ const used = Math.min(held, holdBacking);
131
+ return { used, remaining: sumAmounts(held, -used) };
132
+ }
133
+ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
134
+ const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
135
+ const rest = sumAmounts(amount, -backing.used);
136
+ const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
137
+ const fromBonus = sumAmounts(rest, -fromSpendable);
138
+ return {
139
+ covered: fromBonus <= bonusCredits,
140
+ balance: sumAmounts(balance, -backing.used, -fromSpendable),
141
+ bonusCredits: sumAmounts(bonusCredits, -fromBonus),
142
+ expiredHoldBacking: backing.remaining
143
+ };
144
+ }
145
+ function attributeExpiredBacking(holds, total) {
146
+ const placed = holds.filter((hold) => hold.holdPlacedAt).sort(
147
+ (a, b) => a.holdPlacedAt === b.holdPlacedAt ? a.id < b.id ? -1 : 1 : a.holdPlacedAt < b.holdPlacedAt ? -1 : 1
148
+ );
149
+ const shares = /* @__PURE__ */ new Map();
150
+ let left = total;
151
+ for (const hold of placed) {
152
+ const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);
153
+ shares.set(hold.id, kept);
154
+ left = sumAmounts(left, -kept);
155
+ }
156
+ for (const hold of placed) {
157
+ const share = shares.get(hold.id);
158
+ const added = Math.min(sumAmounts(hold.amount, -share), left);
159
+ shares.set(hold.id, sumAmounts(share, added));
160
+ left = sumAmounts(left, -added);
161
+ }
162
+ return shares;
163
+ }
164
+ function expiredHoldBackingPatch(next, stored = 0) {
165
+ return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
166
+ }
167
+ function assertValidBalanceTarget(value, field, context) {
168
+ assertRepresentableAmount(value, field, context);
169
+ if (value < 0) {
170
+ throw new CreditError(
171
+ `${field} cannot be negative (got ${value})`,
172
+ CreditErrorCode.INVALID_AMOUNT,
173
+ { field, value, ...context }
174
+ );
175
+ }
176
+ }
177
+ function assertValidTierBalanceInput(input, context) {
178
+ if (input.balanceCap === void 0) return;
179
+ if (input.balance !== void 0) {
180
+ throw new CreditError(
181
+ "A tier update cannot set both balance and balanceCap",
182
+ CreditErrorCode.INVALID_AMOUNT,
183
+ { balance: input.balance, balanceCap: input.balanceCap, ...context }
184
+ );
185
+ }
186
+ assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
187
+ }
109
188
  function assertRepresentableTierAmount(value, field, context) {
110
189
  if (value === Number.POSITIVE_INFINITY) return;
111
190
  assertRepresentableAmount(value, field, context);
@@ -323,9 +402,9 @@ var creditSystemConfigSchema = z.object({
323
402
  */
324
403
  reservationExpiryMs: z.number().positive().default(5 * 60 * 1e3),
325
404
  /**
326
- * Default credits for new users (free tier)
405
+ * Default credits for new users (free tier). 0 means no signup grant.
327
406
  */
328
- defaultFreeCredits: z.number().positive().default(25),
407
+ defaultFreeCredits: z.number().min(0).default(25),
329
408
  /**
330
409
  * Grace period in days after subscription expires before downgrade
331
410
  */
@@ -755,11 +834,36 @@ function toDate(value) {
755
834
  return /* @__PURE__ */ new Date();
756
835
  }
757
836
  function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
758
- const next = new Date(from);
759
- next.setMonth(next.getMonth() + 1);
760
- next.setDate(1);
761
- next.setHours(0, 0, 0, 0);
762
- return next;
837
+ return new Date(from.getFullYear(), from.getMonth() + 1, 1);
838
+ }
839
+ function validateBalanceUpdate(current, updates) {
840
+ const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
841
+ const balance = next(current.balance, updates.balance, updates.balanceIncrement);
842
+ const bonusCredits = next(
843
+ current.bonusCredits ?? 0,
844
+ updates.bonusCredits,
845
+ updates.bonusCreditsIncrement
846
+ );
847
+ const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
848
+ const errors = [];
849
+ if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
850
+ const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
851
+ if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
852
+ errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
853
+ }
854
+ if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
855
+ if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
856
+ const covering = sumAmounts(balance, bonusCredits);
857
+ if (reserved > covering) {
858
+ errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
859
+ }
860
+ return { valid: errors.length === 0, errors };
861
+ }
862
+ function assertValidBalanceUpdate(current, updates) {
863
+ const result = validateBalanceUpdate(current, updates);
864
+ if (!result.valid) {
865
+ throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
866
+ }
763
867
  }
764
868
 
765
869
  // src/repository/memory/snapshot.ts
@@ -1040,6 +1144,22 @@ function assertTrustworthy(reservation, transition) {
1040
1144
  assertLockedAmount(reservation, transition);
1041
1145
  assertHoldPlaced(reservation, transition);
1042
1146
  }
1147
+ function openHolds(store, userId) {
1148
+ return [...store.reservations.get(userId)?.values() ?? []].filter(
1149
+ (reservation) => reservation.status === "reserved"
1150
+ );
1151
+ }
1152
+ function writeExpiredHoldBacking(store, credits, next) {
1153
+ const stored = credits.expiredHoldBacking;
1154
+ if (next <= 0 && !stored) return;
1155
+ const holds = openHolds(store, credits.userId);
1156
+ const shares = attributeExpiredBacking(holds, next);
1157
+ for (const hold of holds) {
1158
+ const share = shares.get(hold.id) ?? 0;
1159
+ if (share !== (hold.expiredBacking ?? 0)) hold.expiredBacking = share;
1160
+ }
1161
+ Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
1162
+ }
1043
1163
  function getReservation(store, userId, reservationId) {
1044
1164
  return store.reservations.get(userId)?.get(reservationId);
1045
1165
  }
@@ -1137,16 +1257,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
1137
1257
  const amount = reservation.amount;
1138
1258
  await yieldPoint(store);
1139
1259
  assertReservedCoversHold(credits, userId, reservationId, amount);
1140
- if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
1260
+ const paid = planHoldCommit(
1261
+ credits.balance,
1262
+ credits.bonusCredits,
1263
+ credits.expiredHoldBacking ?? 0,
1264
+ reservation.expiredBacking ?? 0,
1265
+ amount
1266
+ );
1267
+ if (!paid.covered) {
1141
1268
  throw new CreditError(
1142
1269
  `Insufficient credits to commit reservation ${reservationId}`,
1143
1270
  CreditErrorCode.INSUFFICIENT_CREDITS,
1144
1271
  { userId, reservationId, required: amount }
1145
1272
  );
1146
1273
  }
1147
- const fromBalance = Math.min(credits.balance, amount);
1148
- const nextBalance = sumAmounts(credits.balance, -fromBalance);
1149
- const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
1274
+ const nextBalance = paid.balance;
1275
+ const nextBonusCredits = paid.bonusCredits;
1150
1276
  const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
1151
1277
  const journal = {
1152
1278
  userId,
@@ -1180,6 +1306,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1180
1306
  credits.bonusCredits = nextBonusCredits;
1181
1307
  credits.reserved = nextReserved;
1182
1308
  credits.monthlyUsed = nextMonthlyUsed;
1309
+ Object.assign(
1310
+ credits,
1311
+ expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1312
+ );
1183
1313
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1184
1314
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
1185
1315
  return {
@@ -1259,19 +1389,26 @@ function finishUnspent(store, reservation, transition) {
1259
1389
  reservation.id,
1260
1390
  reservation.amount
1261
1391
  );
1392
+ const backing = settleExpiredHoldBacking(
1393
+ credits.balance,
1394
+ credits.expiredHoldBacking ?? 0,
1395
+ reservation.expiredBacking ?? 0
1396
+ );
1397
+ const nextBalance = sumAmounts(credits.balance, -backing.used);
1262
1398
  const journal = {
1263
1399
  userId: reservation.userId,
1264
1400
  operation: `${transition.transition}Reservation`,
1265
1401
  entryType: "credit",
1266
1402
  amount: 0,
1267
- balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
1403
+ balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1268
1404
  source: transition.source,
1269
1405
  reservationId: reservation.id,
1270
1406
  description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1271
1407
  metadata: {
1272
1408
  ...transition.options?.metadata,
1273
1409
  operationType: reservation.operationType,
1274
- amount: reservation.amount
1410
+ amount: reservation.amount,
1411
+ ...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
1275
1412
  },
1276
1413
  idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
1277
1414
  };
@@ -1284,6 +1421,8 @@ function finishUnspent(store, reservation, transition) {
1284
1421
  reservation.status = transition.status;
1285
1422
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1286
1423
  credits.reserved = nextReserved;
1424
+ credits.balance = nextBalance;
1425
+ Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1287
1426
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1288
1427
  return commitTransitionJournal(store, journal, existingEntryId);
1289
1428
  }
@@ -1294,6 +1433,10 @@ function isoOf(value) {
1294
1433
  }
1295
1434
  var InMemoryCreditRepository = class {
1296
1435
  store = new MemoryStore();
1436
+ options;
1437
+ constructor(options = {}) {
1438
+ this.options = options;
1439
+ }
1297
1440
  // ==================== User Credits ====================
1298
1441
  /**
1299
1442
  * A copy, never the live record.
@@ -1310,7 +1453,8 @@ var InMemoryCreditRepository = class {
1310
1453
  return copyRecord(credits) ?? null;
1311
1454
  }
1312
1455
  async initializeUserCredits(userId, tier, initialBalance) {
1313
- return copyRecord(this.seedUser(userId, tier, initialBalance));
1456
+ const existing = this.store.users.get(userId);
1457
+ return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
1314
1458
  }
1315
1459
  /**
1316
1460
  * The synchronous body of `initializeUserCredits`.
@@ -1361,8 +1505,11 @@ var InMemoryCreditRepository = class {
1361
1505
  if (!credits) {
1362
1506
  throw new Error(`User ${userId} not found`);
1363
1507
  }
1508
+ assertValidBalanceUpdate(credits, updates);
1364
1509
  const next = { ...credits };
1365
- if (updates.balance !== void 0) next.balance = updates.balance;
1510
+ if (updates.balance !== void 0) {
1511
+ next.balance = updates.balance;
1512
+ }
1366
1513
  if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
1367
1514
  if (updates.reserved !== void 0) next.reserved = updates.reserved;
1368
1515
  if (updates.tier !== void 0) next.tier = updates.tier;
@@ -1399,6 +1546,9 @@ var InMemoryCreditRepository = class {
1399
1546
  userId,
1400
1547
  operation: "updateUserCredits"
1401
1548
  });
1549
+ if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
1550
+ writeExpiredHoldBacking(this.store, next, 0);
1551
+ }
1402
1552
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1403
1553
  this.store.users.set(userId, next);
1404
1554
  }
@@ -1407,17 +1557,28 @@ var InMemoryCreditRepository = class {
1407
1557
  { monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
1408
1558
  { userId, operation: "updateUserTier" }
1409
1559
  );
1560
+ assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
1410
1561
  const credits = this.store.users.get(userId);
1411
1562
  if (!credits) {
1412
1563
  throw new Error(`User ${userId} not found`);
1413
1564
  }
1565
+ if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1566
+ const capped = planBalanceCap(
1567
+ credits.balance,
1568
+ input.balanceCap,
1569
+ credits.reserved,
1570
+ credits.bonusCredits,
1571
+ credits.expiredHoldBacking
1572
+ );
1573
+ credits.balance = capped.balance;
1574
+ writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
1575
+ }
1414
1576
  credits.tier = input.tier;
1415
1577
  credits.monthlyLimit = input.monthlyLimit;
1416
1578
  if (input.balance !== void 0) {
1417
- credits.balance = Math.max(
1418
- input.balance,
1419
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
1420
- );
1579
+ const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1580
+ credits.balance = planned.balance;
1581
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1421
1582
  }
1422
1583
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1423
1584
  if (input.subscriptionExpiresAt !== void 0) {
@@ -1638,13 +1799,17 @@ var InMemoryCreditRepository = class {
1638
1799
  if (!credits) {
1639
1800
  throw new Error(`User credits not found for userId: ${userId}`);
1640
1801
  }
1641
- const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
1802
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
1803
+ const available = Math.min(
1804
+ sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
1805
+ sumAmounts(spendable, credits.bonusCredits)
1806
+ );
1642
1807
  if (available < amount) {
1643
1808
  throw new Error(
1644
1809
  `Insufficient credits. Available: ${available}, requested: ${amount}`
1645
1810
  );
1646
1811
  }
1647
- const balanceDeduction = Math.min(credits.balance, amount);
1812
+ const balanceDeduction = Math.min(spendable, amount);
1648
1813
  const bonusDeduction = sumAmounts(amount, -balanceDeduction);
1649
1814
  const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
1650
1815
  const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
@@ -1881,7 +2046,11 @@ var InMemoryCreditRepository = class {
1881
2046
  return { expiredCount, creditsReleased, errors };
1882
2047
  }
1883
2048
  // ==================== Atomic Monthly Reset ====================
1884
- async atomicMonthlyReset(userId, tier, expectedResetAt) {
2049
+ /**
2050
+ * @param _tier - Not trusted. The caller read it earlier and the account may
2051
+ * have changed tier since, so the reset uses the tier stored on the account.
2052
+ */
2053
+ async atomicMonthlyReset(userId, _tier, expectedResetAt) {
1885
2054
  const credits = this.store.users.get(userId);
1886
2055
  if (!credits) {
1887
2056
  throw new Error(`User ${userId} not found`);
@@ -1891,17 +2060,41 @@ var InMemoryCreditRepository = class {
1891
2060
  if (currentResetAt !== expected) {
1892
2061
  return { wasReset: false, credits: { ...credits } };
1893
2062
  }
1894
- assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2063
+ const tier = credits.tier;
2064
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
2065
+ const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
2066
+ if (decision === null) {
2067
+ return { wasReset: false, credits: { ...credits } };
2068
+ }
2069
+ if (decision) {
2070
+ assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
2071
+ if (decision.monthlyLimit !== void 0) {
2072
+ assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
2073
+ userId,
2074
+ tier
2075
+ });
2076
+ }
2077
+ } else {
2078
+ assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2079
+ }
1895
2080
  const nextReset = getNextMonthlyReset();
1896
- const target = monthlyResetBalance(tier);
1897
- const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
1898
2081
  const previousBalance = credits.balance;
1899
- const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
2082
+ let target;
2083
+ if (decision) {
2084
+ target = decision.balance;
2085
+ } else {
2086
+ const configured = monthlyResetBalance(tier);
2087
+ target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
2088
+ }
2089
+ const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
2090
+ const newBalance = planned.balance;
1900
2091
  const change = sumAmounts(newBalance, -previousBalance);
1901
2092
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
1902
2093
  assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
1903
2094
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
1904
2095
  credits.balance = newBalance;
2096
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2097
+ if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
1905
2098
  credits.monthlyUsed = 0;
1906
2099
  credits.monthlyResetAt = nextReset.toISOString();
1907
2100
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -1955,23 +2148,34 @@ var InMemoryCreditRepository = class {
1955
2148
  };
1956
2149
  }
1957
2150
  const defaultTier = getDefaultTier();
1958
- const configured = getConfigMonthlyLimit(defaultTier);
1959
- assertRepresentableTierAmount(configured, "monthlyLimit", {
1960
- userId,
1961
- tier: defaultTier
1962
- });
1963
- const limit = storedMonthlyLimit(configured);
2151
+ let limit;
2152
+ if (this.options.getDowngradeBalanceCap) {
2153
+ limit = this.options.getDowngradeBalanceCap();
2154
+ assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
2155
+ } else {
2156
+ const configured = getConfigMonthlyLimit(defaultTier);
2157
+ assertRepresentableTierAmount(configured, "monthlyLimit", {
2158
+ userId,
2159
+ tier: defaultTier
2160
+ });
2161
+ limit = storedMonthlyLimit(configured);
2162
+ }
1964
2163
  const previousTier = credits.tier;
1965
2164
  const previousBalance = credits.balance;
1966
- const newBalance = Math.max(
1967
- Math.min(credits.balance, limit),
1968
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
2165
+ const planned = planBalanceCap(
2166
+ credits.balance,
2167
+ limit,
2168
+ credits.reserved,
2169
+ credits.bonusCredits,
2170
+ credits.expiredHoldBacking
1969
2171
  );
2172
+ const newBalance = planned.balance;
1970
2173
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
1971
2174
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
1972
2175
  credits.tier = defaultTier;
1973
2176
  credits.monthlyLimit = limit;
1974
2177
  credits.balance = newBalance;
2178
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1975
2179
  credits.subscriptionExpiresAt = null;
1976
2180
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1977
2181
  this.store.users.set(userId, credits);
@@ -2029,12 +2233,13 @@ var InMemoryCreditRepository = class {
2029
2233
  return copyRecords(Array.from(userReservations.values()));
2030
2234
  }
2031
2235
  };
2032
- function createInMemoryCreditRepository() {
2033
- return new InMemoryCreditRepository();
2236
+ function createInMemoryCreditRepository(options = {}) {
2237
+ return new InMemoryCreditRepository(options);
2034
2238
  }
2035
2239
  export {
2036
2240
  InMemoryCreditRepository,
2037
2241
  addCreditsThroughRepository,
2242
+ assertValidBalanceUpdate,
2038
2243
  commitThroughRepository,
2039
2244
  createInMemoryCreditRepository,
2040
2245
  generateId,
@@ -2045,6 +2250,7 @@ export {
2045
2250
  supportsCreditsV2,
2046
2251
  supportsIdempotentCredit,
2047
2252
  toClientUserCredits,
2048
- toDate
2253
+ toDate,
2254
+ validateBalanceUpdate
2049
2255
  };
2050
2256
  //# sourceMappingURL=index.js.map