@nehorai/credits 2.2.0 → 3.1.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/dist/adapters/index.cjs +169 -155
  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 +169 -155
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/core/index.cjs +148 -24
  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 +137 -22
  12. package/dist/core/index.js.map +1 -1
  13. package/dist/index-BrogG0Gc.d.cts +277 -0
  14. package/dist/index-DOvJES2m.d.ts +277 -0
  15. package/dist/index.cjs +324 -71
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +5 -5
  18. package/dist/index.d.ts +5 -5
  19. package/dist/index.js +311 -69
  20. package/dist/index.js.map +1 -1
  21. package/dist/repository/index.cjs +254 -49
  22. package/dist/repository/index.cjs.map +1 -1
  23. package/dist/repository/index.d.cts +3 -254
  24. package/dist/repository/index.d.ts +3 -254
  25. package/dist/repository/index.js +254 -49
  26. package/dist/repository/index.js.map +1 -1
  27. package/dist/sdk/index.cjs +1 -0
  28. package/dist/sdk/index.cjs.map +1 -1
  29. package/dist/sdk/index.d.cts +2 -2
  30. package/dist/sdk/index.d.ts +2 -2
  31. package/dist/sdk/index.js +1 -0
  32. package/dist/sdk/index.js.map +1 -1
  33. package/dist/service/index.cjs +42 -19
  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 +42 -19
  38. package/dist/service/index.js.map +1 -1
  39. package/dist/{types-3c-IAwZI.d.ts → types-BL3YqxUD.d.ts} +28 -6
  40. package/dist/{types-MaViK5tD.d.cts → types-C3sxDvNb.d.cts} +29 -6
  41. package/dist/{types-MaViK5tD.d.ts → types-C3sxDvNb.d.ts} +29 -6
  42. package/dist/{types-XSjNXO5l.d.cts → types-YLAB8bFk.d.cts} +28 -6
  43. 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",
@@ -550,17 +631,25 @@ var DEFAULT_CONFIG = {
550
631
  }
551
632
  };
552
633
  var currentConfig = DEFAULT_CONFIG;
634
+ function isOperationCostMap(value) {
635
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
636
+ return Object.values(value).every((cost) => isRepresentableAmount(cost) && cost >= 0);
637
+ }
553
638
  function loadConfigFromEnv() {
554
639
  const envConfig = {};
555
640
  const operationCostsEnv = process.env.CREDITS_OPERATION_COSTS;
556
641
  if (operationCostsEnv) {
557
642
  try {
558
643
  const parsed = JSON.parse(operationCostsEnv);
559
- if (typeof parsed === "object" && parsed !== null) {
644
+ if (isOperationCostMap(parsed)) {
560
645
  envConfig.operationCosts = {
561
646
  ...DEFAULT_CONFIG.operationCosts,
562
647
  ...parsed
563
648
  };
649
+ } else {
650
+ console.warn(
651
+ "[Credits Config] Ignoring CREDITS_OPERATION_COSTS: it must be a JSON object whose costs are numbers >= 0, exact to the cent"
652
+ );
564
653
  }
565
654
  } catch {
566
655
  console.warn("[Credits Config] Failed to parse CREDITS_OPERATION_COSTS env var as JSON");
@@ -887,7 +976,7 @@ function validateBalanceUpdate(current, updates) {
887
976
  const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
888
977
  const errors = [];
889
978
  if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
890
- const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
979
+ const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
891
980
  if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
892
981
  errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
893
982
  }
@@ -905,6 +994,50 @@ function assertValidBalanceUpdate(current, updates) {
905
994
  throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
906
995
  }
907
996
  }
997
+ function signupGrantJournalInput(userId, tier, grant) {
998
+ if (grant <= 0) return null;
999
+ return {
1000
+ userId,
1001
+ entryType: "credit",
1002
+ amount: grant,
1003
+ balanceBefore: 0,
1004
+ balanceAfter: grant,
1005
+ source: "bonus",
1006
+ referenceId: `signup-${userId}`,
1007
+ referenceType: "adjustment",
1008
+ description: `Signup grant for ${tier} tier.`,
1009
+ metadata: { tier }
1010
+ };
1011
+ }
1012
+ function tierChangeJournalInput(input) {
1013
+ const { userId, previousTier, newTier, previousBalance, newBalance, bonusCredits } = input;
1014
+ const change = sumAmounts(newBalance, -previousBalance);
1015
+ if (change === 0) return null;
1016
+ const raised = change > 0;
1017
+ const amount = raised ? newBalance : 0;
1018
+ const balanceBefore = sumAmounts(previousBalance, bonusCredits);
1019
+ const balanceAfter = sumAmounts(newBalance, bonusCredits);
1020
+ assertRepresentableFields(
1021
+ {
1022
+ "journal amount": amount,
1023
+ "journal balanceBefore": balanceBefore,
1024
+ "journal balanceAfter": balanceAfter
1025
+ },
1026
+ { userId, operation: "updateUserTier" }
1027
+ );
1028
+ return {
1029
+ userId,
1030
+ entryType: raised ? "credit" : "debit",
1031
+ amount,
1032
+ balanceBefore,
1033
+ balanceAfter,
1034
+ source: raised ? "subscription_upgrade" : "subscription_downgrade",
1035
+ referenceId: `tier-${Date.now()}`,
1036
+ referenceType: "subscription",
1037
+ description: `Tier changed from ${previousTier} to ${newTier}.`,
1038
+ metadata: { previousTier, newTier, previousBalance, newBalance }
1039
+ };
1040
+ }
908
1041
 
909
1042
  // src/repository/memory/snapshot.ts
910
1043
  function copyRecord(record) {
@@ -1140,6 +1273,7 @@ function commitTransitionJournal(store, input, existingId) {
1140
1273
  userId: input.userId,
1141
1274
  entryType: input.entryType,
1142
1275
  amount: input.amount,
1276
+ balanceBefore: input.balanceBefore,
1143
1277
  balanceAfter: input.balanceAfter,
1144
1278
  source: input.source,
1145
1279
  referenceId: input.reservationId,
@@ -1189,16 +1323,28 @@ function openHolds(store, userId) {
1189
1323
  (reservation) => reservation.status === "reserved"
1190
1324
  );
1191
1325
  }
1326
+ function expiredBackingInForce(store, credits) {
1327
+ const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1328
+ if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
1329
+ return allocateExpiredBacking(openHolds(store, credits.userId), total);
1330
+ }
1331
+ return { total, frontier: credits.expiredBackingFrontier ?? null };
1332
+ }
1333
+ function writeRecordedShares(store, userId, allocation) {
1334
+ for (const { id, expiredBacking } of allocation.rewrites ?? []) {
1335
+ const hold = getReservation(store, userId, id);
1336
+ if (hold) hold.expiredBacking = expiredBacking;
1337
+ }
1338
+ }
1192
1339
  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;
1340
+ if (next <= 0) {
1341
+ Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
1342
+ return;
1200
1343
  }
1201
- Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
1344
+ if (next === credits.expiredHoldBacking) return;
1345
+ const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
1346
+ writeRecordedShares(store, credits.userId, allocation);
1347
+ Object.assign(credits, expiredBackingPatch(allocation, credits));
1202
1348
  }
1203
1349
  function getReservation(store, userId, reservationId) {
1204
1350
  return store.reservations.get(userId)?.get(reservationId);
@@ -1247,6 +1393,7 @@ async function reserveCreditsV2(store, input) {
1247
1393
  };
1248
1394
  }
1249
1395
  const now = (/* @__PURE__ */ new Date()).toISOString();
1396
+ const backing = expiredBackingInForce(store, credits);
1250
1397
  const reservation = {
1251
1398
  id: generateId(),
1252
1399
  userId: input.userId,
@@ -1259,7 +1406,7 @@ async function reserveCreditsV2(store, input) {
1259
1406
  // The hold-origin fact. Written here and nowhere else, in the same
1260
1407
  // critical section that raises `reserved` below, so the row and the hold
1261
1408
  // become visible together. `createReservation` cannot set it.
1262
- holdPlacedAt: now
1409
+ holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
1263
1410
  };
1264
1411
  if (!store.reservations.has(input.userId)) {
1265
1412
  store.reservations.set(input.userId, /* @__PURE__ */ new Map());
@@ -1277,6 +1424,8 @@ async function reserveCreditsV2(store, input) {
1277
1424
  );
1278
1425
  }
1279
1426
  credits.reserved = nextReserved;
1427
+ writeRecordedShares(store, input.userId, backing);
1428
+ Object.assign(credits, expiredBackingPatch(backing, credits));
1280
1429
  credits.updatedAt = now;
1281
1430
  return { outcome: "created", reservation: copyRecord(reservation) };
1282
1431
  });
@@ -1297,11 +1446,12 @@ async function commitReservationV2(store, userId, reservationId, options) {
1297
1446
  const amount = reservation.amount;
1298
1447
  await yieldPoint(store);
1299
1448
  assertReservedCoversHold(credits, userId, reservationId, amount);
1449
+ const backing = expiredBackingInForce(store, credits);
1300
1450
  const paid = planHoldCommit(
1301
1451
  credits.balance,
1302
1452
  credits.bonusCredits,
1303
- credits.expiredHoldBacking ?? 0,
1304
- reservation.expiredBacking ?? 0,
1453
+ backing.total,
1454
+ holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
1305
1455
  amount
1306
1456
  );
1307
1457
  if (!paid.covered) {
@@ -1319,6 +1469,7 @@ async function commitReservationV2(store, userId, reservationId, options) {
1319
1469
  operation: "commitReservation",
1320
1470
  entryType: "debit",
1321
1471
  amount,
1472
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1322
1473
  balanceAfter,
1323
1474
  source: "operation_commit",
1324
1475
  reservationId,
@@ -1346,9 +1497,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1346
1497
  credits.bonusCredits = nextBonusCredits;
1347
1498
  credits.reserved = nextReserved;
1348
1499
  credits.monthlyUsed = nextMonthlyUsed;
1500
+ writeRecordedShares(store, userId, backing);
1349
1501
  Object.assign(
1350
1502
  credits,
1351
- expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1503
+ expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
1352
1504
  );
1353
1505
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1354
1506
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
@@ -1429,10 +1581,11 @@ function finishUnspent(store, reservation, transition) {
1429
1581
  reservation.id,
1430
1582
  reservation.amount
1431
1583
  );
1584
+ const inForce = expiredBackingInForce(store, credits);
1432
1585
  const backing = settleExpiredHoldBacking(
1433
1586
  credits.balance,
1434
- credits.expiredHoldBacking ?? 0,
1435
- reservation.expiredBacking ?? 0
1587
+ inForce.total,
1588
+ holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
1436
1589
  );
1437
1590
  const nextBalance = sumAmounts(credits.balance, -backing.used);
1438
1591
  const journal = {
@@ -1440,6 +1593,7 @@ function finishUnspent(store, reservation, transition) {
1440
1593
  operation: `${transition.transition}Reservation`,
1441
1594
  entryType: "credit",
1442
1595
  amount: 0,
1596
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1443
1597
  balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1444
1598
  source: transition.source,
1445
1599
  reservationId: reservation.id,
@@ -1462,7 +1616,11 @@ function finishUnspent(store, reservation, transition) {
1462
1616
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1463
1617
  credits.reserved = nextReserved;
1464
1618
  credits.balance = nextBalance;
1465
- Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1619
+ writeRecordedShares(store, reservation.userId, inForce);
1620
+ Object.assign(
1621
+ credits,
1622
+ expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
1623
+ );
1466
1624
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1467
1625
  return commitTransitionJournal(store, journal, existingEntryId);
1468
1626
  }
@@ -1505,7 +1663,7 @@ var InMemoryCreditRepository = class {
1505
1663
  * simultaneous first deliveries of the same reference would then both credit.
1506
1664
  */
1507
1665
  seedUser(userId, tier, initialBalance) {
1508
- assertRepresentableAmount(initialBalance, "initialBalance", { userId });
1666
+ assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
1509
1667
  const configured = getConfigMonthlyLimit(tier);
1510
1668
  assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
1511
1669
  const monthlyLimit = storedMonthlyLimit(configured);
@@ -1524,6 +1682,8 @@ var InMemoryCreditRepository = class {
1524
1682
  updatedAt: now
1525
1683
  };
1526
1684
  this.store.users.set(userId, credits);
1685
+ const grantLine = signupGrantJournalInput(userId, tier, initialBalance);
1686
+ if (grantLine) this.recordJournalEntry(grantLine);
1527
1687
  return credits;
1528
1688
  }
1529
1689
  async updateUserCredits(userId, updates) {
@@ -1586,9 +1746,11 @@ var InMemoryCreditRepository = class {
1586
1746
  userId,
1587
1747
  operation: "updateUserCredits"
1588
1748
  });
1589
- if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
1590
- writeExpiredHoldBacking(this.store, next, 0);
1591
- }
1749
+ writeExpiredHoldBacking(
1750
+ this.store,
1751
+ next,
1752
+ updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
1753
+ );
1592
1754
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1593
1755
  this.store.users.set(userId, next);
1594
1756
  }
@@ -1602,24 +1764,37 @@ var InMemoryCreditRepository = class {
1602
1764
  if (!credits) {
1603
1765
  throw new Error(`User ${userId} not found`);
1604
1766
  }
1605
- if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1606
- const capped = planBalanceCap(
1767
+ let planned;
1768
+ if (input.balance !== void 0) {
1769
+ planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1770
+ } else if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1771
+ planned = planBalanceCap(
1607
1772
  credits.balance,
1608
1773
  input.balanceCap,
1609
1774
  credits.reserved,
1610
1775
  credits.bonusCredits,
1611
1776
  credits.expiredHoldBacking
1612
1777
  );
1613
- credits.balance = capped.balance;
1614
- writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
1615
1778
  }
1779
+ const journalLine = planned ? tierChangeJournalInput({
1780
+ userId,
1781
+ previousTier: credits.tier,
1782
+ newTier: input.tier,
1783
+ previousBalance: credits.balance,
1784
+ newBalance: planned.balance,
1785
+ bonusCredits: credits.bonusCredits
1786
+ }) : null;
1616
1787
  credits.tier = input.tier;
1617
1788
  credits.monthlyLimit = input.monthlyLimit;
1618
- if (input.balance !== void 0) {
1619
- const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1789
+ if (planned) {
1620
1790
  credits.balance = planned.balance;
1621
1791
  writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1622
1792
  }
1793
+ writeExpiredHoldBacking(
1794
+ this.store,
1795
+ credits,
1796
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1797
+ );
1623
1798
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1624
1799
  if (input.subscriptionExpiresAt !== void 0) {
1625
1800
  if (input.subscriptionExpiresAt === null) {
@@ -1630,6 +1805,7 @@ var InMemoryCreditRepository = class {
1630
1805
  }
1631
1806
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1632
1807
  this.store.users.set(userId, credits);
1808
+ if (journalLine) this.recordJournalEntry(journalLine);
1633
1809
  }
1634
1810
  // ==================== Reservations ====================
1635
1811
  async createReservation(input) {
@@ -1781,6 +1957,11 @@ var InMemoryCreditRepository = class {
1781
1957
  { userId, operation: "addCredits" }
1782
1958
  );
1783
1959
  credits.bonusCredits = nextBonusCredits;
1960
+ writeExpiredHoldBacking(
1961
+ this.store,
1962
+ credits,
1963
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1964
+ );
1784
1965
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1785
1966
  this.store.users.set(userId, credits);
1786
1967
  const transaction = this.recordTransaction({
@@ -1800,6 +1981,7 @@ var InMemoryCreditRepository = class {
1800
1981
  userId,
1801
1982
  entryType: "credit",
1802
1983
  amount,
1984
+ balanceBefore: previousTotal,
1803
1985
  balanceAfter: newTotal,
1804
1986
  source: payload.source,
1805
1987
  referenceId: transaction.id ?? paymentRef ?? "unknown",
@@ -1833,7 +2015,7 @@ var InMemoryCreditRepository = class {
1833
2015
  });
1834
2016
  }
1835
2017
  }
1836
- async deductCreditsAtomic(userId, amount) {
2018
+ async deductCreditsAtomic(userId, amount, journal) {
1837
2019
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
1838
2020
  const credits = this.store.users.get(userId);
1839
2021
  if (!credits) {
@@ -1859,11 +2041,26 @@ var InMemoryCreditRepository = class {
1859
2041
  { previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
1860
2042
  { userId, operation: "deductCredits" }
1861
2043
  );
2044
+ const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1862
2045
  credits.balance = nextBalance;
1863
2046
  credits.bonusCredits = nextBonusCredits;
2047
+ writeExpiredHoldBacking(this.store, credits, held);
1864
2048
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1865
2049
  this.store.users.set(userId, credits);
1866
- return { previousBalance, newBalance };
2050
+ if (!journal) return { previousBalance, newBalance };
2051
+ const entry = this.recordJournalEntry({
2052
+ userId,
2053
+ entryType: "debit",
2054
+ amount,
2055
+ balanceBefore: previousBalance,
2056
+ balanceAfter: newBalance,
2057
+ source: journal.source ?? "operation_commit",
2058
+ referenceId: journal.referenceId,
2059
+ referenceType: journal.referenceType ?? "adjustment",
2060
+ description: journal.description,
2061
+ metadata: journal.metadata
2062
+ });
2063
+ return { previousBalance, newBalance, journalEntryId: entry.id };
1867
2064
  }
1868
2065
  // ==================== Transactions ====================
1869
2066
  async createTransaction(input) {
@@ -1977,6 +2174,11 @@ var InMemoryCreditRepository = class {
1977
2174
  assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
1978
2175
  userId: input.userId
1979
2176
  });
2177
+ if (input.balanceBefore !== void 0) {
2178
+ assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
2179
+ userId: input.userId
2180
+ });
2181
+ }
1980
2182
  assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
1981
2183
  assertPublicJournalKey(input.idempotencyKey, input.userId);
1982
2184
  const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
@@ -1992,6 +2194,7 @@ var InMemoryCreditRepository = class {
1992
2194
  userId: input.userId,
1993
2195
  entryType: input.entryType,
1994
2196
  amount: input.amount,
2197
+ ...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
1995
2198
  balanceAfter: input.balanceAfter,
1996
2199
  source: input.source,
1997
2200
  referenceId: input.referenceId,
@@ -2144,6 +2347,7 @@ var InMemoryCreditRepository = class {
2144
2347
  userId,
2145
2348
  entryType: change > 0 ? "credit" : "debit",
2146
2349
  amount: Math.abs(change),
2350
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2147
2351
  balanceAfter,
2148
2352
  source: "monthly_reset",
2149
2353
  referenceId: `reset-${Date.now()}`,
@@ -2223,7 +2427,8 @@ var InMemoryCreditRepository = class {
2223
2427
  userId,
2224
2428
  entryType: "debit",
2225
2429
  amount: 0,
2226
- // No credits deducted, just tier change
2430
+ // 0, however far the cap lowered the balance: the totals record the move
2431
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2227
2432
  balanceAfter,
2228
2433
  source: "subscription_downgrade",
2229
2434
  referenceId: `downgrade-${Date.now()}`,