@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.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,
@@ -162,13 +169,17 @@ __export(src_exports, {
162
169
  resetConfig: () => resetConfig,
163
170
  sameAmount: () => sameAmount,
164
171
  settleExpiredHoldBacking: () => settleExpiredHoldBacking,
172
+ signupGrantJournalInput: () => signupGrantJournalInput,
165
173
  spendableBalance: () => spendableBalance,
174
+ storedAmountCeil: () => storedAmountCeil,
175
+ storedAmountFloor: () => storedAmountFloor,
166
176
  storedMonthlyLimit: () => storedMonthlyLimit,
167
177
  sumAmounts: () => sumAmounts,
168
178
  supportsCreditsV2: () => supportsCreditsV2,
169
179
  supportsIdempotentCredit: () => supportsIdempotentCredit,
170
180
  synchronousDeferred: () => synchronousDeferred,
171
181
  terminalStatusOf: () => terminalStatusOf,
182
+ tierChangeJournalInput: () => tierChangeJournalInput,
172
183
  tierSchema: () => tierSchema,
173
184
  toCents: () => toCents,
174
185
  toClientUserCredits: () => toClientUserCredits,
@@ -349,6 +360,40 @@ function floatSum(values) {
349
360
  for (const value of values) total += value;
350
361
  return total;
351
362
  }
363
+ function multiplyAmount(unit, quantity) {
364
+ const cents = toCents(unit);
365
+ const product = cents === null ? Number.NaN : cents * quantity;
366
+ if (cents === null || cents < 0 || !Number.isSafeInteger(quantity) || quantity < 0 || !Number.isSafeInteger(product) || product > MAX_CENTS) {
367
+ throw new CreditError(
368
+ `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}`,
369
+ CreditErrorCode.INVALID_AMOUNT,
370
+ { unit, quantity, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE }
371
+ );
372
+ }
373
+ return product / 100;
374
+ }
375
+ var FLOAT_NOISE_FLOOR = 1e-6;
376
+ var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
377
+ function floatNoise(value) {
378
+ return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
379
+ }
380
+ function canonicalAmount(value) {
381
+ if (typeof value !== "number" || !Number.isFinite(value)) return value;
382
+ const cents = Math.round(value * 100);
383
+ if (!Number.isSafeInteger(cents)) return value;
384
+ const onGrid = cents / 100 + 0;
385
+ return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
386
+ }
387
+ function storedAmountFloor(value) {
388
+ const canonical = canonicalAmount(value);
389
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
390
+ return Math.floor(canonical * 100) / 100;
391
+ }
392
+ function storedAmountCeil(value) {
393
+ const canonical = canonicalAmount(value);
394
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
395
+ return Math.ceil(canonical * 100) / 100;
396
+ }
352
397
  function isValidCreditAmount(value) {
353
398
  if (typeof value !== "number") return false;
354
399
  const cents = toCents(value);
@@ -404,14 +449,19 @@ function backedBalanceFloor(reserved, bonusCredits) {
404
449
  function cappedBalance(balance, cap, reserved, bonusCredits) {
405
450
  return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
406
451
  }
407
- function spendableBalance(balance, expiredHoldBacking = 0) {
408
- return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
452
+ function heldExpiredBacking(balance, stored) {
453
+ if (!(stored !== void 0 && stored > 0)) return 0;
454
+ return Math.max(0, Math.min(stored, balance));
455
+ }
456
+ function spendableBalance(balance, expiredHoldBacking) {
457
+ return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
409
458
  }
410
459
  function planBalanceTarget(target, reserved, bonusCredits) {
460
+ assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
411
461
  const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
412
462
  return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
413
463
  }
414
- function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
464
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
415
465
  const spendable = spendableBalance(balance, expiredHoldBacking);
416
466
  const target = Math.min(spendable, cap);
417
467
  const kept = Math.max(
@@ -422,8 +472,8 @@ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
422
472
  return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
423
473
  }
424
474
  function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
425
- const held = Math.min(expiredHoldBacking, balance);
426
- const used = Math.min(held, holdBacking);
475
+ const held = heldExpiredBacking(balance, expiredHoldBacking);
476
+ const used = Math.max(0, Math.min(held, holdBacking));
427
477
  return { used, remaining: sumAmounts(held, -used) };
428
478
  }
429
479
  function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
@@ -438,27 +488,83 @@ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking,
438
488
  expiredHoldBacking: backing.remaining
439
489
  };
440
490
  }
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);
491
+ var NO_EXPIRED_BACKING_PREFIX = {
492
+ holdPlacedAt: "",
493
+ id: "",
494
+ share: 0
495
+ };
496
+ function compareHolds(a, b) {
497
+ if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
498
+ if (a.id === b.id) return 0;
499
+ return a.id < b.id ? -1 : 1;
500
+ }
501
+ function recordedExpiredBacking(hold) {
502
+ const stored = hold.expiredBacking;
503
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
504
+ if (!isValidCreditAmount(hold.amount)) return 0;
505
+ return Math.min(storedAmountCeil(stored), hold.amount);
506
+ }
507
+ function allocateExpiredBacking(holds, total) {
508
+ const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
509
+ const wanted = Math.max(0, total);
510
+ if (wanted === 0) return { total: 0, frontier: null };
511
+ const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
512
+ const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
513
+ let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
514
+ for (const hold of [...recorded].reverse()) {
515
+ if (left >= 0) break;
516
+ const share = shares.get(hold.id);
517
+ const cut = Math.min(share, -left);
518
+ shares.set(hold.id, sumAmounts(share, -cut));
519
+ left = sumAmounts(left, cut);
451
520
  }
521
+ let frontier = NO_EXPIRED_BACKING_PREFIX;
452
522
  for (const hold of placed) {
523
+ if (left <= 0) break;
524
+ if (shares.has(hold.id)) continue;
525
+ const share = Math.min(hold.amount, left);
526
+ frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
527
+ left = sumAmounts(left, -share);
528
+ }
529
+ for (const hold of recorded) {
530
+ if (left <= 0) break;
453
531
  const share = shares.get(hold.id);
454
532
  const added = Math.min(sumAmounts(hold.amount, -share), left);
455
533
  shares.set(hold.id, sumAmounts(share, added));
456
534
  left = sumAmounts(left, -added);
457
535
  }
458
- return shares;
536
+ const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
537
+ return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
459
538
  }
460
- function expiredHoldBackingPatch(next, stored = 0) {
461
- return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
539
+ function holdExpiredBacking(hold, frontier, rewrites) {
540
+ const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
541
+ const recorded = recordedExpiredBacking(
542
+ rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
543
+ );
544
+ if (recorded > 0) return recorded;
545
+ if (!frontier || !hold.holdPlacedAt) return 0;
546
+ const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
547
+ if (order > 0) return 0;
548
+ return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
549
+ }
550
+ function holdPlacedAtAfter(now, frontier) {
551
+ if (!frontier?.holdPlacedAt) return now;
552
+ const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
553
+ return now > earliest ? now : earliest;
554
+ }
555
+ function expiredBackingPatch(next, stored) {
556
+ const total = Math.max(0, next.total);
557
+ const patch = {};
558
+ const storedTotal = stored.expiredHoldBacking;
559
+ if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
560
+ patch.expiredHoldBacking = total;
561
+ }
562
+ if (total <= 0) {
563
+ if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
564
+ } else if (next.frontier) {
565
+ patch.expiredBackingFrontier = next.frontier;
566
+ }
567
+ return patch;
462
568
  }
463
569
  function assertValidBalanceTarget(value, field, context) {
464
570
  assertRepresentableAmount(value, field, context);
@@ -471,7 +577,10 @@ function assertValidBalanceTarget(value, field, context) {
471
577
  }
472
578
  }
473
579
  function assertValidTierBalanceInput(input, context) {
474
- if (input.balanceCap === void 0) return;
580
+ if (input.balanceCap === void 0) {
581
+ if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
582
+ return;
583
+ }
475
584
  if (input.balance !== void 0) {
476
585
  throw new CreditError(
477
586
  "A tier update cannot set both balance and balanceCap",
@@ -997,17 +1106,25 @@ var DEFAULT_CONFIG = {
997
1106
  }
998
1107
  };
999
1108
  var currentConfig = DEFAULT_CONFIG;
1109
+ function isOperationCostMap(value) {
1110
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1111
+ return Object.values(value).every((cost) => isRepresentableAmount(cost) && cost >= 0);
1112
+ }
1000
1113
  function loadConfigFromEnv() {
1001
1114
  const envConfig = {};
1002
1115
  const operationCostsEnv = process.env.CREDITS_OPERATION_COSTS;
1003
1116
  if (operationCostsEnv) {
1004
1117
  try {
1005
1118
  const parsed = JSON.parse(operationCostsEnv);
1006
- if (typeof parsed === "object" && parsed !== null) {
1119
+ if (isOperationCostMap(parsed)) {
1007
1120
  envConfig.operationCosts = {
1008
1121
  ...DEFAULT_CONFIG.operationCosts,
1009
1122
  ...parsed
1010
1123
  };
1124
+ } else {
1125
+ console.warn(
1126
+ "[Credits Config] Ignoring CREDITS_OPERATION_COSTS: it must be a JSON object whose costs are numbers >= 0, exact to the cent"
1127
+ );
1011
1128
  }
1012
1129
  } catch {
1013
1130
  console.warn("[Credits Config] Failed to parse CREDITS_OPERATION_COSTS env var as JSON");
@@ -1437,7 +1554,7 @@ function validateBalanceUpdate(current, updates) {
1437
1554
  const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
1438
1555
  const errors = [];
1439
1556
  if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
1440
- const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
1557
+ const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
1441
1558
  if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
1442
1559
  errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
1443
1560
  }
@@ -1455,6 +1572,50 @@ function assertValidBalanceUpdate(current, updates) {
1455
1572
  throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
1456
1573
  }
1457
1574
  }
1575
+ function signupGrantJournalInput(userId, tier, grant) {
1576
+ if (grant <= 0) return null;
1577
+ return {
1578
+ userId,
1579
+ entryType: "credit",
1580
+ amount: grant,
1581
+ balanceBefore: 0,
1582
+ balanceAfter: grant,
1583
+ source: "bonus",
1584
+ referenceId: `signup-${userId}`,
1585
+ referenceType: "adjustment",
1586
+ description: `Signup grant for ${tier} tier.`,
1587
+ metadata: { tier }
1588
+ };
1589
+ }
1590
+ function tierChangeJournalInput(input) {
1591
+ const { userId, previousTier, newTier, previousBalance, newBalance, bonusCredits } = input;
1592
+ const change = sumAmounts(newBalance, -previousBalance);
1593
+ if (change === 0) return null;
1594
+ const raised = change > 0;
1595
+ const amount = raised ? newBalance : 0;
1596
+ const balanceBefore = sumAmounts(previousBalance, bonusCredits);
1597
+ const balanceAfter = sumAmounts(newBalance, bonusCredits);
1598
+ assertRepresentableFields(
1599
+ {
1600
+ "journal amount": amount,
1601
+ "journal balanceBefore": balanceBefore,
1602
+ "journal balanceAfter": balanceAfter
1603
+ },
1604
+ { userId, operation: "updateUserTier" }
1605
+ );
1606
+ return {
1607
+ userId,
1608
+ entryType: raised ? "credit" : "debit",
1609
+ amount,
1610
+ balanceBefore,
1611
+ balanceAfter,
1612
+ source: raised ? "subscription_upgrade" : "subscription_downgrade",
1613
+ referenceId: `tier-${Date.now()}`,
1614
+ referenceType: "subscription",
1615
+ description: `Tier changed from ${previousTier} to ${newTier}.`,
1616
+ metadata: { previousTier, newTier, previousBalance, newBalance }
1617
+ };
1618
+ }
1458
1619
 
1459
1620
  // src/repository/memory/snapshot.ts
1460
1621
  function copyRecord(record) {
@@ -1589,6 +1750,7 @@ function commitTransitionJournal(store, input, existingId) {
1589
1750
  userId: input.userId,
1590
1751
  entryType: input.entryType,
1591
1752
  amount: input.amount,
1753
+ balanceBefore: input.balanceBefore,
1592
1754
  balanceAfter: input.balanceAfter,
1593
1755
  source: input.source,
1594
1756
  referenceId: input.reservationId,
@@ -1638,16 +1800,28 @@ function openHolds(store, userId) {
1638
1800
  (reservation) => reservation.status === "reserved"
1639
1801
  );
1640
1802
  }
1803
+ function expiredBackingInForce(store, credits) {
1804
+ const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1805
+ if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
1806
+ return allocateExpiredBacking(openHolds(store, credits.userId), total);
1807
+ }
1808
+ return { total, frontier: credits.expiredBackingFrontier ?? null };
1809
+ }
1810
+ function writeRecordedShares(store, userId, allocation) {
1811
+ for (const { id, expiredBacking } of allocation.rewrites ?? []) {
1812
+ const hold = getReservation(store, userId, id);
1813
+ if (hold) hold.expiredBacking = expiredBacking;
1814
+ }
1815
+ }
1641
1816
  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;
1817
+ if (next <= 0) {
1818
+ Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
1819
+ return;
1649
1820
  }
1650
- Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
1821
+ if (next === credits.expiredHoldBacking) return;
1822
+ const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
1823
+ writeRecordedShares(store, credits.userId, allocation);
1824
+ Object.assign(credits, expiredBackingPatch(allocation, credits));
1651
1825
  }
1652
1826
  function getReservation(store, userId, reservationId) {
1653
1827
  return store.reservations.get(userId)?.get(reservationId);
@@ -1696,6 +1870,7 @@ async function reserveCreditsV2(store, input) {
1696
1870
  };
1697
1871
  }
1698
1872
  const now = (/* @__PURE__ */ new Date()).toISOString();
1873
+ const backing = expiredBackingInForce(store, credits);
1699
1874
  const reservation = {
1700
1875
  id: generateId(),
1701
1876
  userId: input.userId,
@@ -1708,7 +1883,7 @@ async function reserveCreditsV2(store, input) {
1708
1883
  // The hold-origin fact. Written here and nowhere else, in the same
1709
1884
  // critical section that raises `reserved` below, so the row and the hold
1710
1885
  // become visible together. `createReservation` cannot set it.
1711
- holdPlacedAt: now
1886
+ holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
1712
1887
  };
1713
1888
  if (!store.reservations.has(input.userId)) {
1714
1889
  store.reservations.set(input.userId, /* @__PURE__ */ new Map());
@@ -1726,6 +1901,8 @@ async function reserveCreditsV2(store, input) {
1726
1901
  );
1727
1902
  }
1728
1903
  credits.reserved = nextReserved;
1904
+ writeRecordedShares(store, input.userId, backing);
1905
+ Object.assign(credits, expiredBackingPatch(backing, credits));
1729
1906
  credits.updatedAt = now;
1730
1907
  return { outcome: "created", reservation: copyRecord(reservation) };
1731
1908
  });
@@ -1746,11 +1923,12 @@ async function commitReservationV2(store, userId, reservationId, options) {
1746
1923
  const amount = reservation.amount;
1747
1924
  await yieldPoint(store);
1748
1925
  assertReservedCoversHold(credits, userId, reservationId, amount);
1926
+ const backing = expiredBackingInForce(store, credits);
1749
1927
  const paid = planHoldCommit(
1750
1928
  credits.balance,
1751
1929
  credits.bonusCredits,
1752
- credits.expiredHoldBacking ?? 0,
1753
- reservation.expiredBacking ?? 0,
1930
+ backing.total,
1931
+ holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
1754
1932
  amount
1755
1933
  );
1756
1934
  if (!paid.covered) {
@@ -1768,6 +1946,7 @@ async function commitReservationV2(store, userId, reservationId, options) {
1768
1946
  operation: "commitReservation",
1769
1947
  entryType: "debit",
1770
1948
  amount,
1949
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1771
1950
  balanceAfter,
1772
1951
  source: "operation_commit",
1773
1952
  reservationId,
@@ -1795,9 +1974,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
1795
1974
  credits.bonusCredits = nextBonusCredits;
1796
1975
  credits.reserved = nextReserved;
1797
1976
  credits.monthlyUsed = nextMonthlyUsed;
1977
+ writeRecordedShares(store, userId, backing);
1798
1978
  Object.assign(
1799
1979
  credits,
1800
- expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
1980
+ expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
1801
1981
  );
1802
1982
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1803
1983
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
@@ -1878,10 +2058,11 @@ function finishUnspent(store, reservation, transition) {
1878
2058
  reservation.id,
1879
2059
  reservation.amount
1880
2060
  );
2061
+ const inForce = expiredBackingInForce(store, credits);
1881
2062
  const backing = settleExpiredHoldBacking(
1882
2063
  credits.balance,
1883
- credits.expiredHoldBacking ?? 0,
1884
- reservation.expiredBacking ?? 0
2064
+ inForce.total,
2065
+ holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
1885
2066
  );
1886
2067
  const nextBalance = sumAmounts(credits.balance, -backing.used);
1887
2068
  const journal = {
@@ -1889,6 +2070,7 @@ function finishUnspent(store, reservation, transition) {
1889
2070
  operation: `${transition.transition}Reservation`,
1890
2071
  entryType: "credit",
1891
2072
  amount: 0,
2073
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1892
2074
  balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1893
2075
  source: transition.source,
1894
2076
  reservationId: reservation.id,
@@ -1911,7 +2093,11 @@ function finishUnspent(store, reservation, transition) {
1911
2093
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1912
2094
  credits.reserved = nextReserved;
1913
2095
  credits.balance = nextBalance;
1914
- Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
2096
+ writeRecordedShares(store, reservation.userId, inForce);
2097
+ Object.assign(
2098
+ credits,
2099
+ expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
2100
+ );
1915
2101
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1916
2102
  return commitTransitionJournal(store, journal, existingEntryId);
1917
2103
  }
@@ -1954,7 +2140,7 @@ var InMemoryCreditRepository = class {
1954
2140
  * simultaneous first deliveries of the same reference would then both credit.
1955
2141
  */
1956
2142
  seedUser(userId, tier, initialBalance) {
1957
- assertRepresentableAmount(initialBalance, "initialBalance", { userId });
2143
+ assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
1958
2144
  const configured = getConfigMonthlyLimit(tier);
1959
2145
  assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
1960
2146
  const monthlyLimit = storedMonthlyLimit(configured);
@@ -1973,6 +2159,8 @@ var InMemoryCreditRepository = class {
1973
2159
  updatedAt: now
1974
2160
  };
1975
2161
  this.store.users.set(userId, credits);
2162
+ const grantLine = signupGrantJournalInput(userId, tier, initialBalance);
2163
+ if (grantLine) this.recordJournalEntry(grantLine);
1976
2164
  return credits;
1977
2165
  }
1978
2166
  async updateUserCredits(userId, updates) {
@@ -2035,9 +2223,11 @@ var InMemoryCreditRepository = class {
2035
2223
  userId,
2036
2224
  operation: "updateUserCredits"
2037
2225
  });
2038
- if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
2039
- writeExpiredHoldBacking(this.store, next, 0);
2040
- }
2226
+ writeExpiredHoldBacking(
2227
+ this.store,
2228
+ next,
2229
+ updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
2230
+ );
2041
2231
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2042
2232
  this.store.users.set(userId, next);
2043
2233
  }
@@ -2051,24 +2241,37 @@ var InMemoryCreditRepository = class {
2051
2241
  if (!credits) {
2052
2242
  throw new Error(`User ${userId} not found`);
2053
2243
  }
2054
- if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
2055
- const capped = planBalanceCap(
2244
+ let planned;
2245
+ if (input.balance !== void 0) {
2246
+ planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
2247
+ } else if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
2248
+ planned = planBalanceCap(
2056
2249
  credits.balance,
2057
2250
  input.balanceCap,
2058
2251
  credits.reserved,
2059
2252
  credits.bonusCredits,
2060
2253
  credits.expiredHoldBacking
2061
2254
  );
2062
- credits.balance = capped.balance;
2063
- writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
2064
2255
  }
2256
+ const journalLine = planned ? tierChangeJournalInput({
2257
+ userId,
2258
+ previousTier: credits.tier,
2259
+ newTier: input.tier,
2260
+ previousBalance: credits.balance,
2261
+ newBalance: planned.balance,
2262
+ bonusCredits: credits.bonusCredits
2263
+ }) : null;
2065
2264
  credits.tier = input.tier;
2066
2265
  credits.monthlyLimit = input.monthlyLimit;
2067
- if (input.balance !== void 0) {
2068
- const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
2266
+ if (planned) {
2069
2267
  credits.balance = planned.balance;
2070
2268
  writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2071
2269
  }
2270
+ writeExpiredHoldBacking(
2271
+ this.store,
2272
+ credits,
2273
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
2274
+ );
2072
2275
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
2073
2276
  if (input.subscriptionExpiresAt !== void 0) {
2074
2277
  if (input.subscriptionExpiresAt === null) {
@@ -2079,6 +2282,7 @@ var InMemoryCreditRepository = class {
2079
2282
  }
2080
2283
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2081
2284
  this.store.users.set(userId, credits);
2285
+ if (journalLine) this.recordJournalEntry(journalLine);
2082
2286
  }
2083
2287
  // ==================== Reservations ====================
2084
2288
  async createReservation(input) {
@@ -2230,6 +2434,11 @@ var InMemoryCreditRepository = class {
2230
2434
  { userId, operation: "addCredits" }
2231
2435
  );
2232
2436
  credits.bonusCredits = nextBonusCredits;
2437
+ writeExpiredHoldBacking(
2438
+ this.store,
2439
+ credits,
2440
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
2441
+ );
2233
2442
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2234
2443
  this.store.users.set(userId, credits);
2235
2444
  const transaction = this.recordTransaction({
@@ -2249,6 +2458,7 @@ var InMemoryCreditRepository = class {
2249
2458
  userId,
2250
2459
  entryType: "credit",
2251
2460
  amount,
2461
+ balanceBefore: previousTotal,
2252
2462
  balanceAfter: newTotal,
2253
2463
  source: payload.source,
2254
2464
  referenceId: transaction.id ?? paymentRef ?? "unknown",
@@ -2282,7 +2492,7 @@ var InMemoryCreditRepository = class {
2282
2492
  });
2283
2493
  }
2284
2494
  }
2285
- async deductCreditsAtomic(userId, amount) {
2495
+ async deductCreditsAtomic(userId, amount, journal) {
2286
2496
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
2287
2497
  const credits = this.store.users.get(userId);
2288
2498
  if (!credits) {
@@ -2308,11 +2518,26 @@ var InMemoryCreditRepository = class {
2308
2518
  { previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
2309
2519
  { userId, operation: "deductCredits" }
2310
2520
  );
2521
+ const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
2311
2522
  credits.balance = nextBalance;
2312
2523
  credits.bonusCredits = nextBonusCredits;
2524
+ writeExpiredHoldBacking(this.store, credits, held);
2313
2525
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2314
2526
  this.store.users.set(userId, credits);
2315
- return { previousBalance, newBalance };
2527
+ if (!journal) return { previousBalance, newBalance };
2528
+ const entry = this.recordJournalEntry({
2529
+ userId,
2530
+ entryType: "debit",
2531
+ amount,
2532
+ balanceBefore: previousBalance,
2533
+ balanceAfter: newBalance,
2534
+ source: journal.source ?? "operation_commit",
2535
+ referenceId: journal.referenceId,
2536
+ referenceType: journal.referenceType ?? "adjustment",
2537
+ description: journal.description,
2538
+ metadata: journal.metadata
2539
+ });
2540
+ return { previousBalance, newBalance, journalEntryId: entry.id };
2316
2541
  }
2317
2542
  // ==================== Transactions ====================
2318
2543
  async createTransaction(input) {
@@ -2426,6 +2651,11 @@ var InMemoryCreditRepository = class {
2426
2651
  assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
2427
2652
  userId: input.userId
2428
2653
  });
2654
+ if (input.balanceBefore !== void 0) {
2655
+ assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
2656
+ userId: input.userId
2657
+ });
2658
+ }
2429
2659
  assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
2430
2660
  assertPublicJournalKey(input.idempotencyKey, input.userId);
2431
2661
  const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
@@ -2441,6 +2671,7 @@ var InMemoryCreditRepository = class {
2441
2671
  userId: input.userId,
2442
2672
  entryType: input.entryType,
2443
2673
  amount: input.amount,
2674
+ ...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
2444
2675
  balanceAfter: input.balanceAfter,
2445
2676
  source: input.source,
2446
2677
  referenceId: input.referenceId,
@@ -2593,6 +2824,7 @@ var InMemoryCreditRepository = class {
2593
2824
  userId,
2594
2825
  entryType: change > 0 ? "credit" : "debit",
2595
2826
  amount: Math.abs(change),
2827
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2596
2828
  balanceAfter,
2597
2829
  source: "monthly_reset",
2598
2830
  referenceId: `reset-${Date.now()}`,
@@ -2672,7 +2904,8 @@ var InMemoryCreditRepository = class {
2672
2904
  userId,
2673
2905
  entryType: "debit",
2674
2906
  amount: 0,
2675
- // No credits deducted, just tier change
2907
+ // 0, however far the cap lowered the balance: the totals record the move
2908
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2676
2909
  balanceAfter,
2677
2910
  source: "subscription_downgrade",
2678
2911
  referenceId: `downgrade-${Date.now()}`,
@@ -2835,7 +3068,8 @@ var CreditsService = class {
2835
3068
  userId,
2836
3069
  entryType: "debit",
2837
3070
  amount: 0,
2838
- // No credits deducted, just tier change
3071
+ // 0, however far the cap lowered the balance: the totals record the move
3072
+ balanceBefore: sumAmounts(data.balance, data.bonusCredits),
2839
3073
  // The ledger balance, matching every other journal writer: the V2
2840
3074
  // transitions and add/deduct all record `balance + bonusCredits`, so
2841
3075
  // recording `balance` alone made the audit trail disagree with itself
@@ -2879,6 +3113,7 @@ var CreditsService = class {
2879
3113
  userId,
2880
3114
  entryType: balanceChange > 0 ? "credit" : "debit",
2881
3115
  amount: Math.abs(balanceChange),
3116
+ balanceBefore: sumAmounts(data.balance, data.bonusCredits),
2882
3117
  balanceAfter: sumAmounts(
2883
3118
  resetResult.credits.balance,
2884
3119
  resetResult.credits.bonusCredits
@@ -3113,9 +3348,18 @@ var CreditsService = class {
3113
3348
  */
3114
3349
  async deductCredits(userId, amount, options) {
3115
3350
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
3351
+ const operationType = options?.operationType;
3352
+ const journal = {
3353
+ referenceId: options?.requestId ?? `deduct-${Date.now()}`,
3354
+ description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
3355
+ metadata: {
3356
+ ...options?.metadata,
3357
+ ...operationType && { operationType }
3358
+ }
3359
+ };
3116
3360
  let deduction;
3117
3361
  try {
3118
- deduction = await this.repository.deductCreditsAtomic(userId, amount);
3362
+ deduction = await this.repository.deductCreditsAtomic(userId, amount, journal);
3119
3363
  } catch (error) {
3120
3364
  if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
3121
3365
  const credits = await this.repository.getUserCredits(userId);
@@ -3131,21 +3375,18 @@ var CreditsService = class {
3131
3375
  shortfall: sumAmounts(amount, -available)
3132
3376
  };
3133
3377
  }
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
- });
3378
+ if (deduction.journalEntryId === void 0) {
3379
+ await this.repository.createJournalEntry({
3380
+ userId,
3381
+ entryType: "debit",
3382
+ amount,
3383
+ balanceBefore: deduction.previousBalance,
3384
+ balanceAfter: deduction.newBalance,
3385
+ source: "operation_commit",
3386
+ referenceType: "adjustment",
3387
+ ...journal
3388
+ });
3389
+ }
3149
3390
  if (operationType) {
3150
3391
  await this.logUsage({
3151
3392
  userId,
@@ -3219,7 +3460,8 @@ var CreditsService = class {
3219
3460
  ]);
3220
3461
  const historyEntries = entries.map((entry) => {
3221
3462
  const type = this.mapSourceToHistoryType(entry.source);
3222
- const creditsChange = entry.entryType === "credit" ? entry.amount : -entry.amount;
3463
+ const signedAmount = entry.entryType === "credit" ? entry.amount : -entry.amount;
3464
+ const creditsChange = entry.balanceBefore !== void 0 ? sumAmounts(entry.balanceAfter, -entry.balanceBefore) : signedAmount;
3223
3465
  return {
3224
3466
  id: entry.id,
3225
3467
  type,
@@ -4156,6 +4398,7 @@ function createDummyReservation2(userId, operationType) {
4156
4398
  CreditsSDKError,
4157
4399
  CreditsService,
4158
4400
  InMemoryCreditRepository,
4401
+ NO_EXPIRED_BACKING_PREFIX,
4159
4402
  NetworkError,
4160
4403
  RESERVATION_STATUSES,
4161
4404
  RESERVED_JOURNAL_KEY_PREFIX,
@@ -4168,6 +4411,7 @@ function createDummyReservation2(userId, operationType) {
4168
4411
  UNLIMITED_BALANCE_SENTINEL,
4169
4412
  ValidationError,
4170
4413
  addCreditsThroughRepository,
4414
+ allocateExpiredBacking,
4171
4415
  assertDirectStatusWriteAllowed,
4172
4416
  assertHoldPlaced,
4173
4417
  assertKnownReservationStatus,
@@ -4184,9 +4428,9 @@ function createDummyReservation2(userId, operationType) {
4184
4428
  assertValidStoredAmount,
4185
4429
  assertValidStoredAmountRaw,
4186
4430
  assertValidTierBalanceInput,
4187
- attributeExpiredBacking,
4188
4431
  backedBalanceFloor,
4189
4432
  calculateAvailableCredits,
4433
+ canonicalAmount,
4190
4434
  cappedBalance,
4191
4435
  checkAndNotifyLowBalance,
4192
4436
  classifyDatabaseError,
@@ -4213,7 +4457,7 @@ function createDummyReservation2(userId, operationType) {
4213
4457
  createUnsupportedOperationError,
4214
4458
  createUserNotFoundError,
4215
4459
  describePaymentMismatch,
4216
- expiredHoldBackingPatch,
4460
+ expiredBackingPatch,
4217
4461
  generateId,
4218
4462
  generateRequestId,
4219
4463
  genericDeferred,
@@ -4239,6 +4483,9 @@ function createDummyReservation2(userId, operationType) {
4239
4483
  getValidOperationTypes,
4240
4484
  getValidTiers,
4241
4485
  hasPlacedHold,
4486
+ heldExpiredBacking,
4487
+ holdExpiredBacking,
4488
+ holdPlacedAtAfter,
4242
4489
  initializeConfig,
4243
4490
  isCreditError,
4244
4491
  isCreditErrorMessage,
@@ -4264,6 +4511,7 @@ function createDummyReservation2(userId, operationType) {
4264
4511
  isWinningOutcome,
4265
4512
  loadConfigFromEnv,
4266
4513
  monthlyResetBalance,
4514
+ multiplyAmount,
4267
4515
  noopDeferred,
4268
4516
  normalizePaymentRef,
4269
4517
  notifySubscriptionExpired,
@@ -4275,6 +4523,7 @@ function createDummyReservation2(userId, operationType) {
4275
4523
  planBalanceCap,
4276
4524
  planBalanceTarget,
4277
4525
  planHoldCommit,
4526
+ recordedExpiredBacking,
4278
4527
  registerConfigProvider,
4279
4528
  registerNotificationHandler,
4280
4529
  releaseReservationWithJournal,
@@ -4285,13 +4534,17 @@ function createDummyReservation2(userId, operationType) {
4285
4534
  resetConfig,
4286
4535
  sameAmount,
4287
4536
  settleExpiredHoldBacking,
4537
+ signupGrantJournalInput,
4288
4538
  spendableBalance,
4539
+ storedAmountCeil,
4540
+ storedAmountFloor,
4289
4541
  storedMonthlyLimit,
4290
4542
  sumAmounts,
4291
4543
  supportsCreditsV2,
4292
4544
  supportsIdempotentCredit,
4293
4545
  synchronousDeferred,
4294
4546
  terminalStatusOf,
4547
+ tierChangeJournalInput,
4295
4548
  tierSchema,
4296
4549
  toCents,
4297
4550
  toClientUserCredits,