@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
@@ -96,6 +96,23 @@ function floatSum(values) {
96
96
  for (const value of values) total += value;
97
97
  return total;
98
98
  }
99
+ var FLOAT_NOISE_FLOOR = 1e-6;
100
+ var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
101
+ function floatNoise(value) {
102
+ return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
103
+ }
104
+ function canonicalAmount(value) {
105
+ if (typeof value !== "number" || !Number.isFinite(value)) return value;
106
+ const cents = Math.round(value * 100);
107
+ if (!Number.isSafeInteger(cents)) return value;
108
+ const onGrid = cents / 100 + 0;
109
+ return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
110
+ }
111
+ function storedAmountCeil(value) {
112
+ const canonical = canonicalAmount(value);
113
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
114
+ return Math.ceil(canonical * 100) / 100;
115
+ }
99
116
  function isValidCreditAmount(value) {
100
117
  if (typeof value !== "number") return false;
101
118
  const cents = toCents(value);
@@ -148,14 +165,19 @@ function backedBalanceFloor(reserved, bonusCredits) {
148
165
  assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
149
166
  return floor;
150
167
  }
151
- function spendableBalance(balance, expiredHoldBacking = 0) {
152
- return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
168
+ function heldExpiredBacking(balance, stored) {
169
+ if (!(stored !== void 0 && stored > 0)) return 0;
170
+ return Math.max(0, Math.min(stored, balance));
171
+ }
172
+ function spendableBalance(balance, expiredHoldBacking) {
173
+ return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
153
174
  }
154
175
  function planBalanceTarget(target, reserved, bonusCredits) {
176
+ assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
155
177
  const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
156
178
  return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
157
179
  }
158
- function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
180
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
159
181
  const spendable = spendableBalance(balance, expiredHoldBacking);
160
182
  const target = Math.min(spendable, cap);
161
183
  const kept = Math.max(
@@ -166,8 +188,8 @@ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
166
188
  return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
167
189
  }
168
190
  function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
169
- const held = Math.min(expiredHoldBacking, balance);
170
- const used = Math.min(held, holdBacking);
191
+ const held = heldExpiredBacking(balance, expiredHoldBacking);
192
+ const used = Math.max(0, Math.min(held, holdBacking));
171
193
  return { used, remaining: sumAmounts(held, -used) };
172
194
  }
173
195
  function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
@@ -182,27 +204,83 @@ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking,
182
204
  expiredHoldBacking: backing.remaining
183
205
  };
184
206
  }
185
- function attributeExpiredBacking(holds, total) {
186
- const placed = holds.filter((hold) => hold.holdPlacedAt).sort(
187
- (a, b) => a.holdPlacedAt === b.holdPlacedAt ? a.id < b.id ? -1 : 1 : a.holdPlacedAt < b.holdPlacedAt ? -1 : 1
188
- );
189
- const shares = /* @__PURE__ */ new Map();
190
- let left = total;
191
- for (const hold of placed) {
192
- const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);
193
- shares.set(hold.id, kept);
194
- left = sumAmounts(left, -kept);
207
+ var NO_EXPIRED_BACKING_PREFIX = {
208
+ holdPlacedAt: "",
209
+ id: "",
210
+ share: 0
211
+ };
212
+ function compareHolds(a, b) {
213
+ if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
214
+ if (a.id === b.id) return 0;
215
+ return a.id < b.id ? -1 : 1;
216
+ }
217
+ function recordedExpiredBacking(hold) {
218
+ const stored = hold.expiredBacking;
219
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
220
+ if (!isValidCreditAmount(hold.amount)) return 0;
221
+ return Math.min(storedAmountCeil(stored), hold.amount);
222
+ }
223
+ function allocateExpiredBacking(holds, total) {
224
+ const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
225
+ const wanted = Math.max(0, total);
226
+ if (wanted === 0) return { total: 0, frontier: null };
227
+ const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
228
+ const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
229
+ let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
230
+ for (const hold of [...recorded].reverse()) {
231
+ if (left >= 0) break;
232
+ const share = shares.get(hold.id);
233
+ const cut = Math.min(share, -left);
234
+ shares.set(hold.id, sumAmounts(share, -cut));
235
+ left = sumAmounts(left, cut);
195
236
  }
237
+ let frontier = NO_EXPIRED_BACKING_PREFIX;
196
238
  for (const hold of placed) {
239
+ if (left <= 0) break;
240
+ if (shares.has(hold.id)) continue;
241
+ const share = Math.min(hold.amount, left);
242
+ frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
243
+ left = sumAmounts(left, -share);
244
+ }
245
+ for (const hold of recorded) {
246
+ if (left <= 0) break;
197
247
  const share = shares.get(hold.id);
198
248
  const added = Math.min(sumAmounts(hold.amount, -share), left);
199
249
  shares.set(hold.id, sumAmounts(share, added));
200
250
  left = sumAmounts(left, -added);
201
251
  }
202
- return shares;
252
+ const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
253
+ return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
203
254
  }
204
- function expiredHoldBackingPatch(next, stored = 0) {
205
- return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
255
+ function holdExpiredBacking(hold, frontier, rewrites) {
256
+ const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
257
+ const recorded = recordedExpiredBacking(
258
+ rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
259
+ );
260
+ if (recorded > 0) return recorded;
261
+ if (!frontier || !hold.holdPlacedAt) return 0;
262
+ const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
263
+ if (order > 0) return 0;
264
+ return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
265
+ }
266
+ function holdPlacedAtAfter(now, frontier) {
267
+ if (!frontier?.holdPlacedAt) return now;
268
+ const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
269
+ return now > earliest ? now : earliest;
270
+ }
271
+ function expiredBackingPatch(next, stored) {
272
+ const total = Math.max(0, next.total);
273
+ const patch = {};
274
+ const storedTotal = stored.expiredHoldBacking;
275
+ if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
276
+ patch.expiredHoldBacking = total;
277
+ }
278
+ if (total <= 0) {
279
+ if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
280
+ } else if (next.frontier) {
281
+ patch.expiredBackingFrontier = next.frontier;
282
+ }
283
+ return patch;
206
284
  }
207
285
  function assertValidBalanceTarget(value, field, context) {
208
286
  assertRepresentableAmount(value, field, context);
@@ -215,7 +293,10 @@ function assertValidBalanceTarget(value, field, context) {
215
293
  }
216
294
  }
217
295
  function assertValidTierBalanceInput(input, context) {
218
- if (input.balanceCap === void 0) return;
296
+ if (input.balanceCap === void 0) {
297
+ if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
298
+ return;
299
+ }
219
300
  if (input.balance !== void 0) {
220
301
  throw new CreditError(
221
302
  "A tier update cannot set both balance and balanceCap",
@@ -887,7 +968,7 @@ function validateBalanceUpdate(current, updates) {
887
968
  const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
888
969
  const errors = [];
889
970
  if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
890
- const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
971
+ const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
891
972
  if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
892
973
  errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
893
974
  }
@@ -1140,6 +1221,7 @@ function commitTransitionJournal(store, input, existingId) {
1140
1221
  userId: input.userId,
1141
1222
  entryType: input.entryType,
1142
1223
  amount: input.amount,
1224
+ balanceBefore: input.balanceBefore,
1143
1225
  balanceAfter: input.balanceAfter,
1144
1226
  source: input.source,
1145
1227
  referenceId: input.reservationId,
@@ -1189,16 +1271,28 @@ function openHolds(store, userId) {
1189
1271
  (reservation) => reservation.status === "reserved"
1190
1272
  );
1191
1273
  }
1274
+ function expiredBackingInForce(store, credits) {
1275
+ const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1276
+ if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
1277
+ return allocateExpiredBacking(openHolds(store, credits.userId), total);
1278
+ }
1279
+ return { total, frontier: credits.expiredBackingFrontier ?? null };
1280
+ }
1281
+ function writeRecordedShares(store, userId, allocation) {
1282
+ for (const { id, expiredBacking } of allocation.rewrites ?? []) {
1283
+ const hold = getReservation(store, userId, id);
1284
+ if (hold) hold.expiredBacking = expiredBacking;
1285
+ }
1286
+ }
1192
1287
  function writeExpiredHoldBacking(store, credits, next) {
1193
- const stored = credits.expiredHoldBacking;
1194
- if (next <= 0 && !stored) return;
1195
- const holds = openHolds(store, credits.userId);
1196
- const shares = attributeExpiredBacking(holds, next);
1197
- for (const hold of holds) {
1198
- const share = shares.get(hold.id) ?? 0;
1199
- if (share !== (hold.expiredBacking ?? 0)) hold.expiredBacking = share;
1288
+ if (next <= 0) {
1289
+ Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
1290
+ return;
1200
1291
  }
1201
- Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
1292
+ if (next === credits.expiredHoldBacking) return;
1293
+ const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
1294
+ writeRecordedShares(store, credits.userId, allocation);
1295
+ Object.assign(credits, expiredBackingPatch(allocation, credits));
1202
1296
  }
1203
1297
  function getReservation(store, userId, reservationId) {
1204
1298
  return store.reservations.get(userId)?.get(reservationId);
@@ -1247,6 +1341,7 @@ async function reserveCreditsV2(store, input) {
1247
1341
  };
1248
1342
  }
1249
1343
  const now = (/* @__PURE__ */ new Date()).toISOString();
1344
+ const backing = expiredBackingInForce(store, credits);
1250
1345
  const reservation = {
1251
1346
  id: generateId(),
1252
1347
  userId: input.userId,
@@ -1259,7 +1354,7 @@ async function reserveCreditsV2(store, input) {
1259
1354
  // The hold-origin fact. Written here and nowhere else, in the same
1260
1355
  // critical section that raises `reserved` below, so the row and the hold
1261
1356
  // become visible together. `createReservation` cannot set it.
1262
- holdPlacedAt: now
1357
+ holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
1263
1358
  };
1264
1359
  if (!store.reservations.has(input.userId)) {
1265
1360
  store.reservations.set(input.userId, /* @__PURE__ */ new Map());
@@ -1277,6 +1372,8 @@ async function reserveCreditsV2(store, input) {
1277
1372
  );
1278
1373
  }
1279
1374
  credits.reserved = nextReserved;
1375
+ writeRecordedShares(store, input.userId, backing);
1376
+ Object.assign(credits, expiredBackingPatch(backing, credits));
1280
1377
  credits.updatedAt = now;
1281
1378
  return { outcome: "created", reservation: copyRecord(reservation) };
1282
1379
  });
@@ -1297,11 +1394,12 @@ async function commitReservationV2(store, userId, reservationId, options) {
1297
1394
  const amount = reservation.amount;
1298
1395
  await yieldPoint(store);
1299
1396
  assertReservedCoversHold(credits, userId, reservationId, amount);
1397
+ const backing = expiredBackingInForce(store, credits);
1300
1398
  const paid = planHoldCommit(
1301
1399
  credits.balance,
1302
1400
  credits.bonusCredits,
1303
- credits.expiredHoldBacking ?? 0,
1304
- reservation.expiredBacking ?? 0,
1401
+ backing.total,
1402
+ holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
1305
1403
  amount
1306
1404
  );
1307
1405
  if (!paid.covered) {
@@ -1319,6 +1417,7 @@ async function commitReservationV2(store, userId, reservationId, options) {
1319
1417
  operation: "commitReservation",
1320
1418
  entryType: "debit",
1321
1419
  amount,
1420
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1322
1421
  balanceAfter,
1323
1422
  source: "operation_commit",
1324
1423
  reservationId,
@@ -1346,9 +1445,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1346
1445
  credits.bonusCredits = nextBonusCredits;
1347
1446
  credits.reserved = nextReserved;
1348
1447
  credits.monthlyUsed = nextMonthlyUsed;
1448
+ writeRecordedShares(store, userId, backing);
1349
1449
  Object.assign(
1350
1450
  credits,
1351
- expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1451
+ expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
1352
1452
  );
1353
1453
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1354
1454
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
@@ -1429,10 +1529,11 @@ function finishUnspent(store, reservation, transition) {
1429
1529
  reservation.id,
1430
1530
  reservation.amount
1431
1531
  );
1532
+ const inForce = expiredBackingInForce(store, credits);
1432
1533
  const backing = settleExpiredHoldBacking(
1433
1534
  credits.balance,
1434
- credits.expiredHoldBacking ?? 0,
1435
- reservation.expiredBacking ?? 0
1535
+ inForce.total,
1536
+ holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
1436
1537
  );
1437
1538
  const nextBalance = sumAmounts(credits.balance, -backing.used);
1438
1539
  const journal = {
@@ -1440,6 +1541,7 @@ function finishUnspent(store, reservation, transition) {
1440
1541
  operation: `${transition.transition}Reservation`,
1441
1542
  entryType: "credit",
1442
1543
  amount: 0,
1544
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1443
1545
  balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1444
1546
  source: transition.source,
1445
1547
  reservationId: reservation.id,
@@ -1462,7 +1564,11 @@ function finishUnspent(store, reservation, transition) {
1462
1564
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1463
1565
  credits.reserved = nextReserved;
1464
1566
  credits.balance = nextBalance;
1465
- Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1567
+ writeRecordedShares(store, reservation.userId, inForce);
1568
+ Object.assign(
1569
+ credits,
1570
+ expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
1571
+ );
1466
1572
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1467
1573
  return commitTransitionJournal(store, journal, existingEntryId);
1468
1574
  }
@@ -1505,7 +1611,7 @@ var InMemoryCreditRepository = class {
1505
1611
  * simultaneous first deliveries of the same reference would then both credit.
1506
1612
  */
1507
1613
  seedUser(userId, tier, initialBalance) {
1508
- assertRepresentableAmount(initialBalance, "initialBalance", { userId });
1614
+ assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
1509
1615
  const configured = getConfigMonthlyLimit(tier);
1510
1616
  assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
1511
1617
  const monthlyLimit = storedMonthlyLimit(configured);
@@ -1586,9 +1692,11 @@ var InMemoryCreditRepository = class {
1586
1692
  userId,
1587
1693
  operation: "updateUserCredits"
1588
1694
  });
1589
- if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
1590
- writeExpiredHoldBacking(this.store, next, 0);
1591
- }
1695
+ writeExpiredHoldBacking(
1696
+ this.store,
1697
+ next,
1698
+ updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
1699
+ );
1592
1700
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1593
1701
  this.store.users.set(userId, next);
1594
1702
  }
@@ -1620,6 +1728,11 @@ var InMemoryCreditRepository = class {
1620
1728
  credits.balance = planned.balance;
1621
1729
  writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1622
1730
  }
1731
+ writeExpiredHoldBacking(
1732
+ this.store,
1733
+ credits,
1734
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1735
+ );
1623
1736
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1624
1737
  if (input.subscriptionExpiresAt !== void 0) {
1625
1738
  if (input.subscriptionExpiresAt === null) {
@@ -1781,6 +1894,11 @@ var InMemoryCreditRepository = class {
1781
1894
  { userId, operation: "addCredits" }
1782
1895
  );
1783
1896
  credits.bonusCredits = nextBonusCredits;
1897
+ writeExpiredHoldBacking(
1898
+ this.store,
1899
+ credits,
1900
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1901
+ );
1784
1902
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1785
1903
  this.store.users.set(userId, credits);
1786
1904
  const transaction = this.recordTransaction({
@@ -1800,6 +1918,7 @@ var InMemoryCreditRepository = class {
1800
1918
  userId,
1801
1919
  entryType: "credit",
1802
1920
  amount,
1921
+ balanceBefore: previousTotal,
1803
1922
  balanceAfter: newTotal,
1804
1923
  source: payload.source,
1805
1924
  referenceId: transaction.id ?? paymentRef ?? "unknown",
@@ -1833,7 +1952,7 @@ var InMemoryCreditRepository = class {
1833
1952
  });
1834
1953
  }
1835
1954
  }
1836
- async deductCreditsAtomic(userId, amount) {
1955
+ async deductCreditsAtomic(userId, amount, journal) {
1837
1956
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
1838
1957
  const credits = this.store.users.get(userId);
1839
1958
  if (!credits) {
@@ -1859,11 +1978,26 @@ var InMemoryCreditRepository = class {
1859
1978
  { previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
1860
1979
  { userId, operation: "deductCredits" }
1861
1980
  );
1981
+ const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1862
1982
  credits.balance = nextBalance;
1863
1983
  credits.bonusCredits = nextBonusCredits;
1984
+ writeExpiredHoldBacking(this.store, credits, held);
1864
1985
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1865
1986
  this.store.users.set(userId, credits);
1866
- return { previousBalance, newBalance };
1987
+ if (!journal) return { previousBalance, newBalance };
1988
+ const entry = this.recordJournalEntry({
1989
+ userId,
1990
+ entryType: "debit",
1991
+ amount,
1992
+ balanceBefore: previousBalance,
1993
+ balanceAfter: newBalance,
1994
+ source: journal.source ?? "operation_commit",
1995
+ referenceId: journal.referenceId,
1996
+ referenceType: journal.referenceType ?? "adjustment",
1997
+ description: journal.description,
1998
+ metadata: journal.metadata
1999
+ });
2000
+ return { previousBalance, newBalance, journalEntryId: entry.id };
1867
2001
  }
1868
2002
  // ==================== Transactions ====================
1869
2003
  async createTransaction(input) {
@@ -1977,6 +2111,11 @@ var InMemoryCreditRepository = class {
1977
2111
  assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
1978
2112
  userId: input.userId
1979
2113
  });
2114
+ if (input.balanceBefore !== void 0) {
2115
+ assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
2116
+ userId: input.userId
2117
+ });
2118
+ }
1980
2119
  assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
1981
2120
  assertPublicJournalKey(input.idempotencyKey, input.userId);
1982
2121
  const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
@@ -1992,6 +2131,7 @@ var InMemoryCreditRepository = class {
1992
2131
  userId: input.userId,
1993
2132
  entryType: input.entryType,
1994
2133
  amount: input.amount,
2134
+ ...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
1995
2135
  balanceAfter: input.balanceAfter,
1996
2136
  source: input.source,
1997
2137
  referenceId: input.referenceId,
@@ -2144,6 +2284,7 @@ var InMemoryCreditRepository = class {
2144
2284
  userId,
2145
2285
  entryType: change > 0 ? "credit" : "debit",
2146
2286
  amount: Math.abs(change),
2287
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2147
2288
  balanceAfter,
2148
2289
  source: "monthly_reset",
2149
2290
  referenceId: `reset-${Date.now()}`,
@@ -2224,6 +2365,7 @@ var InMemoryCreditRepository = class {
2224
2365
  entryType: "debit",
2225
2366
  amount: 0,
2226
2367
  // No credits deducted, just tier change
2368
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2227
2369
  balanceAfter,
2228
2370
  source: "subscription_downgrade",
2229
2371
  referenceId: `downgrade-${Date.now()}`,