@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
package/dist/index.js CHANGED
@@ -167,6 +167,40 @@ function floatSum(values) {
167
167
  for (const value of values) total += value;
168
168
  return total;
169
169
  }
170
+ function multiplyAmount(unit, quantity) {
171
+ const cents = toCents(unit);
172
+ const product = cents === null ? Number.NaN : cents * quantity;
173
+ if (cents === null || cents < 0 || !Number.isSafeInteger(quantity) || quantity < 0 || !Number.isSafeInteger(product) || product > MAX_CENTS) {
174
+ throw new CreditError(
175
+ `Cannot price ${quantity} units at ${unit}: the price must be a non-negative amount exact to ${CREDIT_AMOUNT_SCALE} decimals, the quantity a whole number, and the result at most ${CREDIT_AMOUNT_MAX}`,
176
+ CreditErrorCode.INVALID_AMOUNT,
177
+ { unit, quantity, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE }
178
+ );
179
+ }
180
+ return product / 100;
181
+ }
182
+ var FLOAT_NOISE_FLOOR = 1e-6;
183
+ var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
184
+ function floatNoise(value) {
185
+ return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
186
+ }
187
+ function canonicalAmount(value) {
188
+ if (typeof value !== "number" || !Number.isFinite(value)) return value;
189
+ const cents = Math.round(value * 100);
190
+ if (!Number.isSafeInteger(cents)) return value;
191
+ const onGrid = cents / 100 + 0;
192
+ return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
193
+ }
194
+ function storedAmountFloor(value) {
195
+ const canonical = canonicalAmount(value);
196
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
197
+ return Math.floor(canonical * 100) / 100;
198
+ }
199
+ function storedAmountCeil(value) {
200
+ const canonical = canonicalAmount(value);
201
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
202
+ return Math.ceil(canonical * 100) / 100;
203
+ }
170
204
  function isValidCreditAmount(value) {
171
205
  if (typeof value !== "number") return false;
172
206
  const cents = toCents(value);
@@ -222,14 +256,19 @@ function backedBalanceFloor(reserved, bonusCredits) {
222
256
  function cappedBalance(balance, cap, reserved, bonusCredits) {
223
257
  return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
224
258
  }
225
- function spendableBalance(balance, expiredHoldBacking = 0) {
226
- return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
259
+ function heldExpiredBacking(balance, stored) {
260
+ if (!(stored !== void 0 && stored > 0)) return 0;
261
+ return Math.max(0, Math.min(stored, balance));
262
+ }
263
+ function spendableBalance(balance, expiredHoldBacking) {
264
+ return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
227
265
  }
228
266
  function planBalanceTarget(target, reserved, bonusCredits) {
267
+ assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
229
268
  const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
230
269
  return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
231
270
  }
232
- function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
271
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
233
272
  const spendable = spendableBalance(balance, expiredHoldBacking);
234
273
  const target = Math.min(spendable, cap);
235
274
  const kept = Math.max(
@@ -240,8 +279,8 @@ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
240
279
  return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
241
280
  }
242
281
  function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
243
- const held = Math.min(expiredHoldBacking, balance);
244
- const used = Math.min(held, holdBacking);
282
+ const held = heldExpiredBacking(balance, expiredHoldBacking);
283
+ const used = Math.max(0, Math.min(held, holdBacking));
245
284
  return { used, remaining: sumAmounts(held, -used) };
246
285
  }
247
286
  function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
@@ -256,27 +295,83 @@ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking,
256
295
  expiredHoldBacking: backing.remaining
257
296
  };
258
297
  }
259
- function attributeExpiredBacking(holds, total) {
260
- const placed = holds.filter((hold) => hold.holdPlacedAt).sort(
261
- (a, b) => a.holdPlacedAt === b.holdPlacedAt ? a.id < b.id ? -1 : 1 : a.holdPlacedAt < b.holdPlacedAt ? -1 : 1
262
- );
263
- const shares = /* @__PURE__ */ new Map();
264
- let left = total;
265
- for (const hold of placed) {
266
- const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);
267
- shares.set(hold.id, kept);
268
- left = sumAmounts(left, -kept);
298
+ var NO_EXPIRED_BACKING_PREFIX = {
299
+ holdPlacedAt: "",
300
+ id: "",
301
+ share: 0
302
+ };
303
+ function compareHolds(a, b) {
304
+ if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
305
+ if (a.id === b.id) return 0;
306
+ return a.id < b.id ? -1 : 1;
307
+ }
308
+ function recordedExpiredBacking(hold) {
309
+ const stored = hold.expiredBacking;
310
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
311
+ if (!isValidCreditAmount(hold.amount)) return 0;
312
+ return Math.min(storedAmountCeil(stored), hold.amount);
313
+ }
314
+ function allocateExpiredBacking(holds, total) {
315
+ const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
316
+ const wanted = Math.max(0, total);
317
+ if (wanted === 0) return { total: 0, frontier: null };
318
+ const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
319
+ const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
320
+ let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
321
+ for (const hold of [...recorded].reverse()) {
322
+ if (left >= 0) break;
323
+ const share = shares.get(hold.id);
324
+ const cut = Math.min(share, -left);
325
+ shares.set(hold.id, sumAmounts(share, -cut));
326
+ left = sumAmounts(left, cut);
269
327
  }
328
+ let frontier = NO_EXPIRED_BACKING_PREFIX;
270
329
  for (const hold of placed) {
330
+ if (left <= 0) break;
331
+ if (shares.has(hold.id)) continue;
332
+ const share = Math.min(hold.amount, left);
333
+ frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
334
+ left = sumAmounts(left, -share);
335
+ }
336
+ for (const hold of recorded) {
337
+ if (left <= 0) break;
271
338
  const share = shares.get(hold.id);
272
339
  const added = Math.min(sumAmounts(hold.amount, -share), left);
273
340
  shares.set(hold.id, sumAmounts(share, added));
274
341
  left = sumAmounts(left, -added);
275
342
  }
276
- return shares;
343
+ const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
344
+ return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
277
345
  }
278
- function expiredHoldBackingPatch(next, stored = 0) {
279
- return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
346
+ function holdExpiredBacking(hold, frontier, rewrites) {
347
+ const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
348
+ const recorded = recordedExpiredBacking(
349
+ rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
350
+ );
351
+ if (recorded > 0) return recorded;
352
+ if (!frontier || !hold.holdPlacedAt) return 0;
353
+ const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
354
+ if (order > 0) return 0;
355
+ return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
356
+ }
357
+ function holdPlacedAtAfter(now, frontier) {
358
+ if (!frontier?.holdPlacedAt) return now;
359
+ const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
360
+ return now > earliest ? now : earliest;
361
+ }
362
+ function expiredBackingPatch(next, stored) {
363
+ const total = Math.max(0, next.total);
364
+ const patch = {};
365
+ const storedTotal = stored.expiredHoldBacking;
366
+ if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
367
+ patch.expiredHoldBacking = total;
368
+ }
369
+ if (total <= 0) {
370
+ if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
371
+ } else if (next.frontier) {
372
+ patch.expiredBackingFrontier = next.frontier;
373
+ }
374
+ return patch;
280
375
  }
281
376
  function assertValidBalanceTarget(value, field, context) {
282
377
  assertRepresentableAmount(value, field, context);
@@ -289,7 +384,10 @@ function assertValidBalanceTarget(value, field, context) {
289
384
  }
290
385
  }
291
386
  function assertValidTierBalanceInput(input, context) {
292
- if (input.balanceCap === void 0) return;
387
+ if (input.balanceCap === void 0) {
388
+ if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
389
+ return;
390
+ }
293
391
  if (input.balance !== void 0) {
294
392
  throw new CreditError(
295
393
  "A tier update cannot set both balance and balanceCap",
@@ -815,17 +913,25 @@ var DEFAULT_CONFIG = {
815
913
  }
816
914
  };
817
915
  var currentConfig = DEFAULT_CONFIG;
916
+ function isOperationCostMap(value) {
917
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
918
+ return Object.values(value).every((cost) => isRepresentableAmount(cost) && cost >= 0);
919
+ }
818
920
  function loadConfigFromEnv() {
819
921
  const envConfig = {};
820
922
  const operationCostsEnv = process.env.CREDITS_OPERATION_COSTS;
821
923
  if (operationCostsEnv) {
822
924
  try {
823
925
  const parsed = JSON.parse(operationCostsEnv);
824
- if (typeof parsed === "object" && parsed !== null) {
926
+ if (isOperationCostMap(parsed)) {
825
927
  envConfig.operationCosts = {
826
928
  ...DEFAULT_CONFIG.operationCosts,
827
929
  ...parsed
828
930
  };
931
+ } else {
932
+ console.warn(
933
+ "[Credits Config] Ignoring CREDITS_OPERATION_COSTS: it must be a JSON object whose costs are numbers >= 0, exact to the cent"
934
+ );
829
935
  }
830
936
  } catch {
831
937
  console.warn("[Credits Config] Failed to parse CREDITS_OPERATION_COSTS env var as JSON");
@@ -1255,7 +1361,7 @@ function validateBalanceUpdate(current, updates) {
1255
1361
  const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
1256
1362
  const errors = [];
1257
1363
  if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
1258
- const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
1364
+ const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
1259
1365
  if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
1260
1366
  errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
1261
1367
  }
@@ -1273,6 +1379,50 @@ function assertValidBalanceUpdate(current, updates) {
1273
1379
  throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
1274
1380
  }
1275
1381
  }
1382
+ function signupGrantJournalInput(userId, tier, grant) {
1383
+ if (grant <= 0) return null;
1384
+ return {
1385
+ userId,
1386
+ entryType: "credit",
1387
+ amount: grant,
1388
+ balanceBefore: 0,
1389
+ balanceAfter: grant,
1390
+ source: "bonus",
1391
+ referenceId: `signup-${userId}`,
1392
+ referenceType: "adjustment",
1393
+ description: `Signup grant for ${tier} tier.`,
1394
+ metadata: { tier }
1395
+ };
1396
+ }
1397
+ function tierChangeJournalInput(input) {
1398
+ const { userId, previousTier, newTier, previousBalance, newBalance, bonusCredits } = input;
1399
+ const change = sumAmounts(newBalance, -previousBalance);
1400
+ if (change === 0) return null;
1401
+ const raised = change > 0;
1402
+ const amount = raised ? newBalance : 0;
1403
+ const balanceBefore = sumAmounts(previousBalance, bonusCredits);
1404
+ const balanceAfter = sumAmounts(newBalance, bonusCredits);
1405
+ assertRepresentableFields(
1406
+ {
1407
+ "journal amount": amount,
1408
+ "journal balanceBefore": balanceBefore,
1409
+ "journal balanceAfter": balanceAfter
1410
+ },
1411
+ { userId, operation: "updateUserTier" }
1412
+ );
1413
+ return {
1414
+ userId,
1415
+ entryType: raised ? "credit" : "debit",
1416
+ amount,
1417
+ balanceBefore,
1418
+ balanceAfter,
1419
+ source: raised ? "subscription_upgrade" : "subscription_downgrade",
1420
+ referenceId: `tier-${Date.now()}`,
1421
+ referenceType: "subscription",
1422
+ description: `Tier changed from ${previousTier} to ${newTier}.`,
1423
+ metadata: { previousTier, newTier, previousBalance, newBalance }
1424
+ };
1425
+ }
1276
1426
 
1277
1427
  // src/repository/memory/snapshot.ts
1278
1428
  function copyRecord(record) {
@@ -1407,6 +1557,7 @@ function commitTransitionJournal(store, input, existingId) {
1407
1557
  userId: input.userId,
1408
1558
  entryType: input.entryType,
1409
1559
  amount: input.amount,
1560
+ balanceBefore: input.balanceBefore,
1410
1561
  balanceAfter: input.balanceAfter,
1411
1562
  source: input.source,
1412
1563
  referenceId: input.reservationId,
@@ -1456,16 +1607,28 @@ function openHolds(store, userId) {
1456
1607
  (reservation) => reservation.status === "reserved"
1457
1608
  );
1458
1609
  }
1610
+ function expiredBackingInForce(store, credits) {
1611
+ const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1612
+ if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
1613
+ return allocateExpiredBacking(openHolds(store, credits.userId), total);
1614
+ }
1615
+ return { total, frontier: credits.expiredBackingFrontier ?? null };
1616
+ }
1617
+ function writeRecordedShares(store, userId, allocation) {
1618
+ for (const { id, expiredBacking } of allocation.rewrites ?? []) {
1619
+ const hold = getReservation(store, userId, id);
1620
+ if (hold) hold.expiredBacking = expiredBacking;
1621
+ }
1622
+ }
1459
1623
  function writeExpiredHoldBacking(store, credits, next) {
1460
- const stored = credits.expiredHoldBacking;
1461
- if (next <= 0 && !stored) return;
1462
- const holds = openHolds(store, credits.userId);
1463
- const shares = attributeExpiredBacking(holds, next);
1464
- for (const hold of holds) {
1465
- const share = shares.get(hold.id) ?? 0;
1466
- if (share !== (hold.expiredBacking ?? 0)) hold.expiredBacking = share;
1624
+ if (next <= 0) {
1625
+ Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
1626
+ return;
1467
1627
  }
1468
- Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
1628
+ if (next === credits.expiredHoldBacking) return;
1629
+ const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
1630
+ writeRecordedShares(store, credits.userId, allocation);
1631
+ Object.assign(credits, expiredBackingPatch(allocation, credits));
1469
1632
  }
1470
1633
  function getReservation(store, userId, reservationId) {
1471
1634
  return store.reservations.get(userId)?.get(reservationId);
@@ -1514,6 +1677,7 @@ async function reserveCreditsV2(store, input) {
1514
1677
  };
1515
1678
  }
1516
1679
  const now = (/* @__PURE__ */ new Date()).toISOString();
1680
+ const backing = expiredBackingInForce(store, credits);
1517
1681
  const reservation = {
1518
1682
  id: generateId(),
1519
1683
  userId: input.userId,
@@ -1526,7 +1690,7 @@ async function reserveCreditsV2(store, input) {
1526
1690
  // The hold-origin fact. Written here and nowhere else, in the same
1527
1691
  // critical section that raises `reserved` below, so the row and the hold
1528
1692
  // become visible together. `createReservation` cannot set it.
1529
- holdPlacedAt: now
1693
+ holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
1530
1694
  };
1531
1695
  if (!store.reservations.has(input.userId)) {
1532
1696
  store.reservations.set(input.userId, /* @__PURE__ */ new Map());
@@ -1544,6 +1708,8 @@ async function reserveCreditsV2(store, input) {
1544
1708
  );
1545
1709
  }
1546
1710
  credits.reserved = nextReserved;
1711
+ writeRecordedShares(store, input.userId, backing);
1712
+ Object.assign(credits, expiredBackingPatch(backing, credits));
1547
1713
  credits.updatedAt = now;
1548
1714
  return { outcome: "created", reservation: copyRecord(reservation) };
1549
1715
  });
@@ -1564,11 +1730,12 @@ async function commitReservationV2(store, userId, reservationId, options) {
1564
1730
  const amount = reservation.amount;
1565
1731
  await yieldPoint(store);
1566
1732
  assertReservedCoversHold(credits, userId, reservationId, amount);
1733
+ const backing = expiredBackingInForce(store, credits);
1567
1734
  const paid = planHoldCommit(
1568
1735
  credits.balance,
1569
1736
  credits.bonusCredits,
1570
- credits.expiredHoldBacking ?? 0,
1571
- reservation.expiredBacking ?? 0,
1737
+ backing.total,
1738
+ holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
1572
1739
  amount
1573
1740
  );
1574
1741
  if (!paid.covered) {
@@ -1586,6 +1753,7 @@ async function commitReservationV2(store, userId, reservationId, options) {
1586
1753
  operation: "commitReservation",
1587
1754
  entryType: "debit",
1588
1755
  amount,
1756
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1589
1757
  balanceAfter,
1590
1758
  source: "operation_commit",
1591
1759
  reservationId,
@@ -1613,9 +1781,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1613
1781
  credits.bonusCredits = nextBonusCredits;
1614
1782
  credits.reserved = nextReserved;
1615
1783
  credits.monthlyUsed = nextMonthlyUsed;
1784
+ writeRecordedShares(store, userId, backing);
1616
1785
  Object.assign(
1617
1786
  credits,
1618
- expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1787
+ expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
1619
1788
  );
1620
1789
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1621
1790
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
@@ -1696,10 +1865,11 @@ function finishUnspent(store, reservation, transition) {
1696
1865
  reservation.id,
1697
1866
  reservation.amount
1698
1867
  );
1868
+ const inForce = expiredBackingInForce(store, credits);
1699
1869
  const backing = settleExpiredHoldBacking(
1700
1870
  credits.balance,
1701
- credits.expiredHoldBacking ?? 0,
1702
- reservation.expiredBacking ?? 0
1871
+ inForce.total,
1872
+ holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
1703
1873
  );
1704
1874
  const nextBalance = sumAmounts(credits.balance, -backing.used);
1705
1875
  const journal = {
@@ -1707,6 +1877,7 @@ function finishUnspent(store, reservation, transition) {
1707
1877
  operation: `${transition.transition}Reservation`,
1708
1878
  entryType: "credit",
1709
1879
  amount: 0,
1880
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1710
1881
  balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1711
1882
  source: transition.source,
1712
1883
  reservationId: reservation.id,
@@ -1729,7 +1900,11 @@ function finishUnspent(store, reservation, transition) {
1729
1900
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1730
1901
  credits.reserved = nextReserved;
1731
1902
  credits.balance = nextBalance;
1732
- Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
1903
+ writeRecordedShares(store, reservation.userId, inForce);
1904
+ Object.assign(
1905
+ credits,
1906
+ expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
1907
+ );
1733
1908
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1734
1909
  return commitTransitionJournal(store, journal, existingEntryId);
1735
1910
  }
@@ -1772,7 +1947,7 @@ var InMemoryCreditRepository = class {
1772
1947
  * simultaneous first deliveries of the same reference would then both credit.
1773
1948
  */
1774
1949
  seedUser(userId, tier, initialBalance) {
1775
- assertRepresentableAmount(initialBalance, "initialBalance", { userId });
1950
+ assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
1776
1951
  const configured = getConfigMonthlyLimit(tier);
1777
1952
  assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
1778
1953
  const monthlyLimit = storedMonthlyLimit(configured);
@@ -1791,6 +1966,8 @@ var InMemoryCreditRepository = class {
1791
1966
  updatedAt: now
1792
1967
  };
1793
1968
  this.store.users.set(userId, credits);
1969
+ const grantLine = signupGrantJournalInput(userId, tier, initialBalance);
1970
+ if (grantLine) this.recordJournalEntry(grantLine);
1794
1971
  return credits;
1795
1972
  }
1796
1973
  async updateUserCredits(userId, updates) {
@@ -1853,9 +2030,11 @@ var InMemoryCreditRepository = class {
1853
2030
  userId,
1854
2031
  operation: "updateUserCredits"
1855
2032
  });
1856
- if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
1857
- writeExpiredHoldBacking(this.store, next, 0);
1858
- }
2033
+ writeExpiredHoldBacking(
2034
+ this.store,
2035
+ next,
2036
+ updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
2037
+ );
1859
2038
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1860
2039
  this.store.users.set(userId, next);
1861
2040
  }
@@ -1869,24 +2048,37 @@ var InMemoryCreditRepository = class {
1869
2048
  if (!credits) {
1870
2049
  throw new Error(`User ${userId} not found`);
1871
2050
  }
1872
- if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1873
- const capped = planBalanceCap(
2051
+ let planned;
2052
+ if (input.balance !== void 0) {
2053
+ planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
2054
+ } else if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
2055
+ planned = planBalanceCap(
1874
2056
  credits.balance,
1875
2057
  input.balanceCap,
1876
2058
  credits.reserved,
1877
2059
  credits.bonusCredits,
1878
2060
  credits.expiredHoldBacking
1879
2061
  );
1880
- credits.balance = capped.balance;
1881
- writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
1882
2062
  }
2063
+ const journalLine = planned ? tierChangeJournalInput({
2064
+ userId,
2065
+ previousTier: credits.tier,
2066
+ newTier: input.tier,
2067
+ previousBalance: credits.balance,
2068
+ newBalance: planned.balance,
2069
+ bonusCredits: credits.bonusCredits
2070
+ }) : null;
1883
2071
  credits.tier = input.tier;
1884
2072
  credits.monthlyLimit = input.monthlyLimit;
1885
- if (input.balance !== void 0) {
1886
- const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
2073
+ if (planned) {
1887
2074
  credits.balance = planned.balance;
1888
2075
  writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1889
2076
  }
2077
+ writeExpiredHoldBacking(
2078
+ this.store,
2079
+ credits,
2080
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
2081
+ );
1890
2082
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1891
2083
  if (input.subscriptionExpiresAt !== void 0) {
1892
2084
  if (input.subscriptionExpiresAt === null) {
@@ -1897,6 +2089,7 @@ var InMemoryCreditRepository = class {
1897
2089
  }
1898
2090
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1899
2091
  this.store.users.set(userId, credits);
2092
+ if (journalLine) this.recordJournalEntry(journalLine);
1900
2093
  }
1901
2094
  // ==================== Reservations ====================
1902
2095
  async createReservation(input) {
@@ -2048,6 +2241,11 @@ var InMemoryCreditRepository = class {
2048
2241
  { userId, operation: "addCredits" }
2049
2242
  );
2050
2243
  credits.bonusCredits = nextBonusCredits;
2244
+ writeExpiredHoldBacking(
2245
+ this.store,
2246
+ credits,
2247
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
2248
+ );
2051
2249
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2052
2250
  this.store.users.set(userId, credits);
2053
2251
  const transaction = this.recordTransaction({
@@ -2067,6 +2265,7 @@ var InMemoryCreditRepository = class {
2067
2265
  userId,
2068
2266
  entryType: "credit",
2069
2267
  amount,
2268
+ balanceBefore: previousTotal,
2070
2269
  balanceAfter: newTotal,
2071
2270
  source: payload.source,
2072
2271
  referenceId: transaction.id ?? paymentRef ?? "unknown",
@@ -2100,7 +2299,7 @@ var InMemoryCreditRepository = class {
2100
2299
  });
2101
2300
  }
2102
2301
  }
2103
- async deductCreditsAtomic(userId, amount) {
2302
+ async deductCreditsAtomic(userId, amount, journal) {
2104
2303
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
2105
2304
  const credits = this.store.users.get(userId);
2106
2305
  if (!credits) {
@@ -2126,11 +2325,26 @@ var InMemoryCreditRepository = class {
2126
2325
  { previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
2127
2326
  { userId, operation: "deductCredits" }
2128
2327
  );
2328
+ const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
2129
2329
  credits.balance = nextBalance;
2130
2330
  credits.bonusCredits = nextBonusCredits;
2331
+ writeExpiredHoldBacking(this.store, credits, held);
2131
2332
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2132
2333
  this.store.users.set(userId, credits);
2133
- return { previousBalance, newBalance };
2334
+ if (!journal) return { previousBalance, newBalance };
2335
+ const entry = this.recordJournalEntry({
2336
+ userId,
2337
+ entryType: "debit",
2338
+ amount,
2339
+ balanceBefore: previousBalance,
2340
+ balanceAfter: newBalance,
2341
+ source: journal.source ?? "operation_commit",
2342
+ referenceId: journal.referenceId,
2343
+ referenceType: journal.referenceType ?? "adjustment",
2344
+ description: journal.description,
2345
+ metadata: journal.metadata
2346
+ });
2347
+ return { previousBalance, newBalance, journalEntryId: entry.id };
2134
2348
  }
2135
2349
  // ==================== Transactions ====================
2136
2350
  async createTransaction(input) {
@@ -2244,6 +2458,11 @@ var InMemoryCreditRepository = class {
2244
2458
  assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
2245
2459
  userId: input.userId
2246
2460
  });
2461
+ if (input.balanceBefore !== void 0) {
2462
+ assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
2463
+ userId: input.userId
2464
+ });
2465
+ }
2247
2466
  assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
2248
2467
  assertPublicJournalKey(input.idempotencyKey, input.userId);
2249
2468
  const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
@@ -2259,6 +2478,7 @@ var InMemoryCreditRepository = class {
2259
2478
  userId: input.userId,
2260
2479
  entryType: input.entryType,
2261
2480
  amount: input.amount,
2481
+ ...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
2262
2482
  balanceAfter: input.balanceAfter,
2263
2483
  source: input.source,
2264
2484
  referenceId: input.referenceId,
@@ -2411,6 +2631,7 @@ var InMemoryCreditRepository = class {
2411
2631
  userId,
2412
2632
  entryType: change > 0 ? "credit" : "debit",
2413
2633
  amount: Math.abs(change),
2634
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2414
2635
  balanceAfter,
2415
2636
  source: "monthly_reset",
2416
2637
  referenceId: `reset-${Date.now()}`,
@@ -2490,7 +2711,8 @@ var InMemoryCreditRepository = class {
2490
2711
  userId,
2491
2712
  entryType: "debit",
2492
2713
  amount: 0,
2493
- // No credits deducted, just tier change
2714
+ // 0, however far the cap lowered the balance: the totals record the move
2715
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2494
2716
  balanceAfter,
2495
2717
  source: "subscription_downgrade",
2496
2718
  referenceId: `downgrade-${Date.now()}`,
@@ -2653,7 +2875,8 @@ var CreditsService = class {
2653
2875
  userId,
2654
2876
  entryType: "debit",
2655
2877
  amount: 0,
2656
- // No credits deducted, just tier change
2878
+ // 0, however far the cap lowered the balance: the totals record the move
2879
+ balanceBefore: sumAmounts(data.balance, data.bonusCredits),
2657
2880
  // The ledger balance, matching every other journal writer: the V2
2658
2881
  // transitions and add/deduct all record `balance + bonusCredits`, so
2659
2882
  // recording `balance` alone made the audit trail disagree with itself
@@ -2697,6 +2920,7 @@ var CreditsService = class {
2697
2920
  userId,
2698
2921
  entryType: balanceChange > 0 ? "credit" : "debit",
2699
2922
  amount: Math.abs(balanceChange),
2923
+ balanceBefore: sumAmounts(data.balance, data.bonusCredits),
2700
2924
  balanceAfter: sumAmounts(
2701
2925
  resetResult.credits.balance,
2702
2926
  resetResult.credits.bonusCredits
@@ -2931,9 +3155,18 @@ var CreditsService = class {
2931
3155
  */
2932
3156
  async deductCredits(userId, amount, options) {
2933
3157
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
3158
+ const operationType = options?.operationType;
3159
+ const journal = {
3160
+ referenceId: options?.requestId ?? `deduct-${Date.now()}`,
3161
+ description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
3162
+ metadata: {
3163
+ ...options?.metadata,
3164
+ ...operationType && { operationType }
3165
+ }
3166
+ };
2934
3167
  let deduction;
2935
3168
  try {
2936
- deduction = await this.repository.deductCreditsAtomic(userId, amount);
3169
+ deduction = await this.repository.deductCreditsAtomic(userId, amount, journal);
2937
3170
  } catch (error) {
2938
3171
  if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
2939
3172
  const credits = await this.repository.getUserCredits(userId);
@@ -2949,21 +3182,18 @@ var CreditsService = class {
2949
3182
  shortfall: sumAmounts(amount, -available)
2950
3183
  };
2951
3184
  }
2952
- const operationType = options?.operationType;
2953
- await this.repository.createJournalEntry({
2954
- userId,
2955
- entryType: "debit",
2956
- amount,
2957
- balanceAfter: deduction.newBalance,
2958
- source: "operation_commit",
2959
- referenceId: options?.requestId ?? `deduct-${Date.now()}`,
2960
- referenceType: "adjustment",
2961
- description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
2962
- metadata: {
2963
- ...options?.metadata,
2964
- ...operationType && { operationType }
2965
- }
2966
- });
3185
+ if (deduction.journalEntryId === void 0) {
3186
+ await this.repository.createJournalEntry({
3187
+ userId,
3188
+ entryType: "debit",
3189
+ amount,
3190
+ balanceBefore: deduction.previousBalance,
3191
+ balanceAfter: deduction.newBalance,
3192
+ source: "operation_commit",
3193
+ referenceType: "adjustment",
3194
+ ...journal
3195
+ });
3196
+ }
2967
3197
  if (operationType) {
2968
3198
  await this.logUsage({
2969
3199
  userId,
@@ -3037,7 +3267,8 @@ var CreditsService = class {
3037
3267
  ]);
3038
3268
  const historyEntries = entries.map((entry) => {
3039
3269
  const type = this.mapSourceToHistoryType(entry.source);
3040
- const creditsChange = entry.entryType === "credit" ? entry.amount : -entry.amount;
3270
+ const signedAmount = entry.entryType === "credit" ? entry.amount : -entry.amount;
3271
+ const creditsChange = entry.balanceBefore !== void 0 ? sumAmounts(entry.balanceAfter, -entry.balanceBefore) : signedAmount;
3041
3272
  return {
3042
3273
  id: entry.id,
3043
3274
  type,
@@ -3973,6 +4204,7 @@ export {
3973
4204
  CreditsSDKError,
3974
4205
  CreditsService,
3975
4206
  InMemoryCreditRepository,
4207
+ NO_EXPIRED_BACKING_PREFIX,
3976
4208
  NetworkError,
3977
4209
  RESERVATION_STATUSES,
3978
4210
  RESERVED_JOURNAL_KEY_PREFIX,
@@ -3985,6 +4217,7 @@ export {
3985
4217
  UNLIMITED_BALANCE_SENTINEL,
3986
4218
  ValidationError,
3987
4219
  addCreditsThroughRepository,
4220
+ allocateExpiredBacking,
3988
4221
  assertDirectStatusWriteAllowed,
3989
4222
  assertHoldPlaced,
3990
4223
  assertKnownReservationStatus,
@@ -4001,9 +4234,9 @@ export {
4001
4234
  assertValidStoredAmount,
4002
4235
  assertValidStoredAmountRaw,
4003
4236
  assertValidTierBalanceInput,
4004
- attributeExpiredBacking,
4005
4237
  backedBalanceFloor,
4006
4238
  calculateAvailableCredits,
4239
+ canonicalAmount,
4007
4240
  cappedBalance,
4008
4241
  checkAndNotifyLowBalance,
4009
4242
  classifyDatabaseError,
@@ -4030,7 +4263,7 @@ export {
4030
4263
  createUnsupportedOperationError,
4031
4264
  createUserNotFoundError,
4032
4265
  describePaymentMismatch,
4033
- expiredHoldBackingPatch,
4266
+ expiredBackingPatch,
4034
4267
  generateId,
4035
4268
  generateRequestId2 as generateRequestId,
4036
4269
  genericDeferred,
@@ -4056,6 +4289,9 @@ export {
4056
4289
  getValidOperationTypes,
4057
4290
  getValidTiers,
4058
4291
  hasPlacedHold,
4292
+ heldExpiredBacking,
4293
+ holdExpiredBacking,
4294
+ holdPlacedAtAfter,
4059
4295
  initializeConfig,
4060
4296
  isCreditError,
4061
4297
  isCreditErrorMessage,
@@ -4081,6 +4317,7 @@ export {
4081
4317
  isWinningOutcome,
4082
4318
  loadConfigFromEnv,
4083
4319
  monthlyResetBalance,
4320
+ multiplyAmount,
4084
4321
  noopDeferred,
4085
4322
  normalizePaymentRef,
4086
4323
  notifySubscriptionExpired,
@@ -4092,6 +4329,7 @@ export {
4092
4329
  planBalanceCap,
4093
4330
  planBalanceTarget,
4094
4331
  planHoldCommit,
4332
+ recordedExpiredBacking,
4095
4333
  registerConfigProvider,
4096
4334
  registerNotificationHandler,
4097
4335
  releaseReservationWithJournal,
@@ -4102,13 +4340,17 @@ export {
4102
4340
  resetConfig,
4103
4341
  sameAmount,
4104
4342
  settleExpiredHoldBacking,
4343
+ signupGrantJournalInput,
4105
4344
  spendableBalance,
4345
+ storedAmountCeil,
4346
+ storedAmountFloor,
4106
4347
  storedMonthlyLimit,
4107
4348
  sumAmounts,
4108
4349
  supportsCreditsV2,
4109
4350
  supportsIdempotentCredit,
4110
4351
  synchronousDeferred,
4111
4352
  terminalStatusOf,
4353
+ tierChangeJournalInput,
4112
4354
  tierSchema,
4113
4355
  toCents,
4114
4356
  toClientUserCredits,