@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
@@ -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-C8hZMe8a.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-C8hZMe8a.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-DJ8yqaVr.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-BmG5Omzj.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-BmG5Omzj.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-DJ8yqaVr.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);
@@ -295,11 +374,13 @@ var creditSystemConfigSchema = z.object({
295
374
  /**
296
375
  * Operation costs in credits
297
376
  * Keys are operation type names (any non-empty string)
298
- * Values are positive numbers representing credit cost
377
+ * Values are non-negative numbers representing credit cost. 0 marks a free
378
+ * operation: `withCredits` runs it without touching the ledger. Amounts that
379
+ * move credits stay strictly positive, so `reserveCredits` refuses a 0.
299
380
  */
300
381
  operationCosts: z.record(
301
382
  z.string().min(1),
302
- z.number().positive()
383
+ z.number().nonnegative()
303
384
  ),
304
385
  /**
305
386
  * Display labels for operation types (English canonical labels).
@@ -321,9 +402,9 @@ var creditSystemConfigSchema = z.object({
321
402
  */
322
403
  reservationExpiryMs: z.number().positive().default(5 * 60 * 1e3),
323
404
  /**
324
- * Default credits for new users (free tier)
405
+ * Default credits for new users (free tier). 0 means no signup grant.
325
406
  */
326
- defaultFreeCredits: z.number().positive().default(25),
407
+ defaultFreeCredits: z.number().min(0).default(25),
327
408
  /**
328
409
  * Grace period in days after subscription expires before downgrade
329
410
  */
@@ -753,11 +834,36 @@ function toDate(value) {
753
834
  return /* @__PURE__ */ new Date();
754
835
  }
755
836
  function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
756
- const next = new Date(from);
757
- next.setMonth(next.getMonth() + 1);
758
- next.setDate(1);
759
- next.setHours(0, 0, 0, 0);
760
- 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
+ }
761
867
  }
762
868
 
763
869
  // src/repository/memory/snapshot.ts
@@ -1038,6 +1144,22 @@ function assertTrustworthy(reservation, transition) {
1038
1144
  assertLockedAmount(reservation, transition);
1039
1145
  assertHoldPlaced(reservation, transition);
1040
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
+ }
1041
1163
  function getReservation(store, userId, reservationId) {
1042
1164
  return store.reservations.get(userId)?.get(reservationId);
1043
1165
  }
@@ -1135,16 +1257,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
1135
1257
  const amount = reservation.amount;
1136
1258
  await yieldPoint(store);
1137
1259
  assertReservedCoversHold(credits, userId, reservationId, amount);
1138
- 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) {
1139
1268
  throw new CreditError(
1140
1269
  `Insufficient credits to commit reservation ${reservationId}`,
1141
1270
  CreditErrorCode.INSUFFICIENT_CREDITS,
1142
1271
  { userId, reservationId, required: amount }
1143
1272
  );
1144
1273
  }
1145
- const fromBalance = Math.min(credits.balance, amount);
1146
- const nextBalance = sumAmounts(credits.balance, -fromBalance);
1147
- const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
1274
+ const nextBalance = paid.balance;
1275
+ const nextBonusCredits = paid.bonusCredits;
1148
1276
  const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
1149
1277
  const journal = {
1150
1278
  userId,
@@ -1178,6 +1306,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1178
1306
  credits.bonusCredits = nextBonusCredits;
1179
1307
  credits.reserved = nextReserved;
1180
1308
  credits.monthlyUsed = nextMonthlyUsed;
1309
+ Object.assign(
1310
+ credits,
1311
+ expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1312
+ );
1181
1313
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1182
1314
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
1183
1315
  return {
@@ -1257,19 +1389,26 @@ function finishUnspent(store, reservation, transition) {
1257
1389
  reservation.id,
1258
1390
  reservation.amount
1259
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);
1260
1398
  const journal = {
1261
1399
  userId: reservation.userId,
1262
1400
  operation: `${transition.transition}Reservation`,
1263
1401
  entryType: "credit",
1264
1402
  amount: 0,
1265
- balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
1403
+ balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1266
1404
  source: transition.source,
1267
1405
  reservationId: reservation.id,
1268
1406
  description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1269
1407
  metadata: {
1270
1408
  ...transition.options?.metadata,
1271
1409
  operationType: reservation.operationType,
1272
- amount: reservation.amount
1410
+ amount: reservation.amount,
1411
+ ...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
1273
1412
  },
1274
1413
  idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
1275
1414
  };
@@ -1282,6 +1421,8 @@ function finishUnspent(store, reservation, transition) {
1282
1421
  reservation.status = transition.status;
1283
1422
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1284
1423
  credits.reserved = nextReserved;
1424
+ credits.balance = nextBalance;
1425
+ Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1285
1426
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1286
1427
  return commitTransitionJournal(store, journal, existingEntryId);
1287
1428
  }
@@ -1292,6 +1433,10 @@ function isoOf(value) {
1292
1433
  }
1293
1434
  var InMemoryCreditRepository = class {
1294
1435
  store = new MemoryStore();
1436
+ options;
1437
+ constructor(options = {}) {
1438
+ this.options = options;
1439
+ }
1295
1440
  // ==================== User Credits ====================
1296
1441
  /**
1297
1442
  * A copy, never the live record.
@@ -1308,7 +1453,8 @@ var InMemoryCreditRepository = class {
1308
1453
  return copyRecord(credits) ?? null;
1309
1454
  }
1310
1455
  async initializeUserCredits(userId, tier, initialBalance) {
1311
- return copyRecord(this.seedUser(userId, tier, initialBalance));
1456
+ const existing = this.store.users.get(userId);
1457
+ return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
1312
1458
  }
1313
1459
  /**
1314
1460
  * The synchronous body of `initializeUserCredits`.
@@ -1359,8 +1505,11 @@ var InMemoryCreditRepository = class {
1359
1505
  if (!credits) {
1360
1506
  throw new Error(`User ${userId} not found`);
1361
1507
  }
1508
+ assertValidBalanceUpdate(credits, updates);
1362
1509
  const next = { ...credits };
1363
- if (updates.balance !== void 0) next.balance = updates.balance;
1510
+ if (updates.balance !== void 0) {
1511
+ next.balance = updates.balance;
1512
+ }
1364
1513
  if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
1365
1514
  if (updates.reserved !== void 0) next.reserved = updates.reserved;
1366
1515
  if (updates.tier !== void 0) next.tier = updates.tier;
@@ -1397,6 +1546,9 @@ var InMemoryCreditRepository = class {
1397
1546
  userId,
1398
1547
  operation: "updateUserCredits"
1399
1548
  });
1549
+ if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
1550
+ writeExpiredHoldBacking(this.store, next, 0);
1551
+ }
1400
1552
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1401
1553
  this.store.users.set(userId, next);
1402
1554
  }
@@ -1405,17 +1557,28 @@ var InMemoryCreditRepository = class {
1405
1557
  { monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
1406
1558
  { userId, operation: "updateUserTier" }
1407
1559
  );
1560
+ assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
1408
1561
  const credits = this.store.users.get(userId);
1409
1562
  if (!credits) {
1410
1563
  throw new Error(`User ${userId} not found`);
1411
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
+ }
1412
1576
  credits.tier = input.tier;
1413
1577
  credits.monthlyLimit = input.monthlyLimit;
1414
1578
  if (input.balance !== void 0) {
1415
- credits.balance = Math.max(
1416
- input.balance,
1417
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
1418
- );
1579
+ const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1580
+ credits.balance = planned.balance;
1581
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1419
1582
  }
1420
1583
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1421
1584
  if (input.subscriptionExpiresAt !== void 0) {
@@ -1636,13 +1799,17 @@ var InMemoryCreditRepository = class {
1636
1799
  if (!credits) {
1637
1800
  throw new Error(`User credits not found for userId: ${userId}`);
1638
1801
  }
1639
- 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
+ );
1640
1807
  if (available < amount) {
1641
1808
  throw new Error(
1642
1809
  `Insufficient credits. Available: ${available}, requested: ${amount}`
1643
1810
  );
1644
1811
  }
1645
- const balanceDeduction = Math.min(credits.balance, amount);
1812
+ const balanceDeduction = Math.min(spendable, amount);
1646
1813
  const bonusDeduction = sumAmounts(amount, -balanceDeduction);
1647
1814
  const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
1648
1815
  const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
@@ -1879,7 +2046,11 @@ var InMemoryCreditRepository = class {
1879
2046
  return { expiredCount, creditsReleased, errors };
1880
2047
  }
1881
2048
  // ==================== Atomic Monthly Reset ====================
1882
- 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) {
1883
2054
  const credits = this.store.users.get(userId);
1884
2055
  if (!credits) {
1885
2056
  throw new Error(`User ${userId} not found`);
@@ -1889,17 +2060,41 @@ var InMemoryCreditRepository = class {
1889
2060
  if (currentResetAt !== expected) {
1890
2061
  return { wasReset: false, credits: { ...credits } };
1891
2062
  }
1892
- 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
+ }
1893
2080
  const nextReset = getNextMonthlyReset();
1894
- const target = monthlyResetBalance(tier);
1895
- const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
1896
2081
  const previousBalance = credits.balance;
1897
- 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;
1898
2091
  const change = sumAmounts(newBalance, -previousBalance);
1899
2092
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
1900
2093
  assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
1901
2094
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
1902
2095
  credits.balance = newBalance;
2096
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2097
+ if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
1903
2098
  credits.monthlyUsed = 0;
1904
2099
  credits.monthlyResetAt = nextReset.toISOString();
1905
2100
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -1953,23 +2148,34 @@ var InMemoryCreditRepository = class {
1953
2148
  };
1954
2149
  }
1955
2150
  const defaultTier = getDefaultTier();
1956
- const configured = getConfigMonthlyLimit(defaultTier);
1957
- assertRepresentableTierAmount(configured, "monthlyLimit", {
1958
- userId,
1959
- tier: defaultTier
1960
- });
1961
- 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
+ }
1962
2163
  const previousTier = credits.tier;
1963
2164
  const previousBalance = credits.balance;
1964
- const newBalance = Math.max(
1965
- Math.min(credits.balance, limit),
1966
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
2165
+ const planned = planBalanceCap(
2166
+ credits.balance,
2167
+ limit,
2168
+ credits.reserved,
2169
+ credits.bonusCredits,
2170
+ credits.expiredHoldBacking
1967
2171
  );
2172
+ const newBalance = planned.balance;
1968
2173
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
1969
2174
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
1970
2175
  credits.tier = defaultTier;
1971
2176
  credits.monthlyLimit = limit;
1972
2177
  credits.balance = newBalance;
2178
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1973
2179
  credits.subscriptionExpiresAt = null;
1974
2180
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1975
2181
  this.store.users.set(userId, credits);
@@ -2027,12 +2233,13 @@ var InMemoryCreditRepository = class {
2027
2233
  return copyRecords(Array.from(userReservations.values()));
2028
2234
  }
2029
2235
  };
2030
- function createInMemoryCreditRepository() {
2031
- return new InMemoryCreditRepository();
2236
+ function createInMemoryCreditRepository(options = {}) {
2237
+ return new InMemoryCreditRepository(options);
2032
2238
  }
2033
2239
  export {
2034
2240
  InMemoryCreditRepository,
2035
2241
  addCreditsThroughRepository,
2242
+ assertValidBalanceUpdate,
2036
2243
  commitThroughRepository,
2037
2244
  createInMemoryCreditRepository,
2038
2245
  generateId,
@@ -2043,6 +2250,7 @@ export {
2043
2250
  supportsCreditsV2,
2044
2251
  supportsIdempotentCredit,
2045
2252
  toClientUserCredits,
2046
- toDate
2253
+ toDate,
2254
+ validateBalanceUpdate
2047
2255
  };
2048
2256
  //# sourceMappingURL=index.js.map