@nehorai/credits 2.2.0 → 3.0.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 (41) hide show
  1. package/dist/adapters/index.cjs +1 -0
  2. package/dist/adapters/index.cjs.map +1 -1
  3. package/dist/adapters/index.d.cts +2 -2
  4. package/dist/adapters/index.d.ts +2 -2
  5. package/dist/adapters/index.js +1 -0
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/core/index.cjs +139 -23
  8. package/dist/core/index.cjs.map +1 -1
  9. package/dist/core/index.d.cts +113 -22
  10. package/dist/core/index.d.ts +113 -22
  11. package/dist/core/index.js +128 -21
  12. package/dist/core/index.js.map +1 -1
  13. package/dist/index.cjs +246 -61
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +4 -4
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +235 -59
  18. package/dist/index.js.map +1 -1
  19. package/dist/repository/index.cjs +183 -41
  20. package/dist/repository/index.cjs.map +1 -1
  21. package/dist/repository/index.d.cts +6 -5
  22. package/dist/repository/index.d.ts +6 -5
  23. package/dist/repository/index.js +183 -41
  24. package/dist/repository/index.js.map +1 -1
  25. package/dist/sdk/index.cjs +1 -0
  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 +1 -0
  30. package/dist/sdk/index.js.map +1 -1
  31. package/dist/service/index.cjs +25 -16
  32. package/dist/service/index.cjs.map +1 -1
  33. package/dist/service/index.d.cts +2 -2
  34. package/dist/service/index.d.ts +2 -2
  35. package/dist/service/index.js +25 -16
  36. package/dist/service/index.js.map +1 -1
  37. package/dist/{types-MaViK5tD.d.cts → types-C3sxDvNb.d.cts} +29 -6
  38. package/dist/{types-MaViK5tD.d.ts → types-C3sxDvNb.d.ts} +29 -6
  39. package/dist/{types-3c-IAwZI.d.ts → types-CY-oHIMA.d.ts} +19 -3
  40. package/dist/{types-XSjNXO5l.d.cts → types-DOjguhEX.d.cts} +19 -3
  41. package/package.json +1 -1
@@ -1,6 +1,6 @@
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';
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, D as DeductJournalInput, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-DOjguhEX.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-DOjguhEX.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-C3sxDvNb.cjs';
4
4
 
5
5
  /**
6
6
  * One reservation flow for both repository generations.
@@ -181,9 +181,10 @@ declare class InMemoryCreditRepository implements ICreditRepository {
181
181
  * that *is* the idempotent no-op the caller asked for.
182
182
  */
183
183
  addCreditsAtomic(userId: string, amount: number, description: string, paymentRef?: string, options?: AddCreditsAtomicOptions): Promise<void>;
184
- deductCreditsAtomic(userId: string, amount: number): Promise<{
184
+ deductCreditsAtomic(userId: string, amount: number, journal?: DeductJournalInput): Promise<{
185
185
  previousBalance: number;
186
186
  newBalance: number;
187
+ journalEntryId?: string;
187
188
  }>;
188
189
  createTransaction(input: CreateTransactionInput): Promise<PortableTransaction>;
189
190
  /** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
@@ -251,4 +252,4 @@ declare class InMemoryCreditRepository implements ICreditRepository {
251
252
  */
252
253
  declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
253
254
 
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 };
255
+ export { AddCreditsAtomicOptions, AddCreditsV2Input, type BalanceValidationResult, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, DeductJournalInput, 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, 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';
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, D as DeductJournalInput, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-CY-oHIMA.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-CY-oHIMA.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-C3sxDvNb.js';
4
4
 
5
5
  /**
6
6
  * One reservation flow for both repository generations.
@@ -181,9 +181,10 @@ declare class InMemoryCreditRepository implements ICreditRepository {
181
181
  * that *is* the idempotent no-op the caller asked for.
182
182
  */
183
183
  addCreditsAtomic(userId: string, amount: number, description: string, paymentRef?: string, options?: AddCreditsAtomicOptions): Promise<void>;
184
- deductCreditsAtomic(userId: string, amount: number): Promise<{
184
+ deductCreditsAtomic(userId: string, amount: number, journal?: DeductJournalInput): Promise<{
185
185
  previousBalance: number;
186
186
  newBalance: number;
187
+ journalEntryId?: string;
187
188
  }>;
188
189
  createTransaction(input: CreateTransactionInput): Promise<PortableTransaction>;
189
190
  /** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
@@ -251,4 +252,4 @@ declare class InMemoryCreditRepository implements ICreditRepository {
251
252
  */
252
253
  declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
253
254
 
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 };
255
+ export { AddCreditsAtomicOptions, AddCreditsV2Input, type BalanceValidationResult, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, DeductJournalInput, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, assertValidBalanceUpdate, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate, validateBalanceUpdate };
@@ -56,6 +56,23 @@ function floatSum(values) {
56
56
  for (const value of values) total += value;
57
57
  return total;
58
58
  }
59
+ var FLOAT_NOISE_FLOOR = 1e-6;
60
+ var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
61
+ function floatNoise(value) {
62
+ return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
63
+ }
64
+ function canonicalAmount(value) {
65
+ if (typeof value !== "number" || !Number.isFinite(value)) return value;
66
+ const cents = Math.round(value * 100);
67
+ if (!Number.isSafeInteger(cents)) return value;
68
+ const onGrid = cents / 100 + 0;
69
+ return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
70
+ }
71
+ function storedAmountCeil(value) {
72
+ const canonical = canonicalAmount(value);
73
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
74
+ return Math.ceil(canonical * 100) / 100;
75
+ }
59
76
  function isValidCreditAmount(value) {
60
77
  if (typeof value !== "number") return false;
61
78
  const cents = toCents(value);
@@ -108,14 +125,19 @@ function backedBalanceFloor(reserved, bonusCredits) {
108
125
  assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
109
126
  return floor;
110
127
  }
111
- function spendableBalance(balance, expiredHoldBacking = 0) {
112
- return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
128
+ function heldExpiredBacking(balance, stored) {
129
+ if (!(stored !== void 0 && stored > 0)) return 0;
130
+ return Math.max(0, Math.min(stored, balance));
131
+ }
132
+ function spendableBalance(balance, expiredHoldBacking) {
133
+ return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
113
134
  }
114
135
  function planBalanceTarget(target, reserved, bonusCredits) {
136
+ assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
115
137
  const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
116
138
  return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
117
139
  }
118
- function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
140
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
119
141
  const spendable = spendableBalance(balance, expiredHoldBacking);
120
142
  const target = Math.min(spendable, cap);
121
143
  const kept = Math.max(
@@ -126,8 +148,8 @@ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
126
148
  return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
127
149
  }
128
150
  function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
129
- const held = Math.min(expiredHoldBacking, balance);
130
- const used = Math.min(held, holdBacking);
151
+ const held = heldExpiredBacking(balance, expiredHoldBacking);
152
+ const used = Math.max(0, Math.min(held, holdBacking));
131
153
  return { used, remaining: sumAmounts(held, -used) };
132
154
  }
133
155
  function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
@@ -142,27 +164,83 @@ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking,
142
164
  expiredHoldBacking: backing.remaining
143
165
  };
144
166
  }
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);
167
+ var NO_EXPIRED_BACKING_PREFIX = {
168
+ holdPlacedAt: "",
169
+ id: "",
170
+ share: 0
171
+ };
172
+ function compareHolds(a, b) {
173
+ if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
174
+ if (a.id === b.id) return 0;
175
+ return a.id < b.id ? -1 : 1;
176
+ }
177
+ function recordedExpiredBacking(hold) {
178
+ const stored = hold.expiredBacking;
179
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
180
+ if (!isValidCreditAmount(hold.amount)) return 0;
181
+ return Math.min(storedAmountCeil(stored), hold.amount);
182
+ }
183
+ function allocateExpiredBacking(holds, total) {
184
+ const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
185
+ const wanted = Math.max(0, total);
186
+ if (wanted === 0) return { total: 0, frontier: null };
187
+ const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
188
+ const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
189
+ let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
190
+ for (const hold of [...recorded].reverse()) {
191
+ if (left >= 0) break;
192
+ const share = shares.get(hold.id);
193
+ const cut = Math.min(share, -left);
194
+ shares.set(hold.id, sumAmounts(share, -cut));
195
+ left = sumAmounts(left, cut);
155
196
  }
197
+ let frontier = NO_EXPIRED_BACKING_PREFIX;
156
198
  for (const hold of placed) {
199
+ if (left <= 0) break;
200
+ if (shares.has(hold.id)) continue;
201
+ const share = Math.min(hold.amount, left);
202
+ frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
203
+ left = sumAmounts(left, -share);
204
+ }
205
+ for (const hold of recorded) {
206
+ if (left <= 0) break;
157
207
  const share = shares.get(hold.id);
158
208
  const added = Math.min(sumAmounts(hold.amount, -share), left);
159
209
  shares.set(hold.id, sumAmounts(share, added));
160
210
  left = sumAmounts(left, -added);
161
211
  }
162
- return shares;
212
+ const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
213
+ return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
163
214
  }
164
- function expiredHoldBackingPatch(next, stored = 0) {
165
- return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
215
+ function holdExpiredBacking(hold, frontier, rewrites) {
216
+ const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
217
+ const recorded = recordedExpiredBacking(
218
+ rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
219
+ );
220
+ if (recorded > 0) return recorded;
221
+ if (!frontier || !hold.holdPlacedAt) return 0;
222
+ const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
223
+ if (order > 0) return 0;
224
+ return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
225
+ }
226
+ function holdPlacedAtAfter(now, frontier) {
227
+ if (!frontier?.holdPlacedAt) return now;
228
+ const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
229
+ return now > earliest ? now : earliest;
230
+ }
231
+ function expiredBackingPatch(next, stored) {
232
+ const total = Math.max(0, next.total);
233
+ const patch = {};
234
+ const storedTotal = stored.expiredHoldBacking;
235
+ if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
236
+ patch.expiredHoldBacking = total;
237
+ }
238
+ if (total <= 0) {
239
+ if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
240
+ } else if (next.frontier) {
241
+ patch.expiredBackingFrontier = next.frontier;
242
+ }
243
+ return patch;
166
244
  }
167
245
  function assertValidBalanceTarget(value, field, context) {
168
246
  assertRepresentableAmount(value, field, context);
@@ -175,7 +253,10 @@ function assertValidBalanceTarget(value, field, context) {
175
253
  }
176
254
  }
177
255
  function assertValidTierBalanceInput(input, context) {
178
- if (input.balanceCap === void 0) return;
256
+ if (input.balanceCap === void 0) {
257
+ if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
258
+ return;
259
+ }
179
260
  if (input.balance !== void 0) {
180
261
  throw new CreditError(
181
262
  "A tier update cannot set both balance and balanceCap",
@@ -847,7 +928,7 @@ function validateBalanceUpdate(current, updates) {
847
928
  const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
848
929
  const errors = [];
849
930
  if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
850
- const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
931
+ const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
851
932
  if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
852
933
  errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
853
934
  }
@@ -1100,6 +1181,7 @@ function commitTransitionJournal(store, input, existingId) {
1100
1181
  userId: input.userId,
1101
1182
  entryType: input.entryType,
1102
1183
  amount: input.amount,
1184
+ balanceBefore: input.balanceBefore,
1103
1185
  balanceAfter: input.balanceAfter,
1104
1186
  source: input.source,
1105
1187
  referenceId: input.reservationId,
@@ -1149,16 +1231,28 @@ function openHolds(store, userId) {
1149
1231
  (reservation) => reservation.status === "reserved"
1150
1232
  );
1151
1233
  }
1234
+ function expiredBackingInForce(store, credits) {
1235
+ const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1236
+ if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
1237
+ return allocateExpiredBacking(openHolds(store, credits.userId), total);
1238
+ }
1239
+ return { total, frontier: credits.expiredBackingFrontier ?? null };
1240
+ }
1241
+ function writeRecordedShares(store, userId, allocation) {
1242
+ for (const { id, expiredBacking } of allocation.rewrites ?? []) {
1243
+ const hold = getReservation(store, userId, id);
1244
+ if (hold) hold.expiredBacking = expiredBacking;
1245
+ }
1246
+ }
1152
1247
  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;
1248
+ if (next <= 0) {
1249
+ Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
1250
+ return;
1160
1251
  }
1161
- Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
1252
+ if (next === credits.expiredHoldBacking) return;
1253
+ const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
1254
+ writeRecordedShares(store, credits.userId, allocation);
1255
+ Object.assign(credits, expiredBackingPatch(allocation, credits));
1162
1256
  }
1163
1257
  function getReservation(store, userId, reservationId) {
1164
1258
  return store.reservations.get(userId)?.get(reservationId);
@@ -1207,6 +1301,7 @@ async function reserveCreditsV2(store, input) {
1207
1301
  };
1208
1302
  }
1209
1303
  const now = (/* @__PURE__ */ new Date()).toISOString();
1304
+ const backing = expiredBackingInForce(store, credits);
1210
1305
  const reservation = {
1211
1306
  id: generateId(),
1212
1307
  userId: input.userId,
@@ -1219,7 +1314,7 @@ async function reserveCreditsV2(store, input) {
1219
1314
  // The hold-origin fact. Written here and nowhere else, in the same
1220
1315
  // critical section that raises `reserved` below, so the row and the hold
1221
1316
  // become visible together. `createReservation` cannot set it.
1222
- holdPlacedAt: now
1317
+ holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
1223
1318
  };
1224
1319
  if (!store.reservations.has(input.userId)) {
1225
1320
  store.reservations.set(input.userId, /* @__PURE__ */ new Map());
@@ -1237,6 +1332,8 @@ async function reserveCreditsV2(store, input) {
1237
1332
  );
1238
1333
  }
1239
1334
  credits.reserved = nextReserved;
1335
+ writeRecordedShares(store, input.userId, backing);
1336
+ Object.assign(credits, expiredBackingPatch(backing, credits));
1240
1337
  credits.updatedAt = now;
1241
1338
  return { outcome: "created", reservation: copyRecord(reservation) };
1242
1339
  });
@@ -1257,11 +1354,12 @@ async function commitReservationV2(store, userId, reservationId, options) {
1257
1354
  const amount = reservation.amount;
1258
1355
  await yieldPoint(store);
1259
1356
  assertReservedCoversHold(credits, userId, reservationId, amount);
1357
+ const backing = expiredBackingInForce(store, credits);
1260
1358
  const paid = planHoldCommit(
1261
1359
  credits.balance,
1262
1360
  credits.bonusCredits,
1263
- credits.expiredHoldBacking ?? 0,
1264
- reservation.expiredBacking ?? 0,
1361
+ backing.total,
1362
+ holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
1265
1363
  amount
1266
1364
  );
1267
1365
  if (!paid.covered) {
@@ -1279,6 +1377,7 @@ async function commitReservationV2(store, userId, reservationId, options) {
1279
1377
  operation: "commitReservation",
1280
1378
  entryType: "debit",
1281
1379
  amount,
1380
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1282
1381
  balanceAfter,
1283
1382
  source: "operation_commit",
1284
1383
  reservationId,
@@ -1306,9 +1405,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1306
1405
  credits.bonusCredits = nextBonusCredits;
1307
1406
  credits.reserved = nextReserved;
1308
1407
  credits.monthlyUsed = nextMonthlyUsed;
1408
+ writeRecordedShares(store, userId, backing);
1309
1409
  Object.assign(
1310
1410
  credits,
1311
- expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1411
+ expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
1312
1412
  );
1313
1413
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1314
1414
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
@@ -1389,10 +1489,11 @@ function finishUnspent(store, reservation, transition) {
1389
1489
  reservation.id,
1390
1490
  reservation.amount
1391
1491
  );
1492
+ const inForce = expiredBackingInForce(store, credits);
1392
1493
  const backing = settleExpiredHoldBacking(
1393
1494
  credits.balance,
1394
- credits.expiredHoldBacking ?? 0,
1395
- reservation.expiredBacking ?? 0
1495
+ inForce.total,
1496
+ holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
1396
1497
  );
1397
1498
  const nextBalance = sumAmounts(credits.balance, -backing.used);
1398
1499
  const journal = {
@@ -1400,6 +1501,7 @@ function finishUnspent(store, reservation, transition) {
1400
1501
  operation: `${transition.transition}Reservation`,
1401
1502
  entryType: "credit",
1402
1503
  amount: 0,
1504
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1403
1505
  balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1404
1506
  source: transition.source,
1405
1507
  reservationId: reservation.id,
@@ -1422,7 +1524,11 @@ function finishUnspent(store, reservation, transition) {
1422
1524
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1423
1525
  credits.reserved = nextReserved;
1424
1526
  credits.balance = nextBalance;
1425
- Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1527
+ writeRecordedShares(store, reservation.userId, inForce);
1528
+ Object.assign(
1529
+ credits,
1530
+ expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
1531
+ );
1426
1532
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1427
1533
  return commitTransitionJournal(store, journal, existingEntryId);
1428
1534
  }
@@ -1465,7 +1571,7 @@ var InMemoryCreditRepository = class {
1465
1571
  * simultaneous first deliveries of the same reference would then both credit.
1466
1572
  */
1467
1573
  seedUser(userId, tier, initialBalance) {
1468
- assertRepresentableAmount(initialBalance, "initialBalance", { userId });
1574
+ assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
1469
1575
  const configured = getConfigMonthlyLimit(tier);
1470
1576
  assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
1471
1577
  const monthlyLimit = storedMonthlyLimit(configured);
@@ -1546,9 +1652,11 @@ var InMemoryCreditRepository = class {
1546
1652
  userId,
1547
1653
  operation: "updateUserCredits"
1548
1654
  });
1549
- if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
1550
- writeExpiredHoldBacking(this.store, next, 0);
1551
- }
1655
+ writeExpiredHoldBacking(
1656
+ this.store,
1657
+ next,
1658
+ updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
1659
+ );
1552
1660
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1553
1661
  this.store.users.set(userId, next);
1554
1662
  }
@@ -1580,6 +1688,11 @@ var InMemoryCreditRepository = class {
1580
1688
  credits.balance = planned.balance;
1581
1689
  writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1582
1690
  }
1691
+ writeExpiredHoldBacking(
1692
+ this.store,
1693
+ credits,
1694
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1695
+ );
1583
1696
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1584
1697
  if (input.subscriptionExpiresAt !== void 0) {
1585
1698
  if (input.subscriptionExpiresAt === null) {
@@ -1741,6 +1854,11 @@ var InMemoryCreditRepository = class {
1741
1854
  { userId, operation: "addCredits" }
1742
1855
  );
1743
1856
  credits.bonusCredits = nextBonusCredits;
1857
+ writeExpiredHoldBacking(
1858
+ this.store,
1859
+ credits,
1860
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1861
+ );
1744
1862
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1745
1863
  this.store.users.set(userId, credits);
1746
1864
  const transaction = this.recordTransaction({
@@ -1760,6 +1878,7 @@ var InMemoryCreditRepository = class {
1760
1878
  userId,
1761
1879
  entryType: "credit",
1762
1880
  amount,
1881
+ balanceBefore: previousTotal,
1763
1882
  balanceAfter: newTotal,
1764
1883
  source: payload.source,
1765
1884
  referenceId: transaction.id ?? paymentRef ?? "unknown",
@@ -1793,7 +1912,7 @@ var InMemoryCreditRepository = class {
1793
1912
  });
1794
1913
  }
1795
1914
  }
1796
- async deductCreditsAtomic(userId, amount) {
1915
+ async deductCreditsAtomic(userId, amount, journal) {
1797
1916
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
1798
1917
  const credits = this.store.users.get(userId);
1799
1918
  if (!credits) {
@@ -1819,11 +1938,26 @@ var InMemoryCreditRepository = class {
1819
1938
  { previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
1820
1939
  { userId, operation: "deductCredits" }
1821
1940
  );
1941
+ const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1822
1942
  credits.balance = nextBalance;
1823
1943
  credits.bonusCredits = nextBonusCredits;
1944
+ writeExpiredHoldBacking(this.store, credits, held);
1824
1945
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1825
1946
  this.store.users.set(userId, credits);
1826
- return { previousBalance, newBalance };
1947
+ if (!journal) return { previousBalance, newBalance };
1948
+ const entry = this.recordJournalEntry({
1949
+ userId,
1950
+ entryType: "debit",
1951
+ amount,
1952
+ balanceBefore: previousBalance,
1953
+ balanceAfter: newBalance,
1954
+ source: journal.source ?? "operation_commit",
1955
+ referenceId: journal.referenceId,
1956
+ referenceType: journal.referenceType ?? "adjustment",
1957
+ description: journal.description,
1958
+ metadata: journal.metadata
1959
+ });
1960
+ return { previousBalance, newBalance, journalEntryId: entry.id };
1827
1961
  }
1828
1962
  // ==================== Transactions ====================
1829
1963
  async createTransaction(input) {
@@ -1937,6 +2071,11 @@ var InMemoryCreditRepository = class {
1937
2071
  assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
1938
2072
  userId: input.userId
1939
2073
  });
2074
+ if (input.balanceBefore !== void 0) {
2075
+ assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
2076
+ userId: input.userId
2077
+ });
2078
+ }
1940
2079
  assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
1941
2080
  assertPublicJournalKey(input.idempotencyKey, input.userId);
1942
2081
  const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
@@ -1952,6 +2091,7 @@ var InMemoryCreditRepository = class {
1952
2091
  userId: input.userId,
1953
2092
  entryType: input.entryType,
1954
2093
  amount: input.amount,
2094
+ ...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
1955
2095
  balanceAfter: input.balanceAfter,
1956
2096
  source: input.source,
1957
2097
  referenceId: input.referenceId,
@@ -2104,6 +2244,7 @@ var InMemoryCreditRepository = class {
2104
2244
  userId,
2105
2245
  entryType: change > 0 ? "credit" : "debit",
2106
2246
  amount: Math.abs(change),
2247
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2107
2248
  balanceAfter,
2108
2249
  source: "monthly_reset",
2109
2250
  referenceId: `reset-${Date.now()}`,
@@ -2184,6 +2325,7 @@ var InMemoryCreditRepository = class {
2184
2325
  entryType: "debit",
2185
2326
  amount: 0,
2186
2327
  // No credits deducted, just tier change
2328
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2187
2329
  balanceAfter,
2188
2330
  source: "subscription_downgrade",
2189
2331
  referenceId: `downgrade-${Date.now()}`,