@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
@@ -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",
@@ -510,17 +591,25 @@ var DEFAULT_CONFIG = {
510
591
  }
511
592
  };
512
593
  var currentConfig = DEFAULT_CONFIG;
594
+ function isOperationCostMap(value) {
595
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
596
+ return Object.values(value).every((cost) => isRepresentableAmount(cost) && cost >= 0);
597
+ }
513
598
  function loadConfigFromEnv() {
514
599
  const envConfig = {};
515
600
  const operationCostsEnv = process.env.CREDITS_OPERATION_COSTS;
516
601
  if (operationCostsEnv) {
517
602
  try {
518
603
  const parsed = JSON.parse(operationCostsEnv);
519
- if (typeof parsed === "object" && parsed !== null) {
604
+ if (isOperationCostMap(parsed)) {
520
605
  envConfig.operationCosts = {
521
606
  ...DEFAULT_CONFIG.operationCosts,
522
607
  ...parsed
523
608
  };
609
+ } else {
610
+ console.warn(
611
+ "[Credits Config] Ignoring CREDITS_OPERATION_COSTS: it must be a JSON object whose costs are numbers >= 0, exact to the cent"
612
+ );
524
613
  }
525
614
  } catch {
526
615
  console.warn("[Credits Config] Failed to parse CREDITS_OPERATION_COSTS env var as JSON");
@@ -847,7 +936,7 @@ function validateBalanceUpdate(current, updates) {
847
936
  const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
848
937
  const errors = [];
849
938
  if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
850
- const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
939
+ const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
851
940
  if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
852
941
  errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
853
942
  }
@@ -865,6 +954,50 @@ function assertValidBalanceUpdate(current, updates) {
865
954
  throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
866
955
  }
867
956
  }
957
+ function signupGrantJournalInput(userId, tier, grant) {
958
+ if (grant <= 0) return null;
959
+ return {
960
+ userId,
961
+ entryType: "credit",
962
+ amount: grant,
963
+ balanceBefore: 0,
964
+ balanceAfter: grant,
965
+ source: "bonus",
966
+ referenceId: `signup-${userId}`,
967
+ referenceType: "adjustment",
968
+ description: `Signup grant for ${tier} tier.`,
969
+ metadata: { tier }
970
+ };
971
+ }
972
+ function tierChangeJournalInput(input) {
973
+ const { userId, previousTier, newTier, previousBalance, newBalance, bonusCredits } = input;
974
+ const change = sumAmounts(newBalance, -previousBalance);
975
+ if (change === 0) return null;
976
+ const raised = change > 0;
977
+ const amount = raised ? newBalance : 0;
978
+ const balanceBefore = sumAmounts(previousBalance, bonusCredits);
979
+ const balanceAfter = sumAmounts(newBalance, bonusCredits);
980
+ assertRepresentableFields(
981
+ {
982
+ "journal amount": amount,
983
+ "journal balanceBefore": balanceBefore,
984
+ "journal balanceAfter": balanceAfter
985
+ },
986
+ { userId, operation: "updateUserTier" }
987
+ );
988
+ return {
989
+ userId,
990
+ entryType: raised ? "credit" : "debit",
991
+ amount,
992
+ balanceBefore,
993
+ balanceAfter,
994
+ source: raised ? "subscription_upgrade" : "subscription_downgrade",
995
+ referenceId: `tier-${Date.now()}`,
996
+ referenceType: "subscription",
997
+ description: `Tier changed from ${previousTier} to ${newTier}.`,
998
+ metadata: { previousTier, newTier, previousBalance, newBalance }
999
+ };
1000
+ }
868
1001
 
869
1002
  // src/repository/memory/snapshot.ts
870
1003
  function copyRecord(record) {
@@ -1100,6 +1233,7 @@ function commitTransitionJournal(store, input, existingId) {
1100
1233
  userId: input.userId,
1101
1234
  entryType: input.entryType,
1102
1235
  amount: input.amount,
1236
+ balanceBefore: input.balanceBefore,
1103
1237
  balanceAfter: input.balanceAfter,
1104
1238
  source: input.source,
1105
1239
  referenceId: input.reservationId,
@@ -1149,16 +1283,28 @@ function openHolds(store, userId) {
1149
1283
  (reservation) => reservation.status === "reserved"
1150
1284
  );
1151
1285
  }
1286
+ function expiredBackingInForce(store, credits) {
1287
+ const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1288
+ if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
1289
+ return allocateExpiredBacking(openHolds(store, credits.userId), total);
1290
+ }
1291
+ return { total, frontier: credits.expiredBackingFrontier ?? null };
1292
+ }
1293
+ function writeRecordedShares(store, userId, allocation) {
1294
+ for (const { id, expiredBacking } of allocation.rewrites ?? []) {
1295
+ const hold = getReservation(store, userId, id);
1296
+ if (hold) hold.expiredBacking = expiredBacking;
1297
+ }
1298
+ }
1152
1299
  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;
1300
+ if (next <= 0) {
1301
+ Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
1302
+ return;
1160
1303
  }
1161
- Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
1304
+ if (next === credits.expiredHoldBacking) return;
1305
+ const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
1306
+ writeRecordedShares(store, credits.userId, allocation);
1307
+ Object.assign(credits, expiredBackingPatch(allocation, credits));
1162
1308
  }
1163
1309
  function getReservation(store, userId, reservationId) {
1164
1310
  return store.reservations.get(userId)?.get(reservationId);
@@ -1207,6 +1353,7 @@ async function reserveCreditsV2(store, input) {
1207
1353
  };
1208
1354
  }
1209
1355
  const now = (/* @__PURE__ */ new Date()).toISOString();
1356
+ const backing = expiredBackingInForce(store, credits);
1210
1357
  const reservation = {
1211
1358
  id: generateId(),
1212
1359
  userId: input.userId,
@@ -1219,7 +1366,7 @@ async function reserveCreditsV2(store, input) {
1219
1366
  // The hold-origin fact. Written here and nowhere else, in the same
1220
1367
  // critical section that raises `reserved` below, so the row and the hold
1221
1368
  // become visible together. `createReservation` cannot set it.
1222
- holdPlacedAt: now
1369
+ holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
1223
1370
  };
1224
1371
  if (!store.reservations.has(input.userId)) {
1225
1372
  store.reservations.set(input.userId, /* @__PURE__ */ new Map());
@@ -1237,6 +1384,8 @@ async function reserveCreditsV2(store, input) {
1237
1384
  );
1238
1385
  }
1239
1386
  credits.reserved = nextReserved;
1387
+ writeRecordedShares(store, input.userId, backing);
1388
+ Object.assign(credits, expiredBackingPatch(backing, credits));
1240
1389
  credits.updatedAt = now;
1241
1390
  return { outcome: "created", reservation: copyRecord(reservation) };
1242
1391
  });
@@ -1257,11 +1406,12 @@ async function commitReservationV2(store, userId, reservationId, options) {
1257
1406
  const amount = reservation.amount;
1258
1407
  await yieldPoint(store);
1259
1408
  assertReservedCoversHold(credits, userId, reservationId, amount);
1409
+ const backing = expiredBackingInForce(store, credits);
1260
1410
  const paid = planHoldCommit(
1261
1411
  credits.balance,
1262
1412
  credits.bonusCredits,
1263
- credits.expiredHoldBacking ?? 0,
1264
- reservation.expiredBacking ?? 0,
1413
+ backing.total,
1414
+ holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
1265
1415
  amount
1266
1416
  );
1267
1417
  if (!paid.covered) {
@@ -1279,6 +1429,7 @@ async function commitReservationV2(store, userId, reservationId, options) {
1279
1429
  operation: "commitReservation",
1280
1430
  entryType: "debit",
1281
1431
  amount,
1432
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1282
1433
  balanceAfter,
1283
1434
  source: "operation_commit",
1284
1435
  reservationId,
@@ -1306,9 +1457,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1306
1457
  credits.bonusCredits = nextBonusCredits;
1307
1458
  credits.reserved = nextReserved;
1308
1459
  credits.monthlyUsed = nextMonthlyUsed;
1460
+ writeRecordedShares(store, userId, backing);
1309
1461
  Object.assign(
1310
1462
  credits,
1311
- expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1463
+ expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
1312
1464
  );
1313
1465
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1314
1466
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
@@ -1389,10 +1541,11 @@ function finishUnspent(store, reservation, transition) {
1389
1541
  reservation.id,
1390
1542
  reservation.amount
1391
1543
  );
1544
+ const inForce = expiredBackingInForce(store, credits);
1392
1545
  const backing = settleExpiredHoldBacking(
1393
1546
  credits.balance,
1394
- credits.expiredHoldBacking ?? 0,
1395
- reservation.expiredBacking ?? 0
1547
+ inForce.total,
1548
+ holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
1396
1549
  );
1397
1550
  const nextBalance = sumAmounts(credits.balance, -backing.used);
1398
1551
  const journal = {
@@ -1400,6 +1553,7 @@ function finishUnspent(store, reservation, transition) {
1400
1553
  operation: `${transition.transition}Reservation`,
1401
1554
  entryType: "credit",
1402
1555
  amount: 0,
1556
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1403
1557
  balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1404
1558
  source: transition.source,
1405
1559
  reservationId: reservation.id,
@@ -1422,7 +1576,11 @@ function finishUnspent(store, reservation, transition) {
1422
1576
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1423
1577
  credits.reserved = nextReserved;
1424
1578
  credits.balance = nextBalance;
1425
- Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1579
+ writeRecordedShares(store, reservation.userId, inForce);
1580
+ Object.assign(
1581
+ credits,
1582
+ expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
1583
+ );
1426
1584
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1427
1585
  return commitTransitionJournal(store, journal, existingEntryId);
1428
1586
  }
@@ -1465,7 +1623,7 @@ var InMemoryCreditRepository = class {
1465
1623
  * simultaneous first deliveries of the same reference would then both credit.
1466
1624
  */
1467
1625
  seedUser(userId, tier, initialBalance) {
1468
- assertRepresentableAmount(initialBalance, "initialBalance", { userId });
1626
+ assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
1469
1627
  const configured = getConfigMonthlyLimit(tier);
1470
1628
  assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
1471
1629
  const monthlyLimit = storedMonthlyLimit(configured);
@@ -1484,6 +1642,8 @@ var InMemoryCreditRepository = class {
1484
1642
  updatedAt: now
1485
1643
  };
1486
1644
  this.store.users.set(userId, credits);
1645
+ const grantLine = signupGrantJournalInput(userId, tier, initialBalance);
1646
+ if (grantLine) this.recordJournalEntry(grantLine);
1487
1647
  return credits;
1488
1648
  }
1489
1649
  async updateUserCredits(userId, updates) {
@@ -1546,9 +1706,11 @@ var InMemoryCreditRepository = class {
1546
1706
  userId,
1547
1707
  operation: "updateUserCredits"
1548
1708
  });
1549
- if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
1550
- writeExpiredHoldBacking(this.store, next, 0);
1551
- }
1709
+ writeExpiredHoldBacking(
1710
+ this.store,
1711
+ next,
1712
+ updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
1713
+ );
1552
1714
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1553
1715
  this.store.users.set(userId, next);
1554
1716
  }
@@ -1562,24 +1724,37 @@ var InMemoryCreditRepository = class {
1562
1724
  if (!credits) {
1563
1725
  throw new Error(`User ${userId} not found`);
1564
1726
  }
1565
- if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1566
- const capped = planBalanceCap(
1727
+ let planned;
1728
+ if (input.balance !== void 0) {
1729
+ planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1730
+ } else if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1731
+ planned = planBalanceCap(
1567
1732
  credits.balance,
1568
1733
  input.balanceCap,
1569
1734
  credits.reserved,
1570
1735
  credits.bonusCredits,
1571
1736
  credits.expiredHoldBacking
1572
1737
  );
1573
- credits.balance = capped.balance;
1574
- writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
1575
1738
  }
1739
+ const journalLine = planned ? tierChangeJournalInput({
1740
+ userId,
1741
+ previousTier: credits.tier,
1742
+ newTier: input.tier,
1743
+ previousBalance: credits.balance,
1744
+ newBalance: planned.balance,
1745
+ bonusCredits: credits.bonusCredits
1746
+ }) : null;
1576
1747
  credits.tier = input.tier;
1577
1748
  credits.monthlyLimit = input.monthlyLimit;
1578
- if (input.balance !== void 0) {
1579
- const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1749
+ if (planned) {
1580
1750
  credits.balance = planned.balance;
1581
1751
  writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1582
1752
  }
1753
+ writeExpiredHoldBacking(
1754
+ this.store,
1755
+ credits,
1756
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1757
+ );
1583
1758
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1584
1759
  if (input.subscriptionExpiresAt !== void 0) {
1585
1760
  if (input.subscriptionExpiresAt === null) {
@@ -1590,6 +1765,7 @@ var InMemoryCreditRepository = class {
1590
1765
  }
1591
1766
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1592
1767
  this.store.users.set(userId, credits);
1768
+ if (journalLine) this.recordJournalEntry(journalLine);
1593
1769
  }
1594
1770
  // ==================== Reservations ====================
1595
1771
  async createReservation(input) {
@@ -1741,6 +1917,11 @@ var InMemoryCreditRepository = class {
1741
1917
  { userId, operation: "addCredits" }
1742
1918
  );
1743
1919
  credits.bonusCredits = nextBonusCredits;
1920
+ writeExpiredHoldBacking(
1921
+ this.store,
1922
+ credits,
1923
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1924
+ );
1744
1925
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1745
1926
  this.store.users.set(userId, credits);
1746
1927
  const transaction = this.recordTransaction({
@@ -1760,6 +1941,7 @@ var InMemoryCreditRepository = class {
1760
1941
  userId,
1761
1942
  entryType: "credit",
1762
1943
  amount,
1944
+ balanceBefore: previousTotal,
1763
1945
  balanceAfter: newTotal,
1764
1946
  source: payload.source,
1765
1947
  referenceId: transaction.id ?? paymentRef ?? "unknown",
@@ -1793,7 +1975,7 @@ var InMemoryCreditRepository = class {
1793
1975
  });
1794
1976
  }
1795
1977
  }
1796
- async deductCreditsAtomic(userId, amount) {
1978
+ async deductCreditsAtomic(userId, amount, journal) {
1797
1979
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
1798
1980
  const credits = this.store.users.get(userId);
1799
1981
  if (!credits) {
@@ -1819,11 +2001,26 @@ var InMemoryCreditRepository = class {
1819
2001
  { previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
1820
2002
  { userId, operation: "deductCredits" }
1821
2003
  );
2004
+ const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1822
2005
  credits.balance = nextBalance;
1823
2006
  credits.bonusCredits = nextBonusCredits;
2007
+ writeExpiredHoldBacking(this.store, credits, held);
1824
2008
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1825
2009
  this.store.users.set(userId, credits);
1826
- return { previousBalance, newBalance };
2010
+ if (!journal) return { previousBalance, newBalance };
2011
+ const entry = this.recordJournalEntry({
2012
+ userId,
2013
+ entryType: "debit",
2014
+ amount,
2015
+ balanceBefore: previousBalance,
2016
+ balanceAfter: newBalance,
2017
+ source: journal.source ?? "operation_commit",
2018
+ referenceId: journal.referenceId,
2019
+ referenceType: journal.referenceType ?? "adjustment",
2020
+ description: journal.description,
2021
+ metadata: journal.metadata
2022
+ });
2023
+ return { previousBalance, newBalance, journalEntryId: entry.id };
1827
2024
  }
1828
2025
  // ==================== Transactions ====================
1829
2026
  async createTransaction(input) {
@@ -1937,6 +2134,11 @@ var InMemoryCreditRepository = class {
1937
2134
  assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
1938
2135
  userId: input.userId
1939
2136
  });
2137
+ if (input.balanceBefore !== void 0) {
2138
+ assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
2139
+ userId: input.userId
2140
+ });
2141
+ }
1940
2142
  assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
1941
2143
  assertPublicJournalKey(input.idempotencyKey, input.userId);
1942
2144
  const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
@@ -1952,6 +2154,7 @@ var InMemoryCreditRepository = class {
1952
2154
  userId: input.userId,
1953
2155
  entryType: input.entryType,
1954
2156
  amount: input.amount,
2157
+ ...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
1955
2158
  balanceAfter: input.balanceAfter,
1956
2159
  source: input.source,
1957
2160
  referenceId: input.referenceId,
@@ -2104,6 +2307,7 @@ var InMemoryCreditRepository = class {
2104
2307
  userId,
2105
2308
  entryType: change > 0 ? "credit" : "debit",
2106
2309
  amount: Math.abs(change),
2310
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2107
2311
  balanceAfter,
2108
2312
  source: "monthly_reset",
2109
2313
  referenceId: `reset-${Date.now()}`,
@@ -2183,7 +2387,8 @@ var InMemoryCreditRepository = class {
2183
2387
  userId,
2184
2388
  entryType: "debit",
2185
2389
  amount: 0,
2186
- // No credits deducted, just tier change
2390
+ // 0, however far the cap lowered the balance: the totals record the move
2391
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2187
2392
  balanceAfter,
2188
2393
  source: "subscription_downgrade",
2189
2394
  referenceId: `downgrade-${Date.now()}`,