@nehorai/credits 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/adapters/index.cjs +1 -0
  2. package/dist/adapters/index.cjs.map +1 -1
  3. package/dist/adapters/index.d.cts +2 -2
  4. package/dist/adapters/index.d.ts +2 -2
  5. package/dist/adapters/index.js +1 -0
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/core/index.cjs +139 -23
  8. package/dist/core/index.cjs.map +1 -1
  9. package/dist/core/index.d.cts +113 -22
  10. package/dist/core/index.d.ts +113 -22
  11. package/dist/core/index.js +128 -21
  12. package/dist/core/index.js.map +1 -1
  13. package/dist/index.cjs +246 -61
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +4 -4
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +235 -59
  18. package/dist/index.js.map +1 -1
  19. package/dist/repository/index.cjs +183 -41
  20. package/dist/repository/index.cjs.map +1 -1
  21. package/dist/repository/index.d.cts +6 -5
  22. package/dist/repository/index.d.ts +6 -5
  23. package/dist/repository/index.js +183 -41
  24. package/dist/repository/index.js.map +1 -1
  25. package/dist/sdk/index.cjs +1 -0
  26. package/dist/sdk/index.cjs.map +1 -1
  27. package/dist/sdk/index.d.cts +2 -2
  28. package/dist/sdk/index.d.ts +2 -2
  29. package/dist/sdk/index.js +1 -0
  30. package/dist/sdk/index.js.map +1 -1
  31. package/dist/service/index.cjs +25 -16
  32. package/dist/service/index.cjs.map +1 -1
  33. package/dist/service/index.d.cts +2 -2
  34. package/dist/service/index.d.ts +2 -2
  35. package/dist/service/index.js +25 -16
  36. package/dist/service/index.js.map +1 -1
  37. package/dist/{types-MaViK5tD.d.cts → types-C3sxDvNb.d.cts} +29 -6
  38. package/dist/{types-MaViK5tD.d.ts → types-C3sxDvNb.d.ts} +29 -6
  39. package/dist/{types-3c-IAwZI.d.ts → types-CY-oHIMA.d.ts} +19 -3
  40. package/dist/{types-XSjNXO5l.d.cts → types-DOjguhEX.d.cts} +19 -3
  41. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -33,6 +33,7 @@ __export(src_exports, {
33
33
  CreditsSDKError: () => CreditsSDKError,
34
34
  CreditsService: () => CreditsService,
35
35
  InMemoryCreditRepository: () => InMemoryCreditRepository,
36
+ NO_EXPIRED_BACKING_PREFIX: () => NO_EXPIRED_BACKING_PREFIX,
36
37
  NetworkError: () => NetworkError,
37
38
  RESERVATION_STATUSES: () => RESERVATION_STATUSES,
38
39
  RESERVED_JOURNAL_KEY_PREFIX: () => RESERVED_JOURNAL_KEY_PREFIX,
@@ -45,6 +46,7 @@ __export(src_exports, {
45
46
  UNLIMITED_BALANCE_SENTINEL: () => UNLIMITED_BALANCE_SENTINEL,
46
47
  ValidationError: () => ValidationError,
47
48
  addCreditsThroughRepository: () => addCreditsThroughRepository,
49
+ allocateExpiredBacking: () => allocateExpiredBacking,
48
50
  assertDirectStatusWriteAllowed: () => assertDirectStatusWriteAllowed,
49
51
  assertHoldPlaced: () => assertHoldPlaced,
50
52
  assertKnownReservationStatus: () => assertKnownReservationStatus,
@@ -61,9 +63,9 @@ __export(src_exports, {
61
63
  assertValidStoredAmount: () => assertValidStoredAmount,
62
64
  assertValidStoredAmountRaw: () => assertValidStoredAmountRaw,
63
65
  assertValidTierBalanceInput: () => assertValidTierBalanceInput,
64
- attributeExpiredBacking: () => attributeExpiredBacking,
65
66
  backedBalanceFloor: () => backedBalanceFloor,
66
67
  calculateAvailableCredits: () => calculateAvailableCredits,
68
+ canonicalAmount: () => canonicalAmount,
67
69
  cappedBalance: () => cappedBalance,
68
70
  checkAndNotifyLowBalance: () => checkAndNotifyLowBalance,
69
71
  classifyDatabaseError: () => classifyDatabaseError,
@@ -90,7 +92,7 @@ __export(src_exports, {
90
92
  createUnsupportedOperationError: () => createUnsupportedOperationError,
91
93
  createUserNotFoundError: () => createUserNotFoundError,
92
94
  describePaymentMismatch: () => describePaymentMismatch,
93
- expiredHoldBackingPatch: () => expiredHoldBackingPatch,
95
+ expiredBackingPatch: () => expiredBackingPatch,
94
96
  generateId: () => generateId,
95
97
  generateRequestId: () => generateRequestId2,
96
98
  genericDeferred: () => genericDeferred,
@@ -116,6 +118,9 @@ __export(src_exports, {
116
118
  getValidOperationTypes: () => getValidOperationTypes,
117
119
  getValidTiers: () => getValidTiers,
118
120
  hasPlacedHold: () => hasPlacedHold,
121
+ heldExpiredBacking: () => heldExpiredBacking,
122
+ holdExpiredBacking: () => holdExpiredBacking,
123
+ holdPlacedAtAfter: () => holdPlacedAtAfter,
119
124
  initializeConfig: () => initializeConfig,
120
125
  isCreditError: () => isCreditError,
121
126
  isCreditErrorMessage: () => isCreditErrorMessage,
@@ -141,6 +146,7 @@ __export(src_exports, {
141
146
  isWinningOutcome: () => isWinningOutcome,
142
147
  loadConfigFromEnv: () => loadConfigFromEnv,
143
148
  monthlyResetBalance: () => monthlyResetBalance,
149
+ multiplyAmount: () => multiplyAmount,
144
150
  noopDeferred: () => noopDeferred,
145
151
  normalizePaymentRef: () => normalizePaymentRef,
146
152
  notifySubscriptionExpired: () => notifySubscriptionExpired,
@@ -152,6 +158,7 @@ __export(src_exports, {
152
158
  planBalanceCap: () => planBalanceCap,
153
159
  planBalanceTarget: () => planBalanceTarget,
154
160
  planHoldCommit: () => planHoldCommit,
161
+ recordedExpiredBacking: () => recordedExpiredBacking,
155
162
  registerConfigProvider: () => registerConfigProvider,
156
163
  registerNotificationHandler: () => registerNotificationHandler,
157
164
  releaseReservationWithJournal: () => releaseReservationWithJournal,
@@ -163,6 +170,8 @@ __export(src_exports, {
163
170
  sameAmount: () => sameAmount,
164
171
  settleExpiredHoldBacking: () => settleExpiredHoldBacking,
165
172
  spendableBalance: () => spendableBalance,
173
+ storedAmountCeil: () => storedAmountCeil,
174
+ storedAmountFloor: () => storedAmountFloor,
166
175
  storedMonthlyLimit: () => storedMonthlyLimit,
167
176
  sumAmounts: () => sumAmounts,
168
177
  supportsCreditsV2: () => supportsCreditsV2,
@@ -349,6 +358,40 @@ function floatSum(values) {
349
358
  for (const value of values) total += value;
350
359
  return total;
351
360
  }
361
+ function multiplyAmount(unit, quantity) {
362
+ const cents = toCents(unit);
363
+ const product = cents === null ? Number.NaN : cents * quantity;
364
+ if (cents === null || cents < 0 || !Number.isSafeInteger(quantity) || quantity < 0 || !Number.isSafeInteger(product) || product > MAX_CENTS) {
365
+ throw new CreditError(
366
+ `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}`,
367
+ CreditErrorCode.INVALID_AMOUNT,
368
+ { unit, quantity, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE }
369
+ );
370
+ }
371
+ return product / 100;
372
+ }
373
+ var FLOAT_NOISE_FLOOR = 1e-6;
374
+ var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
375
+ function floatNoise(value) {
376
+ return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
377
+ }
378
+ function canonicalAmount(value) {
379
+ if (typeof value !== "number" || !Number.isFinite(value)) return value;
380
+ const cents = Math.round(value * 100);
381
+ if (!Number.isSafeInteger(cents)) return value;
382
+ const onGrid = cents / 100 + 0;
383
+ return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
384
+ }
385
+ function storedAmountFloor(value) {
386
+ const canonical = canonicalAmount(value);
387
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
388
+ return Math.floor(canonical * 100) / 100;
389
+ }
390
+ function storedAmountCeil(value) {
391
+ const canonical = canonicalAmount(value);
392
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
393
+ return Math.ceil(canonical * 100) / 100;
394
+ }
352
395
  function isValidCreditAmount(value) {
353
396
  if (typeof value !== "number") return false;
354
397
  const cents = toCents(value);
@@ -404,14 +447,19 @@ function backedBalanceFloor(reserved, bonusCredits) {
404
447
  function cappedBalance(balance, cap, reserved, bonusCredits) {
405
448
  return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
406
449
  }
407
- function spendableBalance(balance, expiredHoldBacking = 0) {
408
- return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
450
+ function heldExpiredBacking(balance, stored) {
451
+ if (!(stored !== void 0 && stored > 0)) return 0;
452
+ return Math.max(0, Math.min(stored, balance));
453
+ }
454
+ function spendableBalance(balance, expiredHoldBacking) {
455
+ return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
409
456
  }
410
457
  function planBalanceTarget(target, reserved, bonusCredits) {
458
+ assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
411
459
  const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
412
460
  return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
413
461
  }
414
- function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
462
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
415
463
  const spendable = spendableBalance(balance, expiredHoldBacking);
416
464
  const target = Math.min(spendable, cap);
417
465
  const kept = Math.max(
@@ -422,8 +470,8 @@ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
422
470
  return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
423
471
  }
424
472
  function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
425
- const held = Math.min(expiredHoldBacking, balance);
426
- const used = Math.min(held, holdBacking);
473
+ const held = heldExpiredBacking(balance, expiredHoldBacking);
474
+ const used = Math.max(0, Math.min(held, holdBacking));
427
475
  return { used, remaining: sumAmounts(held, -used) };
428
476
  }
429
477
  function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
@@ -438,27 +486,83 @@ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking,
438
486
  expiredHoldBacking: backing.remaining
439
487
  };
440
488
  }
441
- function attributeExpiredBacking(holds, total) {
442
- const placed = holds.filter((hold) => hold.holdPlacedAt).sort(
443
- (a, b) => a.holdPlacedAt === b.holdPlacedAt ? a.id < b.id ? -1 : 1 : a.holdPlacedAt < b.holdPlacedAt ? -1 : 1
444
- );
445
- const shares = /* @__PURE__ */ new Map();
446
- let left = total;
447
- for (const hold of placed) {
448
- const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);
449
- shares.set(hold.id, kept);
450
- left = sumAmounts(left, -kept);
489
+ var NO_EXPIRED_BACKING_PREFIX = {
490
+ holdPlacedAt: "",
491
+ id: "",
492
+ share: 0
493
+ };
494
+ function compareHolds(a, b) {
495
+ if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
496
+ if (a.id === b.id) return 0;
497
+ return a.id < b.id ? -1 : 1;
498
+ }
499
+ function recordedExpiredBacking(hold) {
500
+ const stored = hold.expiredBacking;
501
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
502
+ if (!isValidCreditAmount(hold.amount)) return 0;
503
+ return Math.min(storedAmountCeil(stored), hold.amount);
504
+ }
505
+ function allocateExpiredBacking(holds, total) {
506
+ const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
507
+ const wanted = Math.max(0, total);
508
+ if (wanted === 0) return { total: 0, frontier: null };
509
+ const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
510
+ const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
511
+ let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
512
+ for (const hold of [...recorded].reverse()) {
513
+ if (left >= 0) break;
514
+ const share = shares.get(hold.id);
515
+ const cut = Math.min(share, -left);
516
+ shares.set(hold.id, sumAmounts(share, -cut));
517
+ left = sumAmounts(left, cut);
451
518
  }
519
+ let frontier = NO_EXPIRED_BACKING_PREFIX;
452
520
  for (const hold of placed) {
521
+ if (left <= 0) break;
522
+ if (shares.has(hold.id)) continue;
523
+ const share = Math.min(hold.amount, left);
524
+ frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
525
+ left = sumAmounts(left, -share);
526
+ }
527
+ for (const hold of recorded) {
528
+ if (left <= 0) break;
453
529
  const share = shares.get(hold.id);
454
530
  const added = Math.min(sumAmounts(hold.amount, -share), left);
455
531
  shares.set(hold.id, sumAmounts(share, added));
456
532
  left = sumAmounts(left, -added);
457
533
  }
458
- return shares;
534
+ const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
535
+ return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
459
536
  }
460
- function expiredHoldBackingPatch(next, stored = 0) {
461
- return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
537
+ function holdExpiredBacking(hold, frontier, rewrites) {
538
+ const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
539
+ const recorded = recordedExpiredBacking(
540
+ rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
541
+ );
542
+ if (recorded > 0) return recorded;
543
+ if (!frontier || !hold.holdPlacedAt) return 0;
544
+ const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
545
+ if (order > 0) return 0;
546
+ return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
547
+ }
548
+ function holdPlacedAtAfter(now, frontier) {
549
+ if (!frontier?.holdPlacedAt) return now;
550
+ const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
551
+ return now > earliest ? now : earliest;
552
+ }
553
+ function expiredBackingPatch(next, stored) {
554
+ const total = Math.max(0, next.total);
555
+ const patch = {};
556
+ const storedTotal = stored.expiredHoldBacking;
557
+ if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
558
+ patch.expiredHoldBacking = total;
559
+ }
560
+ if (total <= 0) {
561
+ if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
562
+ } else if (next.frontier) {
563
+ patch.expiredBackingFrontier = next.frontier;
564
+ }
565
+ return patch;
462
566
  }
463
567
  function assertValidBalanceTarget(value, field, context) {
464
568
  assertRepresentableAmount(value, field, context);
@@ -471,7 +575,10 @@ function assertValidBalanceTarget(value, field, context) {
471
575
  }
472
576
  }
473
577
  function assertValidTierBalanceInput(input, context) {
474
- if (input.balanceCap === void 0) return;
578
+ if (input.balanceCap === void 0) {
579
+ if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
580
+ return;
581
+ }
475
582
  if (input.balance !== void 0) {
476
583
  throw new CreditError(
477
584
  "A tier update cannot set both balance and balanceCap",
@@ -1437,7 +1544,7 @@ function validateBalanceUpdate(current, updates) {
1437
1544
  const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
1438
1545
  const errors = [];
1439
1546
  if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
1440
- const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
1547
+ const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
1441
1548
  if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
1442
1549
  errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
1443
1550
  }
@@ -1589,6 +1696,7 @@ function commitTransitionJournal(store, input, existingId) {
1589
1696
  userId: input.userId,
1590
1697
  entryType: input.entryType,
1591
1698
  amount: input.amount,
1699
+ balanceBefore: input.balanceBefore,
1592
1700
  balanceAfter: input.balanceAfter,
1593
1701
  source: input.source,
1594
1702
  referenceId: input.reservationId,
@@ -1638,16 +1746,28 @@ function openHolds(store, userId) {
1638
1746
  (reservation) => reservation.status === "reserved"
1639
1747
  );
1640
1748
  }
1749
+ function expiredBackingInForce(store, credits) {
1750
+ const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1751
+ if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
1752
+ return allocateExpiredBacking(openHolds(store, credits.userId), total);
1753
+ }
1754
+ return { total, frontier: credits.expiredBackingFrontier ?? null };
1755
+ }
1756
+ function writeRecordedShares(store, userId, allocation) {
1757
+ for (const { id, expiredBacking } of allocation.rewrites ?? []) {
1758
+ const hold = getReservation(store, userId, id);
1759
+ if (hold) hold.expiredBacking = expiredBacking;
1760
+ }
1761
+ }
1641
1762
  function writeExpiredHoldBacking(store, credits, next) {
1642
- const stored = credits.expiredHoldBacking;
1643
- if (next <= 0 && !stored) return;
1644
- const holds = openHolds(store, credits.userId);
1645
- const shares = attributeExpiredBacking(holds, next);
1646
- for (const hold of holds) {
1647
- const share = shares.get(hold.id) ?? 0;
1648
- if (share !== (hold.expiredBacking ?? 0)) hold.expiredBacking = share;
1763
+ if (next <= 0) {
1764
+ Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
1765
+ return;
1649
1766
  }
1650
- Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
1767
+ if (next === credits.expiredHoldBacking) return;
1768
+ const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
1769
+ writeRecordedShares(store, credits.userId, allocation);
1770
+ Object.assign(credits, expiredBackingPatch(allocation, credits));
1651
1771
  }
1652
1772
  function getReservation(store, userId, reservationId) {
1653
1773
  return store.reservations.get(userId)?.get(reservationId);
@@ -1696,6 +1816,7 @@ async function reserveCreditsV2(store, input) {
1696
1816
  };
1697
1817
  }
1698
1818
  const now = (/* @__PURE__ */ new Date()).toISOString();
1819
+ const backing = expiredBackingInForce(store, credits);
1699
1820
  const reservation = {
1700
1821
  id: generateId(),
1701
1822
  userId: input.userId,
@@ -1708,7 +1829,7 @@ async function reserveCreditsV2(store, input) {
1708
1829
  // The hold-origin fact. Written here and nowhere else, in the same
1709
1830
  // critical section that raises `reserved` below, so the row and the hold
1710
1831
  // become visible together. `createReservation` cannot set it.
1711
- holdPlacedAt: now
1832
+ holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
1712
1833
  };
1713
1834
  if (!store.reservations.has(input.userId)) {
1714
1835
  store.reservations.set(input.userId, /* @__PURE__ */ new Map());
@@ -1726,6 +1847,8 @@ async function reserveCreditsV2(store, input) {
1726
1847
  );
1727
1848
  }
1728
1849
  credits.reserved = nextReserved;
1850
+ writeRecordedShares(store, input.userId, backing);
1851
+ Object.assign(credits, expiredBackingPatch(backing, credits));
1729
1852
  credits.updatedAt = now;
1730
1853
  return { outcome: "created", reservation: copyRecord(reservation) };
1731
1854
  });
@@ -1746,11 +1869,12 @@ async function commitReservationV2(store, userId, reservationId, options) {
1746
1869
  const amount = reservation.amount;
1747
1870
  await yieldPoint(store);
1748
1871
  assertReservedCoversHold(credits, userId, reservationId, amount);
1872
+ const backing = expiredBackingInForce(store, credits);
1749
1873
  const paid = planHoldCommit(
1750
1874
  credits.balance,
1751
1875
  credits.bonusCredits,
1752
- credits.expiredHoldBacking ?? 0,
1753
- reservation.expiredBacking ?? 0,
1876
+ backing.total,
1877
+ holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
1754
1878
  amount
1755
1879
  );
1756
1880
  if (!paid.covered) {
@@ -1768,6 +1892,7 @@ async function commitReservationV2(store, userId, reservationId, options) {
1768
1892
  operation: "commitReservation",
1769
1893
  entryType: "debit",
1770
1894
  amount,
1895
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1771
1896
  balanceAfter,
1772
1897
  source: "operation_commit",
1773
1898
  reservationId,
@@ -1795,9 +1920,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1795
1920
  credits.bonusCredits = nextBonusCredits;
1796
1921
  credits.reserved = nextReserved;
1797
1922
  credits.monthlyUsed = nextMonthlyUsed;
1923
+ writeRecordedShares(store, userId, backing);
1798
1924
  Object.assign(
1799
1925
  credits,
1800
- expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1926
+ expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
1801
1927
  );
1802
1928
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1803
1929
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
@@ -1878,10 +2004,11 @@ function finishUnspent(store, reservation, transition) {
1878
2004
  reservation.id,
1879
2005
  reservation.amount
1880
2006
  );
2007
+ const inForce = expiredBackingInForce(store, credits);
1881
2008
  const backing = settleExpiredHoldBacking(
1882
2009
  credits.balance,
1883
- credits.expiredHoldBacking ?? 0,
1884
- reservation.expiredBacking ?? 0
2010
+ inForce.total,
2011
+ holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
1885
2012
  );
1886
2013
  const nextBalance = sumAmounts(credits.balance, -backing.used);
1887
2014
  const journal = {
@@ -1889,6 +2016,7 @@ function finishUnspent(store, reservation, transition) {
1889
2016
  operation: `${transition.transition}Reservation`,
1890
2017
  entryType: "credit",
1891
2018
  amount: 0,
2019
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1892
2020
  balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1893
2021
  source: transition.source,
1894
2022
  reservationId: reservation.id,
@@ -1911,7 +2039,11 @@ function finishUnspent(store, reservation, transition) {
1911
2039
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1912
2040
  credits.reserved = nextReserved;
1913
2041
  credits.balance = nextBalance;
1914
- Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
2042
+ writeRecordedShares(store, reservation.userId, inForce);
2043
+ Object.assign(
2044
+ credits,
2045
+ expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
2046
+ );
1915
2047
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1916
2048
  return commitTransitionJournal(store, journal, existingEntryId);
1917
2049
  }
@@ -1954,7 +2086,7 @@ var InMemoryCreditRepository = class {
1954
2086
  * simultaneous first deliveries of the same reference would then both credit.
1955
2087
  */
1956
2088
  seedUser(userId, tier, initialBalance) {
1957
- assertRepresentableAmount(initialBalance, "initialBalance", { userId });
2089
+ assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
1958
2090
  const configured = getConfigMonthlyLimit(tier);
1959
2091
  assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
1960
2092
  const monthlyLimit = storedMonthlyLimit(configured);
@@ -2035,9 +2167,11 @@ var InMemoryCreditRepository = class {
2035
2167
  userId,
2036
2168
  operation: "updateUserCredits"
2037
2169
  });
2038
- if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
2039
- writeExpiredHoldBacking(this.store, next, 0);
2040
- }
2170
+ writeExpiredHoldBacking(
2171
+ this.store,
2172
+ next,
2173
+ updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
2174
+ );
2041
2175
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2042
2176
  this.store.users.set(userId, next);
2043
2177
  }
@@ -2069,6 +2203,11 @@ var InMemoryCreditRepository = class {
2069
2203
  credits.balance = planned.balance;
2070
2204
  writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2071
2205
  }
2206
+ writeExpiredHoldBacking(
2207
+ this.store,
2208
+ credits,
2209
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
2210
+ );
2072
2211
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
2073
2212
  if (input.subscriptionExpiresAt !== void 0) {
2074
2213
  if (input.subscriptionExpiresAt === null) {
@@ -2230,6 +2369,11 @@ var InMemoryCreditRepository = class {
2230
2369
  { userId, operation: "addCredits" }
2231
2370
  );
2232
2371
  credits.bonusCredits = nextBonusCredits;
2372
+ writeExpiredHoldBacking(
2373
+ this.store,
2374
+ credits,
2375
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
2376
+ );
2233
2377
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2234
2378
  this.store.users.set(userId, credits);
2235
2379
  const transaction = this.recordTransaction({
@@ -2249,6 +2393,7 @@ var InMemoryCreditRepository = class {
2249
2393
  userId,
2250
2394
  entryType: "credit",
2251
2395
  amount,
2396
+ balanceBefore: previousTotal,
2252
2397
  balanceAfter: newTotal,
2253
2398
  source: payload.source,
2254
2399
  referenceId: transaction.id ?? paymentRef ?? "unknown",
@@ -2282,7 +2427,7 @@ var InMemoryCreditRepository = class {
2282
2427
  });
2283
2428
  }
2284
2429
  }
2285
- async deductCreditsAtomic(userId, amount) {
2430
+ async deductCreditsAtomic(userId, amount, journal) {
2286
2431
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
2287
2432
  const credits = this.store.users.get(userId);
2288
2433
  if (!credits) {
@@ -2308,11 +2453,26 @@ var InMemoryCreditRepository = class {
2308
2453
  { previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
2309
2454
  { userId, operation: "deductCredits" }
2310
2455
  );
2456
+ const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
2311
2457
  credits.balance = nextBalance;
2312
2458
  credits.bonusCredits = nextBonusCredits;
2459
+ writeExpiredHoldBacking(this.store, credits, held);
2313
2460
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2314
2461
  this.store.users.set(userId, credits);
2315
- return { previousBalance, newBalance };
2462
+ if (!journal) return { previousBalance, newBalance };
2463
+ const entry = this.recordJournalEntry({
2464
+ userId,
2465
+ entryType: "debit",
2466
+ amount,
2467
+ balanceBefore: previousBalance,
2468
+ balanceAfter: newBalance,
2469
+ source: journal.source ?? "operation_commit",
2470
+ referenceId: journal.referenceId,
2471
+ referenceType: journal.referenceType ?? "adjustment",
2472
+ description: journal.description,
2473
+ metadata: journal.metadata
2474
+ });
2475
+ return { previousBalance, newBalance, journalEntryId: entry.id };
2316
2476
  }
2317
2477
  // ==================== Transactions ====================
2318
2478
  async createTransaction(input) {
@@ -2426,6 +2586,11 @@ var InMemoryCreditRepository = class {
2426
2586
  assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
2427
2587
  userId: input.userId
2428
2588
  });
2589
+ if (input.balanceBefore !== void 0) {
2590
+ assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
2591
+ userId: input.userId
2592
+ });
2593
+ }
2429
2594
  assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
2430
2595
  assertPublicJournalKey(input.idempotencyKey, input.userId);
2431
2596
  const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
@@ -2441,6 +2606,7 @@ var InMemoryCreditRepository = class {
2441
2606
  userId: input.userId,
2442
2607
  entryType: input.entryType,
2443
2608
  amount: input.amount,
2609
+ ...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
2444
2610
  balanceAfter: input.balanceAfter,
2445
2611
  source: input.source,
2446
2612
  referenceId: input.referenceId,
@@ -2593,6 +2759,7 @@ var InMemoryCreditRepository = class {
2593
2759
  userId,
2594
2760
  entryType: change > 0 ? "credit" : "debit",
2595
2761
  amount: Math.abs(change),
2762
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2596
2763
  balanceAfter,
2597
2764
  source: "monthly_reset",
2598
2765
  referenceId: `reset-${Date.now()}`,
@@ -2673,6 +2840,7 @@ var InMemoryCreditRepository = class {
2673
2840
  entryType: "debit",
2674
2841
  amount: 0,
2675
2842
  // No credits deducted, just tier change
2843
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2676
2844
  balanceAfter,
2677
2845
  source: "subscription_downgrade",
2678
2846
  referenceId: `downgrade-${Date.now()}`,
@@ -2836,6 +3004,7 @@ var CreditsService = class {
2836
3004
  entryType: "debit",
2837
3005
  amount: 0,
2838
3006
  // No credits deducted, just tier change
3007
+ balanceBefore: sumAmounts(data.balance, data.bonusCredits),
2839
3008
  // The ledger balance, matching every other journal writer: the V2
2840
3009
  // transitions and add/deduct all record `balance + bonusCredits`, so
2841
3010
  // recording `balance` alone made the audit trail disagree with itself
@@ -2879,6 +3048,7 @@ var CreditsService = class {
2879
3048
  userId,
2880
3049
  entryType: balanceChange > 0 ? "credit" : "debit",
2881
3050
  amount: Math.abs(balanceChange),
3051
+ balanceBefore: sumAmounts(data.balance, data.bonusCredits),
2882
3052
  balanceAfter: sumAmounts(
2883
3053
  resetResult.credits.balance,
2884
3054
  resetResult.credits.bonusCredits
@@ -3113,9 +3283,18 @@ var CreditsService = class {
3113
3283
  */
3114
3284
  async deductCredits(userId, amount, options) {
3115
3285
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
3286
+ const operationType = options?.operationType;
3287
+ const journal = {
3288
+ referenceId: options?.requestId ?? `deduct-${Date.now()}`,
3289
+ description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
3290
+ metadata: {
3291
+ ...options?.metadata,
3292
+ ...operationType && { operationType }
3293
+ }
3294
+ };
3116
3295
  let deduction;
3117
3296
  try {
3118
- deduction = await this.repository.deductCreditsAtomic(userId, amount);
3297
+ deduction = await this.repository.deductCreditsAtomic(userId, amount, journal);
3119
3298
  } catch (error) {
3120
3299
  if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
3121
3300
  const credits = await this.repository.getUserCredits(userId);
@@ -3131,21 +3310,18 @@ var CreditsService = class {
3131
3310
  shortfall: sumAmounts(amount, -available)
3132
3311
  };
3133
3312
  }
3134
- const operationType = options?.operationType;
3135
- await this.repository.createJournalEntry({
3136
- userId,
3137
- entryType: "debit",
3138
- amount,
3139
- balanceAfter: deduction.newBalance,
3140
- source: "operation_commit",
3141
- referenceId: options?.requestId ?? `deduct-${Date.now()}`,
3142
- referenceType: "adjustment",
3143
- description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
3144
- metadata: {
3145
- ...options?.metadata,
3146
- ...operationType && { operationType }
3147
- }
3148
- });
3313
+ if (deduction.journalEntryId === void 0) {
3314
+ await this.repository.createJournalEntry({
3315
+ userId,
3316
+ entryType: "debit",
3317
+ amount,
3318
+ balanceBefore: deduction.previousBalance,
3319
+ balanceAfter: deduction.newBalance,
3320
+ source: "operation_commit",
3321
+ referenceType: "adjustment",
3322
+ ...journal
3323
+ });
3324
+ }
3149
3325
  if (operationType) {
3150
3326
  await this.logUsage({
3151
3327
  userId,
@@ -4156,6 +4332,7 @@ function createDummyReservation2(userId, operationType) {
4156
4332
  CreditsSDKError,
4157
4333
  CreditsService,
4158
4334
  InMemoryCreditRepository,
4335
+ NO_EXPIRED_BACKING_PREFIX,
4159
4336
  NetworkError,
4160
4337
  RESERVATION_STATUSES,
4161
4338
  RESERVED_JOURNAL_KEY_PREFIX,
@@ -4168,6 +4345,7 @@ function createDummyReservation2(userId, operationType) {
4168
4345
  UNLIMITED_BALANCE_SENTINEL,
4169
4346
  ValidationError,
4170
4347
  addCreditsThroughRepository,
4348
+ allocateExpiredBacking,
4171
4349
  assertDirectStatusWriteAllowed,
4172
4350
  assertHoldPlaced,
4173
4351
  assertKnownReservationStatus,
@@ -4184,9 +4362,9 @@ function createDummyReservation2(userId, operationType) {
4184
4362
  assertValidStoredAmount,
4185
4363
  assertValidStoredAmountRaw,
4186
4364
  assertValidTierBalanceInput,
4187
- attributeExpiredBacking,
4188
4365
  backedBalanceFloor,
4189
4366
  calculateAvailableCredits,
4367
+ canonicalAmount,
4190
4368
  cappedBalance,
4191
4369
  checkAndNotifyLowBalance,
4192
4370
  classifyDatabaseError,
@@ -4213,7 +4391,7 @@ function createDummyReservation2(userId, operationType) {
4213
4391
  createUnsupportedOperationError,
4214
4392
  createUserNotFoundError,
4215
4393
  describePaymentMismatch,
4216
- expiredHoldBackingPatch,
4394
+ expiredBackingPatch,
4217
4395
  generateId,
4218
4396
  generateRequestId,
4219
4397
  genericDeferred,
@@ -4239,6 +4417,9 @@ function createDummyReservation2(userId, operationType) {
4239
4417
  getValidOperationTypes,
4240
4418
  getValidTiers,
4241
4419
  hasPlacedHold,
4420
+ heldExpiredBacking,
4421
+ holdExpiredBacking,
4422
+ holdPlacedAtAfter,
4242
4423
  initializeConfig,
4243
4424
  isCreditError,
4244
4425
  isCreditErrorMessage,
@@ -4264,6 +4445,7 @@ function createDummyReservation2(userId, operationType) {
4264
4445
  isWinningOutcome,
4265
4446
  loadConfigFromEnv,
4266
4447
  monthlyResetBalance,
4448
+ multiplyAmount,
4267
4449
  noopDeferred,
4268
4450
  normalizePaymentRef,
4269
4451
  notifySubscriptionExpired,
@@ -4275,6 +4457,7 @@ function createDummyReservation2(userId, operationType) {
4275
4457
  planBalanceCap,
4276
4458
  planBalanceTarget,
4277
4459
  planHoldCommit,
4460
+ recordedExpiredBacking,
4278
4461
  registerConfigProvider,
4279
4462
  registerNotificationHandler,
4280
4463
  releaseReservationWithJournal,
@@ -4286,6 +4469,8 @@ function createDummyReservation2(userId, operationType) {
4286
4469
  sameAmount,
4287
4470
  settleExpiredHoldBacking,
4288
4471
  spendableBalance,
4472
+ storedAmountCeil,
4473
+ storedAmountFloor,
4289
4474
  storedMonthlyLimit,
4290
4475
  sumAmounts,
4291
4476
  supportsCreditsV2,