@nehorai/credits 2.1.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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/index.cjs +3 -2
  3. package/dist/adapters/index.cjs.map +1 -1
  4. package/dist/adapters/index.d.cts +2 -2
  5. package/dist/adapters/index.d.ts +2 -2
  6. package/dist/adapters/index.js +3 -2
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/core/index.cjs +218 -2
  9. package/dist/core/index.cjs.map +1 -1
  10. package/dist/core/index.d.cts +183 -5
  11. package/dist/core/index.d.ts +183 -5
  12. package/dist/core/index.js +199 -2
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/index.cjs +477 -59
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +6 -6
  17. package/dist/index.d.ts +6 -6
  18. package/dist/index.js +456 -59
  19. package/dist/index.js.map +1 -1
  20. package/dist/repository/index.cjs +394 -44
  21. package/dist/repository/index.cjs.map +1 -1
  22. package/dist/repository/index.d.cts +36 -7
  23. package/dist/repository/index.d.ts +36 -7
  24. package/dist/repository/index.js +391 -43
  25. package/dist/repository/index.js.map +1 -1
  26. package/dist/sdk/index.cjs +1 -0
  27. package/dist/sdk/index.cjs.map +1 -1
  28. package/dist/sdk/index.d.cts +2 -2
  29. package/dist/sdk/index.d.ts +2 -2
  30. package/dist/sdk/index.js +1 -0
  31. package/dist/sdk/index.js.map +1 -1
  32. package/dist/service/index.cjs +33 -20
  33. package/dist/service/index.cjs.map +1 -1
  34. package/dist/service/index.d.cts +2 -2
  35. package/dist/service/index.d.ts +2 -2
  36. package/dist/service/index.js +33 -20
  37. package/dist/service/index.js.map +1 -1
  38. package/dist/{types-Dzc443vy.d.cts → types-C3sxDvNb.d.cts} +43 -2
  39. package/dist/{types-Dzc443vy.d.ts → types-C3sxDvNb.d.ts} +43 -2
  40. package/dist/{types-BhCdep_t.d.ts → types-CY-oHIMA.d.ts} +55 -4
  41. package/dist/{types-wtksMa4n.d.cts → types-DOjguhEX.d.cts} +55 -4
  42. package/package.json +70 -71
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,
@@ -54,12 +56,17 @@ __export(src_exports, {
54
56
  assertRepresentableTierAmount: () => assertRepresentableTierAmount,
55
57
  assertUnkeyedDirectReservation: () => assertUnkeyedDirectReservation,
56
58
  assertUnreferencedDirectTransaction: () => assertUnreferencedDirectTransaction,
59
+ assertValidBalanceTarget: () => assertValidBalanceTarget,
60
+ assertValidBalanceUpdate: () => assertValidBalanceUpdate,
57
61
  assertValidCreditAmount: () => assertValidCreditAmount,
58
62
  assertValidIdempotencyKey: () => assertValidIdempotencyKey,
59
63
  assertValidStoredAmount: () => assertValidStoredAmount,
60
64
  assertValidStoredAmountRaw: () => assertValidStoredAmountRaw,
65
+ assertValidTierBalanceInput: () => assertValidTierBalanceInput,
61
66
  backedBalanceFloor: () => backedBalanceFloor,
62
67
  calculateAvailableCredits: () => calculateAvailableCredits,
68
+ canonicalAmount: () => canonicalAmount,
69
+ cappedBalance: () => cappedBalance,
63
70
  checkAndNotifyLowBalance: () => checkAndNotifyLowBalance,
64
71
  classifyDatabaseError: () => classifyDatabaseError,
65
72
  clearConfigProvider: () => clearConfigProvider,
@@ -85,6 +92,7 @@ __export(src_exports, {
85
92
  createUnsupportedOperationError: () => createUnsupportedOperationError,
86
93
  createUserNotFoundError: () => createUserNotFoundError,
87
94
  describePaymentMismatch: () => describePaymentMismatch,
95
+ expiredBackingPatch: () => expiredBackingPatch,
88
96
  generateId: () => generateId,
89
97
  generateRequestId: () => generateRequestId2,
90
98
  genericDeferred: () => genericDeferred,
@@ -110,6 +118,9 @@ __export(src_exports, {
110
118
  getValidOperationTypes: () => getValidOperationTypes,
111
119
  getValidTiers: () => getValidTiers,
112
120
  hasPlacedHold: () => hasPlacedHold,
121
+ heldExpiredBacking: () => heldExpiredBacking,
122
+ holdExpiredBacking: () => holdExpiredBacking,
123
+ holdPlacedAtAfter: () => holdPlacedAtAfter,
113
124
  initializeConfig: () => initializeConfig,
114
125
  isCreditError: () => isCreditError,
115
126
  isCreditErrorMessage: () => isCreditErrorMessage,
@@ -135,6 +146,7 @@ __export(src_exports, {
135
146
  isWinningOutcome: () => isWinningOutcome,
136
147
  loadConfigFromEnv: () => loadConfigFromEnv,
137
148
  monthlyResetBalance: () => monthlyResetBalance,
149
+ multiplyAmount: () => multiplyAmount,
138
150
  noopDeferred: () => noopDeferred,
139
151
  normalizePaymentRef: () => normalizePaymentRef,
140
152
  notifySubscriptionExpired: () => notifySubscriptionExpired,
@@ -143,6 +155,10 @@ __export(src_exports, {
143
155
  parseApiError: () => parseApiError,
144
156
  parseCreditError: () => parseCreditError,
145
157
  parseTier: () => parseTier,
158
+ planBalanceCap: () => planBalanceCap,
159
+ planBalanceTarget: () => planBalanceTarget,
160
+ planHoldCommit: () => planHoldCommit,
161
+ recordedExpiredBacking: () => recordedExpiredBacking,
146
162
  registerConfigProvider: () => registerConfigProvider,
147
163
  registerNotificationHandler: () => registerNotificationHandler,
148
164
  releaseReservationWithJournal: () => releaseReservationWithJournal,
@@ -152,6 +168,10 @@ __export(src_exports, {
152
168
  reserveThroughRepository: () => reserveThroughRepository,
153
169
  resetConfig: () => resetConfig,
154
170
  sameAmount: () => sameAmount,
171
+ settleExpiredHoldBacking: () => settleExpiredHoldBacking,
172
+ spendableBalance: () => spendableBalance,
173
+ storedAmountCeil: () => storedAmountCeil,
174
+ storedAmountFloor: () => storedAmountFloor,
155
175
  storedMonthlyLimit: () => storedMonthlyLimit,
156
176
  sumAmounts: () => sumAmounts,
157
177
  supportsCreditsV2: () => supportsCreditsV2,
@@ -163,6 +183,7 @@ __export(src_exports, {
163
183
  toClientUserCredits: () => toClientUserCredits,
164
184
  toDate: () => toDate,
165
185
  toPortableTimestamp: () => toPortableTimestamp,
186
+ validateBalanceUpdate: () => validateBalanceUpdate,
166
187
  verifyAdminApiKey: () => verifyAdminApiKey,
167
188
  verifyBearerToken: () => verifyBearerToken
168
189
  });
@@ -337,6 +358,40 @@ function floatSum(values) {
337
358
  for (const value of values) total += value;
338
359
  return total;
339
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
+ }
340
395
  function isValidCreditAmount(value) {
341
396
  if (typeof value !== "number") return false;
342
397
  const cents = toCents(value);
@@ -389,6 +444,150 @@ function backedBalanceFloor(reserved, bonusCredits) {
389
444
  assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
390
445
  return floor;
391
446
  }
447
+ function cappedBalance(balance, cap, reserved, bonusCredits) {
448
+ return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
449
+ }
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));
456
+ }
457
+ function planBalanceTarget(target, reserved, bonusCredits) {
458
+ assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
459
+ const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
460
+ return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
461
+ }
462
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
463
+ const spendable = spendableBalance(balance, expiredHoldBacking);
464
+ const target = Math.min(spendable, cap);
465
+ const kept = Math.max(
466
+ sumAmounts(balance, -spendable),
467
+ sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
468
+ );
469
+ const next = Math.min(balance, sumAmounts(target, kept));
470
+ return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
471
+ }
472
+ function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
473
+ const held = heldExpiredBacking(balance, expiredHoldBacking);
474
+ const used = Math.max(0, Math.min(held, holdBacking));
475
+ return { used, remaining: sumAmounts(held, -used) };
476
+ }
477
+ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
478
+ const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
479
+ const rest = sumAmounts(amount, -backing.used);
480
+ const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
481
+ const fromBonus = sumAmounts(rest, -fromSpendable);
482
+ return {
483
+ covered: fromBonus <= bonusCredits,
484
+ balance: sumAmounts(balance, -backing.used, -fromSpendable),
485
+ bonusCredits: sumAmounts(bonusCredits, -fromBonus),
486
+ expiredHoldBacking: backing.remaining
487
+ };
488
+ }
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);
518
+ }
519
+ let frontier = NO_EXPIRED_BACKING_PREFIX;
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;
529
+ const share = shares.get(hold.id);
530
+ const added = Math.min(sumAmounts(hold.amount, -share), left);
531
+ shares.set(hold.id, sumAmounts(share, added));
532
+ left = sumAmounts(left, -added);
533
+ }
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 } : {} };
536
+ }
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;
566
+ }
567
+ function assertValidBalanceTarget(value, field, context) {
568
+ assertRepresentableAmount(value, field, context);
569
+ if (value < 0) {
570
+ throw new CreditError(
571
+ `${field} cannot be negative (got ${value})`,
572
+ CreditErrorCode.INVALID_AMOUNT,
573
+ { field, value, ...context }
574
+ );
575
+ }
576
+ }
577
+ function assertValidTierBalanceInput(input, context) {
578
+ if (input.balanceCap === void 0) {
579
+ if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
580
+ return;
581
+ }
582
+ if (input.balance !== void 0) {
583
+ throw new CreditError(
584
+ "A tier update cannot set both balance and balanceCap",
585
+ CreditErrorCode.INVALID_AMOUNT,
586
+ { balance: input.balance, balanceCap: input.balanceCap, ...context }
587
+ );
588
+ }
589
+ assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
590
+ }
392
591
  function assertRepresentableTierAmount(value, field, context) {
393
592
  if (value === Number.POSITIVE_INFINITY) return;
394
593
  assertRepresentableAmount(value, field, context);
@@ -797,9 +996,9 @@ var creditSystemConfigSchema = import_zod.z.object({
797
996
  */
798
997
  reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
799
998
  /**
800
- * Default credits for new users (free tier)
999
+ * Default credits for new users (free tier). 0 means no signup grant.
801
1000
  */
802
- defaultFreeCredits: import_zod.z.number().positive().default(25),
1001
+ defaultFreeCredits: import_zod.z.number().min(0).default(25),
803
1002
  /**
804
1003
  * Grace period in days after subscription expires before downgrade
805
1004
  */
@@ -1099,6 +1298,8 @@ function toClientUserCredits(credits) {
1099
1298
  monthlyUsed: credits.monthlyUsed,
1100
1299
  monthlyResetAt: toISOString(credits.monthlyResetAt),
1101
1300
  subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
1301
+ ...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
1302
+ ...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
1102
1303
  createdAt: toISOString(credits.createdAt),
1103
1304
  updatedAt: toISOString(credits.updatedAt)
1104
1305
  };
@@ -1330,11 +1531,36 @@ function toDate2(value) {
1330
1531
  return /* @__PURE__ */ new Date();
1331
1532
  }
1332
1533
  function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
1333
- const next = new Date(from);
1334
- next.setMonth(next.getMonth() + 1);
1335
- next.setDate(1);
1336
- next.setHours(0, 0, 0, 0);
1337
- return next;
1534
+ return new Date(from.getFullYear(), from.getMonth() + 1, 1);
1535
+ }
1536
+ function validateBalanceUpdate(current, updates) {
1537
+ const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
1538
+ const balance = next(current.balance, updates.balance, updates.balanceIncrement);
1539
+ const bonusCredits = next(
1540
+ current.bonusCredits ?? 0,
1541
+ updates.bonusCredits,
1542
+ updates.bonusCreditsIncrement
1543
+ );
1544
+ const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
1545
+ const errors = [];
1546
+ if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
1547
+ const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
1548
+ if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
1549
+ errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
1550
+ }
1551
+ if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
1552
+ if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
1553
+ const covering = sumAmounts(balance, bonusCredits);
1554
+ if (reserved > covering) {
1555
+ errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
1556
+ }
1557
+ return { valid: errors.length === 0, errors };
1558
+ }
1559
+ function assertValidBalanceUpdate(current, updates) {
1560
+ const result = validateBalanceUpdate(current, updates);
1561
+ if (!result.valid) {
1562
+ throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
1563
+ }
1338
1564
  }
1339
1565
 
1340
1566
  // src/repository/memory/snapshot.ts
@@ -1470,6 +1696,7 @@ function commitTransitionJournal(store, input, existingId) {
1470
1696
  userId: input.userId,
1471
1697
  entryType: input.entryType,
1472
1698
  amount: input.amount,
1699
+ balanceBefore: input.balanceBefore,
1473
1700
  balanceAfter: input.balanceAfter,
1474
1701
  source: input.source,
1475
1702
  referenceId: input.reservationId,
@@ -1514,6 +1741,34 @@ function assertTrustworthy(reservation, transition) {
1514
1741
  assertLockedAmount(reservation, transition);
1515
1742
  assertHoldPlaced(reservation, transition);
1516
1743
  }
1744
+ function openHolds(store, userId) {
1745
+ return [...store.reservations.get(userId)?.values() ?? []].filter(
1746
+ (reservation) => reservation.status === "reserved"
1747
+ );
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
+ }
1762
+ function writeExpiredHoldBacking(store, credits, next) {
1763
+ if (next <= 0) {
1764
+ Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
1765
+ return;
1766
+ }
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));
1771
+ }
1517
1772
  function getReservation(store, userId, reservationId) {
1518
1773
  return store.reservations.get(userId)?.get(reservationId);
1519
1774
  }
@@ -1561,6 +1816,7 @@ async function reserveCreditsV2(store, input) {
1561
1816
  };
1562
1817
  }
1563
1818
  const now = (/* @__PURE__ */ new Date()).toISOString();
1819
+ const backing = expiredBackingInForce(store, credits);
1564
1820
  const reservation = {
1565
1821
  id: generateId(),
1566
1822
  userId: input.userId,
@@ -1573,7 +1829,7 @@ async function reserveCreditsV2(store, input) {
1573
1829
  // The hold-origin fact. Written here and nowhere else, in the same
1574
1830
  // critical section that raises `reserved` below, so the row and the hold
1575
1831
  // become visible together. `createReservation` cannot set it.
1576
- holdPlacedAt: now
1832
+ holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
1577
1833
  };
1578
1834
  if (!store.reservations.has(input.userId)) {
1579
1835
  store.reservations.set(input.userId, /* @__PURE__ */ new Map());
@@ -1591,6 +1847,8 @@ async function reserveCreditsV2(store, input) {
1591
1847
  );
1592
1848
  }
1593
1849
  credits.reserved = nextReserved;
1850
+ writeRecordedShares(store, input.userId, backing);
1851
+ Object.assign(credits, expiredBackingPatch(backing, credits));
1594
1852
  credits.updatedAt = now;
1595
1853
  return { outcome: "created", reservation: copyRecord(reservation) };
1596
1854
  });
@@ -1611,22 +1869,30 @@ async function commitReservationV2(store, userId, reservationId, options) {
1611
1869
  const amount = reservation.amount;
1612
1870
  await yieldPoint(store);
1613
1871
  assertReservedCoversHold(credits, userId, reservationId, amount);
1614
- if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
1872
+ const backing = expiredBackingInForce(store, credits);
1873
+ const paid = planHoldCommit(
1874
+ credits.balance,
1875
+ credits.bonusCredits,
1876
+ backing.total,
1877
+ holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
1878
+ amount
1879
+ );
1880
+ if (!paid.covered) {
1615
1881
  throw new CreditError(
1616
1882
  `Insufficient credits to commit reservation ${reservationId}`,
1617
1883
  CreditErrorCode.INSUFFICIENT_CREDITS,
1618
1884
  { userId, reservationId, required: amount }
1619
1885
  );
1620
1886
  }
1621
- const fromBalance = Math.min(credits.balance, amount);
1622
- const nextBalance = sumAmounts(credits.balance, -fromBalance);
1623
- const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
1887
+ const nextBalance = paid.balance;
1888
+ const nextBonusCredits = paid.bonusCredits;
1624
1889
  const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
1625
1890
  const journal = {
1626
1891
  userId,
1627
1892
  operation: "commitReservation",
1628
1893
  entryType: "debit",
1629
1894
  amount,
1895
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1630
1896
  balanceAfter,
1631
1897
  source: "operation_commit",
1632
1898
  reservationId,
@@ -1654,6 +1920,11 @@ async function commitReservationV2(store, userId, reservationId, options) {
1654
1920
  credits.bonusCredits = nextBonusCredits;
1655
1921
  credits.reserved = nextReserved;
1656
1922
  credits.monthlyUsed = nextMonthlyUsed;
1923
+ writeRecordedShares(store, userId, backing);
1924
+ Object.assign(
1925
+ credits,
1926
+ expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
1927
+ );
1657
1928
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1658
1929
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
1659
1930
  return {
@@ -1733,19 +2004,28 @@ function finishUnspent(store, reservation, transition) {
1733
2004
  reservation.id,
1734
2005
  reservation.amount
1735
2006
  );
2007
+ const inForce = expiredBackingInForce(store, credits);
2008
+ const backing = settleExpiredHoldBacking(
2009
+ credits.balance,
2010
+ inForce.total,
2011
+ holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
2012
+ );
2013
+ const nextBalance = sumAmounts(credits.balance, -backing.used);
1736
2014
  const journal = {
1737
2015
  userId: reservation.userId,
1738
2016
  operation: `${transition.transition}Reservation`,
1739
2017
  entryType: "credit",
1740
2018
  amount: 0,
1741
- balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
2019
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
2020
+ balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1742
2021
  source: transition.source,
1743
2022
  reservationId: reservation.id,
1744
2023
  description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1745
2024
  metadata: {
1746
2025
  ...transition.options?.metadata,
1747
2026
  operationType: reservation.operationType,
1748
- amount: reservation.amount
2027
+ amount: reservation.amount,
2028
+ ...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
1749
2029
  },
1750
2030
  idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
1751
2031
  };
@@ -1758,6 +2038,12 @@ function finishUnspent(store, reservation, transition) {
1758
2038
  reservation.status = transition.status;
1759
2039
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1760
2040
  credits.reserved = nextReserved;
2041
+ credits.balance = nextBalance;
2042
+ writeRecordedShares(store, reservation.userId, inForce);
2043
+ Object.assign(
2044
+ credits,
2045
+ expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
2046
+ );
1761
2047
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1762
2048
  return commitTransitionJournal(store, journal, existingEntryId);
1763
2049
  }
@@ -1768,6 +2054,10 @@ function isoOf(value) {
1768
2054
  }
1769
2055
  var InMemoryCreditRepository = class {
1770
2056
  store = new MemoryStore();
2057
+ options;
2058
+ constructor(options = {}) {
2059
+ this.options = options;
2060
+ }
1771
2061
  // ==================== User Credits ====================
1772
2062
  /**
1773
2063
  * A copy, never the live record.
@@ -1784,7 +2074,8 @@ var InMemoryCreditRepository = class {
1784
2074
  return copyRecord(credits) ?? null;
1785
2075
  }
1786
2076
  async initializeUserCredits(userId, tier, initialBalance) {
1787
- return copyRecord(this.seedUser(userId, tier, initialBalance));
2077
+ const existing = this.store.users.get(userId);
2078
+ return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
1788
2079
  }
1789
2080
  /**
1790
2081
  * The synchronous body of `initializeUserCredits`.
@@ -1795,7 +2086,7 @@ var InMemoryCreditRepository = class {
1795
2086
  * simultaneous first deliveries of the same reference would then both credit.
1796
2087
  */
1797
2088
  seedUser(userId, tier, initialBalance) {
1798
- assertRepresentableAmount(initialBalance, "initialBalance", { userId });
2089
+ assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
1799
2090
  const configured = getConfigMonthlyLimit(tier);
1800
2091
  assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
1801
2092
  const monthlyLimit = storedMonthlyLimit(configured);
@@ -1835,8 +2126,11 @@ var InMemoryCreditRepository = class {
1835
2126
  if (!credits) {
1836
2127
  throw new Error(`User ${userId} not found`);
1837
2128
  }
2129
+ assertValidBalanceUpdate(credits, updates);
1838
2130
  const next = { ...credits };
1839
- if (updates.balance !== void 0) next.balance = updates.balance;
2131
+ if (updates.balance !== void 0) {
2132
+ next.balance = updates.balance;
2133
+ }
1840
2134
  if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
1841
2135
  if (updates.reserved !== void 0) next.reserved = updates.reserved;
1842
2136
  if (updates.tier !== void 0) next.tier = updates.tier;
@@ -1873,6 +2167,11 @@ var InMemoryCreditRepository = class {
1873
2167
  userId,
1874
2168
  operation: "updateUserCredits"
1875
2169
  });
2170
+ writeExpiredHoldBacking(
2171
+ this.store,
2172
+ next,
2173
+ updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
2174
+ );
1876
2175
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1877
2176
  this.store.users.set(userId, next);
1878
2177
  }
@@ -1881,18 +2180,34 @@ var InMemoryCreditRepository = class {
1881
2180
  { monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
1882
2181
  { userId, operation: "updateUserTier" }
1883
2182
  );
2183
+ assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
1884
2184
  const credits = this.store.users.get(userId);
1885
2185
  if (!credits) {
1886
2186
  throw new Error(`User ${userId} not found`);
1887
2187
  }
2188
+ if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
2189
+ const capped = planBalanceCap(
2190
+ credits.balance,
2191
+ input.balanceCap,
2192
+ credits.reserved,
2193
+ credits.bonusCredits,
2194
+ credits.expiredHoldBacking
2195
+ );
2196
+ credits.balance = capped.balance;
2197
+ writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
2198
+ }
1888
2199
  credits.tier = input.tier;
1889
2200
  credits.monthlyLimit = input.monthlyLimit;
1890
2201
  if (input.balance !== void 0) {
1891
- credits.balance = Math.max(
1892
- input.balance,
1893
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
1894
- );
2202
+ const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
2203
+ credits.balance = planned.balance;
2204
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1895
2205
  }
2206
+ writeExpiredHoldBacking(
2207
+ this.store,
2208
+ credits,
2209
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
2210
+ );
1896
2211
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1897
2212
  if (input.subscriptionExpiresAt !== void 0) {
1898
2213
  if (input.subscriptionExpiresAt === null) {
@@ -2054,6 +2369,11 @@ var InMemoryCreditRepository = class {
2054
2369
  { userId, operation: "addCredits" }
2055
2370
  );
2056
2371
  credits.bonusCredits = nextBonusCredits;
2372
+ writeExpiredHoldBacking(
2373
+ this.store,
2374
+ credits,
2375
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
2376
+ );
2057
2377
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2058
2378
  this.store.users.set(userId, credits);
2059
2379
  const transaction = this.recordTransaction({
@@ -2073,6 +2393,7 @@ var InMemoryCreditRepository = class {
2073
2393
  userId,
2074
2394
  entryType: "credit",
2075
2395
  amount,
2396
+ balanceBefore: previousTotal,
2076
2397
  balanceAfter: newTotal,
2077
2398
  source: payload.source,
2078
2399
  referenceId: transaction.id ?? paymentRef ?? "unknown",
@@ -2106,19 +2427,23 @@ var InMemoryCreditRepository = class {
2106
2427
  });
2107
2428
  }
2108
2429
  }
2109
- async deductCreditsAtomic(userId, amount) {
2430
+ async deductCreditsAtomic(userId, amount, journal) {
2110
2431
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
2111
2432
  const credits = this.store.users.get(userId);
2112
2433
  if (!credits) {
2113
2434
  throw new Error(`User credits not found for userId: ${userId}`);
2114
2435
  }
2115
- const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
2436
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
2437
+ const available = Math.min(
2438
+ sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
2439
+ sumAmounts(spendable, credits.bonusCredits)
2440
+ );
2116
2441
  if (available < amount) {
2117
2442
  throw new Error(
2118
2443
  `Insufficient credits. Available: ${available}, requested: ${amount}`
2119
2444
  );
2120
2445
  }
2121
- const balanceDeduction = Math.min(credits.balance, amount);
2446
+ const balanceDeduction = Math.min(spendable, amount);
2122
2447
  const bonusDeduction = sumAmounts(amount, -balanceDeduction);
2123
2448
  const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
2124
2449
  const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
@@ -2128,11 +2453,26 @@ var InMemoryCreditRepository = class {
2128
2453
  { previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
2129
2454
  { userId, operation: "deductCredits" }
2130
2455
  );
2456
+ const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
2131
2457
  credits.balance = nextBalance;
2132
2458
  credits.bonusCredits = nextBonusCredits;
2459
+ writeExpiredHoldBacking(this.store, credits, held);
2133
2460
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2134
2461
  this.store.users.set(userId, credits);
2135
- 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 };
2136
2476
  }
2137
2477
  // ==================== Transactions ====================
2138
2478
  async createTransaction(input) {
@@ -2246,6 +2586,11 @@ var InMemoryCreditRepository = class {
2246
2586
  assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
2247
2587
  userId: input.userId
2248
2588
  });
2589
+ if (input.balanceBefore !== void 0) {
2590
+ assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
2591
+ userId: input.userId
2592
+ });
2593
+ }
2249
2594
  assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
2250
2595
  assertPublicJournalKey(input.idempotencyKey, input.userId);
2251
2596
  const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
@@ -2261,6 +2606,7 @@ var InMemoryCreditRepository = class {
2261
2606
  userId: input.userId,
2262
2607
  entryType: input.entryType,
2263
2608
  amount: input.amount,
2609
+ ...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
2264
2610
  balanceAfter: input.balanceAfter,
2265
2611
  source: input.source,
2266
2612
  referenceId: input.referenceId,
@@ -2355,7 +2701,11 @@ var InMemoryCreditRepository = class {
2355
2701
  return { expiredCount, creditsReleased, errors };
2356
2702
  }
2357
2703
  // ==================== Atomic Monthly Reset ====================
2358
- async atomicMonthlyReset(userId, tier, expectedResetAt) {
2704
+ /**
2705
+ * @param _tier - Not trusted. The caller read it earlier and the account may
2706
+ * have changed tier since, so the reset uses the tier stored on the account.
2707
+ */
2708
+ async atomicMonthlyReset(userId, _tier, expectedResetAt) {
2359
2709
  const credits = this.store.users.get(userId);
2360
2710
  if (!credits) {
2361
2711
  throw new Error(`User ${userId} not found`);
@@ -2365,17 +2715,41 @@ var InMemoryCreditRepository = class {
2365
2715
  if (currentResetAt !== expected) {
2366
2716
  return { wasReset: false, credits: { ...credits } };
2367
2717
  }
2368
- assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2718
+ const tier = credits.tier;
2719
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
2720
+ const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
2721
+ if (decision === null) {
2722
+ return { wasReset: false, credits: { ...credits } };
2723
+ }
2724
+ if (decision) {
2725
+ assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
2726
+ if (decision.monthlyLimit !== void 0) {
2727
+ assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
2728
+ userId,
2729
+ tier
2730
+ });
2731
+ }
2732
+ } else {
2733
+ assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2734
+ }
2369
2735
  const nextReset = getNextMonthlyReset();
2370
- const target = monthlyResetBalance(tier);
2371
- const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
2372
2736
  const previousBalance = credits.balance;
2373
- const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
2737
+ let target;
2738
+ if (decision) {
2739
+ target = decision.balance;
2740
+ } else {
2741
+ const configured = monthlyResetBalance(tier);
2742
+ target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
2743
+ }
2744
+ const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
2745
+ const newBalance = planned.balance;
2374
2746
  const change = sumAmounts(newBalance, -previousBalance);
2375
2747
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2376
2748
  assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
2377
2749
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2378
2750
  credits.balance = newBalance;
2751
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2752
+ if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
2379
2753
  credits.monthlyUsed = 0;
2380
2754
  credits.monthlyResetAt = nextReset.toISOString();
2381
2755
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -2385,6 +2759,7 @@ var InMemoryCreditRepository = class {
2385
2759
  userId,
2386
2760
  entryType: change > 0 ? "credit" : "debit",
2387
2761
  amount: Math.abs(change),
2762
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2388
2763
  balanceAfter,
2389
2764
  source: "monthly_reset",
2390
2765
  referenceId: `reset-${Date.now()}`,
@@ -2429,23 +2804,34 @@ var InMemoryCreditRepository = class {
2429
2804
  };
2430
2805
  }
2431
2806
  const defaultTier = getDefaultTier();
2432
- const configured = getConfigMonthlyLimit(defaultTier);
2433
- assertRepresentableTierAmount(configured, "monthlyLimit", {
2434
- userId,
2435
- tier: defaultTier
2436
- });
2437
- const limit = storedMonthlyLimit(configured);
2807
+ let limit;
2808
+ if (this.options.getDowngradeBalanceCap) {
2809
+ limit = this.options.getDowngradeBalanceCap();
2810
+ assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
2811
+ } else {
2812
+ const configured = getConfigMonthlyLimit(defaultTier);
2813
+ assertRepresentableTierAmount(configured, "monthlyLimit", {
2814
+ userId,
2815
+ tier: defaultTier
2816
+ });
2817
+ limit = storedMonthlyLimit(configured);
2818
+ }
2438
2819
  const previousTier = credits.tier;
2439
2820
  const previousBalance = credits.balance;
2440
- const newBalance = Math.max(
2441
- Math.min(credits.balance, limit),
2442
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
2821
+ const planned = planBalanceCap(
2822
+ credits.balance,
2823
+ limit,
2824
+ credits.reserved,
2825
+ credits.bonusCredits,
2826
+ credits.expiredHoldBacking
2443
2827
  );
2828
+ const newBalance = planned.balance;
2444
2829
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2445
2830
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2446
2831
  credits.tier = defaultTier;
2447
2832
  credits.monthlyLimit = limit;
2448
2833
  credits.balance = newBalance;
2834
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2449
2835
  credits.subscriptionExpiresAt = null;
2450
2836
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2451
2837
  this.store.users.set(userId, credits);
@@ -2454,6 +2840,7 @@ var InMemoryCreditRepository = class {
2454
2840
  entryType: "debit",
2455
2841
  amount: 0,
2456
2842
  // No credits deducted, just tier change
2843
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2457
2844
  balanceAfter,
2458
2845
  source: "subscription_downgrade",
2459
2846
  referenceId: `downgrade-${Date.now()}`,
@@ -2503,8 +2890,8 @@ var InMemoryCreditRepository = class {
2503
2890
  return copyRecords(Array.from(userReservations.values()));
2504
2891
  }
2505
2892
  };
2506
- function createInMemoryCreditRepository() {
2507
- return new InMemoryCreditRepository();
2893
+ function createInMemoryCreditRepository(options = {}) {
2894
+ return new InMemoryCreditRepository(options);
2508
2895
  }
2509
2896
 
2510
2897
  // src/auth/api-key.ts
@@ -2617,6 +3004,7 @@ var CreditsService = class {
2617
3004
  entryType: "debit",
2618
3005
  amount: 0,
2619
3006
  // No credits deducted, just tier change
3007
+ balanceBefore: sumAmounts(data.balance, data.bonusCredits),
2620
3008
  // The ledger balance, matching every other journal writer: the V2
2621
3009
  // transitions and add/deduct all record `balance + bonusCredits`, so
2622
3010
  // recording `balance` alone made the audit trail disagree with itself
@@ -2660,6 +3048,7 @@ var CreditsService = class {
2660
3048
  userId,
2661
3049
  entryType: balanceChange > 0 ? "credit" : "debit",
2662
3050
  amount: Math.abs(balanceChange),
3051
+ balanceBefore: sumAmounts(data.balance, data.bonusCredits),
2663
3052
  balanceAfter: sumAmounts(
2664
3053
  resetResult.credits.balance,
2665
3054
  resetResult.credits.bonusCredits
@@ -2667,9 +3056,11 @@ var CreditsService = class {
2667
3056
  source: "monthly_reset",
2668
3057
  referenceId: `reset-${Date.now()}`,
2669
3058
  referenceType: "reset",
2670
- description: `Monthly credit reset for ${data.tier} tier.`,
3059
+ // The tier the reset ran on, which a concurrent downgrade may have
3060
+ // changed since `data` was read.
3061
+ description: `Monthly credit reset for ${resetResult.credits.tier} tier.`,
2671
3062
  metadata: {
2672
- tier: data.tier,
3063
+ tier: resetResult.credits.tier,
2673
3064
  previousBalance: data.balance,
2674
3065
  newBalance: resetResult.credits.balance
2675
3066
  }
@@ -2892,9 +3283,18 @@ var CreditsService = class {
2892
3283
  */
2893
3284
  async deductCredits(userId, amount, options) {
2894
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
+ };
2895
3295
  let deduction;
2896
3296
  try {
2897
- deduction = await this.repository.deductCreditsAtomic(userId, amount);
3297
+ deduction = await this.repository.deductCreditsAtomic(userId, amount, journal);
2898
3298
  } catch (error) {
2899
3299
  if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
2900
3300
  const credits = await this.repository.getUserCredits(userId);
@@ -2910,21 +3310,18 @@ var CreditsService = class {
2910
3310
  shortfall: sumAmounts(amount, -available)
2911
3311
  };
2912
3312
  }
2913
- const operationType = options?.operationType;
2914
- await this.repository.createJournalEntry({
2915
- userId,
2916
- entryType: "debit",
2917
- amount,
2918
- balanceAfter: deduction.newBalance,
2919
- source: "operation_commit",
2920
- referenceId: options?.requestId ?? `deduct-${Date.now()}`,
2921
- referenceType: "adjustment",
2922
- description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
2923
- metadata: {
2924
- ...options?.metadata,
2925
- ...operationType && { operationType }
2926
- }
2927
- });
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
+ }
2928
3325
  if (operationType) {
2929
3326
  await this.logUsage({
2930
3327
  userId,
@@ -3935,6 +4332,7 @@ function createDummyReservation2(userId, operationType) {
3935
4332
  CreditsSDKError,
3936
4333
  CreditsService,
3937
4334
  InMemoryCreditRepository,
4335
+ NO_EXPIRED_BACKING_PREFIX,
3938
4336
  NetworkError,
3939
4337
  RESERVATION_STATUSES,
3940
4338
  RESERVED_JOURNAL_KEY_PREFIX,
@@ -3947,6 +4345,7 @@ function createDummyReservation2(userId, operationType) {
3947
4345
  UNLIMITED_BALANCE_SENTINEL,
3948
4346
  ValidationError,
3949
4347
  addCreditsThroughRepository,
4348
+ allocateExpiredBacking,
3950
4349
  assertDirectStatusWriteAllowed,
3951
4350
  assertHoldPlaced,
3952
4351
  assertKnownReservationStatus,
@@ -3956,12 +4355,17 @@ function createDummyReservation2(userId, operationType) {
3956
4355
  assertRepresentableTierAmount,
3957
4356
  assertUnkeyedDirectReservation,
3958
4357
  assertUnreferencedDirectTransaction,
4358
+ assertValidBalanceTarget,
4359
+ assertValidBalanceUpdate,
3959
4360
  assertValidCreditAmount,
3960
4361
  assertValidIdempotencyKey,
3961
4362
  assertValidStoredAmount,
3962
4363
  assertValidStoredAmountRaw,
4364
+ assertValidTierBalanceInput,
3963
4365
  backedBalanceFloor,
3964
4366
  calculateAvailableCredits,
4367
+ canonicalAmount,
4368
+ cappedBalance,
3965
4369
  checkAndNotifyLowBalance,
3966
4370
  classifyDatabaseError,
3967
4371
  clearConfigProvider,
@@ -3987,6 +4391,7 @@ function createDummyReservation2(userId, operationType) {
3987
4391
  createUnsupportedOperationError,
3988
4392
  createUserNotFoundError,
3989
4393
  describePaymentMismatch,
4394
+ expiredBackingPatch,
3990
4395
  generateId,
3991
4396
  generateRequestId,
3992
4397
  genericDeferred,
@@ -4012,6 +4417,9 @@ function createDummyReservation2(userId, operationType) {
4012
4417
  getValidOperationTypes,
4013
4418
  getValidTiers,
4014
4419
  hasPlacedHold,
4420
+ heldExpiredBacking,
4421
+ holdExpiredBacking,
4422
+ holdPlacedAtAfter,
4015
4423
  initializeConfig,
4016
4424
  isCreditError,
4017
4425
  isCreditErrorMessage,
@@ -4037,6 +4445,7 @@ function createDummyReservation2(userId, operationType) {
4037
4445
  isWinningOutcome,
4038
4446
  loadConfigFromEnv,
4039
4447
  monthlyResetBalance,
4448
+ multiplyAmount,
4040
4449
  noopDeferred,
4041
4450
  normalizePaymentRef,
4042
4451
  notifySubscriptionExpired,
@@ -4045,6 +4454,10 @@ function createDummyReservation2(userId, operationType) {
4045
4454
  parseApiError,
4046
4455
  parseCreditError,
4047
4456
  parseTier,
4457
+ planBalanceCap,
4458
+ planBalanceTarget,
4459
+ planHoldCommit,
4460
+ recordedExpiredBacking,
4048
4461
  registerConfigProvider,
4049
4462
  registerNotificationHandler,
4050
4463
  releaseReservationWithJournal,
@@ -4054,6 +4467,10 @@ function createDummyReservation2(userId, operationType) {
4054
4467
  reserveThroughRepository,
4055
4468
  resetConfig,
4056
4469
  sameAmount,
4470
+ settleExpiredHoldBacking,
4471
+ spendableBalance,
4472
+ storedAmountCeil,
4473
+ storedAmountFloor,
4057
4474
  storedMonthlyLimit,
4058
4475
  sumAmounts,
4059
4476
  supportsCreditsV2,
@@ -4065,6 +4482,7 @@ function createDummyReservation2(userId, operationType) {
4065
4482
  toClientUserCredits,
4066
4483
  toDate,
4067
4484
  toPortableTimestamp,
4485
+ validateBalanceUpdate,
4068
4486
  verifyAdminApiKey,
4069
4487
  verifyBearerToken
4070
4488
  });