@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
@@ -22,6 +22,7 @@ var repository_exports = {};
22
22
  __export(repository_exports, {
23
23
  InMemoryCreditRepository: () => InMemoryCreditRepository,
24
24
  addCreditsThroughRepository: () => addCreditsThroughRepository,
25
+ assertValidBalanceUpdate: () => assertValidBalanceUpdate,
25
26
  commitThroughRepository: () => commitThroughRepository,
26
27
  createInMemoryCreditRepository: () => createInMemoryCreditRepository,
27
28
  generateId: () => generateId,
@@ -32,7 +33,8 @@ __export(repository_exports, {
32
33
  supportsCreditsV2: () => supportsCreditsV2,
33
34
  supportsIdempotentCredit: () => supportsIdempotentCredit,
34
35
  toClientUserCredits: () => toClientUserCredits,
35
- toDate: () => toDate
36
+ toDate: () => toDate,
37
+ validateBalanceUpdate: () => validateBalanceUpdate
36
38
  });
37
39
  module.exports = __toCommonJS(repository_exports);
38
40
 
@@ -54,6 +56,8 @@ function toClientUserCredits(credits) {
54
56
  monthlyUsed: credits.monthlyUsed,
55
57
  monthlyResetAt: toISOString(credits.monthlyResetAt),
56
58
  subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
59
+ ...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
60
+ ...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
57
61
  createdAt: toISOString(credits.createdAt),
58
62
  updatedAt: toISOString(credits.updatedAt)
59
63
  };
@@ -92,6 +96,23 @@ function floatSum(values) {
92
96
  for (const value of values) total += value;
93
97
  return total;
94
98
  }
99
+ var FLOAT_NOISE_FLOOR = 1e-6;
100
+ var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
101
+ function floatNoise(value) {
102
+ return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
103
+ }
104
+ function canonicalAmount(value) {
105
+ if (typeof value !== "number" || !Number.isFinite(value)) return value;
106
+ const cents = Math.round(value * 100);
107
+ if (!Number.isSafeInteger(cents)) return value;
108
+ const onGrid = cents / 100 + 0;
109
+ return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
110
+ }
111
+ function storedAmountCeil(value) {
112
+ const canonical = canonicalAmount(value);
113
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
114
+ return Math.ceil(canonical * 100) / 100;
115
+ }
95
116
  function isValidCreditAmount(value) {
96
117
  if (typeof value !== "number") return false;
97
118
  const cents = toCents(value);
@@ -144,6 +165,147 @@ function backedBalanceFloor(reserved, bonusCredits) {
144
165
  assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
145
166
  return floor;
146
167
  }
168
+ function heldExpiredBacking(balance, stored) {
169
+ if (!(stored !== void 0 && stored > 0)) return 0;
170
+ return Math.max(0, Math.min(stored, balance));
171
+ }
172
+ function spendableBalance(balance, expiredHoldBacking) {
173
+ return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
174
+ }
175
+ function planBalanceTarget(target, reserved, bonusCredits) {
176
+ assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
177
+ const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
178
+ return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
179
+ }
180
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
181
+ const spendable = spendableBalance(balance, expiredHoldBacking);
182
+ const target = Math.min(spendable, cap);
183
+ const kept = Math.max(
184
+ sumAmounts(balance, -spendable),
185
+ sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
186
+ );
187
+ const next = Math.min(balance, sumAmounts(target, kept));
188
+ return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
189
+ }
190
+ function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
191
+ const held = heldExpiredBacking(balance, expiredHoldBacking);
192
+ const used = Math.max(0, Math.min(held, holdBacking));
193
+ return { used, remaining: sumAmounts(held, -used) };
194
+ }
195
+ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
196
+ const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
197
+ const rest = sumAmounts(amount, -backing.used);
198
+ const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
199
+ const fromBonus = sumAmounts(rest, -fromSpendable);
200
+ return {
201
+ covered: fromBonus <= bonusCredits,
202
+ balance: sumAmounts(balance, -backing.used, -fromSpendable),
203
+ bonusCredits: sumAmounts(bonusCredits, -fromBonus),
204
+ expiredHoldBacking: backing.remaining
205
+ };
206
+ }
207
+ var NO_EXPIRED_BACKING_PREFIX = {
208
+ holdPlacedAt: "",
209
+ id: "",
210
+ share: 0
211
+ };
212
+ function compareHolds(a, b) {
213
+ if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
214
+ if (a.id === b.id) return 0;
215
+ return a.id < b.id ? -1 : 1;
216
+ }
217
+ function recordedExpiredBacking(hold) {
218
+ const stored = hold.expiredBacking;
219
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
220
+ if (!isValidCreditAmount(hold.amount)) return 0;
221
+ return Math.min(storedAmountCeil(stored), hold.amount);
222
+ }
223
+ function allocateExpiredBacking(holds, total) {
224
+ const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
225
+ const wanted = Math.max(0, total);
226
+ if (wanted === 0) return { total: 0, frontier: null };
227
+ const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
228
+ const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
229
+ let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
230
+ for (const hold of [...recorded].reverse()) {
231
+ if (left >= 0) break;
232
+ const share = shares.get(hold.id);
233
+ const cut = Math.min(share, -left);
234
+ shares.set(hold.id, sumAmounts(share, -cut));
235
+ left = sumAmounts(left, cut);
236
+ }
237
+ let frontier = NO_EXPIRED_BACKING_PREFIX;
238
+ for (const hold of placed) {
239
+ if (left <= 0) break;
240
+ if (shares.has(hold.id)) continue;
241
+ const share = Math.min(hold.amount, left);
242
+ frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
243
+ left = sumAmounts(left, -share);
244
+ }
245
+ for (const hold of recorded) {
246
+ if (left <= 0) break;
247
+ const share = shares.get(hold.id);
248
+ const added = Math.min(sumAmounts(hold.amount, -share), left);
249
+ shares.set(hold.id, sumAmounts(share, added));
250
+ left = sumAmounts(left, -added);
251
+ }
252
+ const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
253
+ return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
254
+ }
255
+ function holdExpiredBacking(hold, frontier, rewrites) {
256
+ const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
257
+ const recorded = recordedExpiredBacking(
258
+ rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
259
+ );
260
+ if (recorded > 0) return recorded;
261
+ if (!frontier || !hold.holdPlacedAt) return 0;
262
+ const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
263
+ if (order > 0) return 0;
264
+ return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
265
+ }
266
+ function holdPlacedAtAfter(now, frontier) {
267
+ if (!frontier?.holdPlacedAt) return now;
268
+ const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
269
+ return now > earliest ? now : earliest;
270
+ }
271
+ function expiredBackingPatch(next, stored) {
272
+ const total = Math.max(0, next.total);
273
+ const patch = {};
274
+ const storedTotal = stored.expiredHoldBacking;
275
+ if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
276
+ patch.expiredHoldBacking = total;
277
+ }
278
+ if (total <= 0) {
279
+ if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
280
+ } else if (next.frontier) {
281
+ patch.expiredBackingFrontier = next.frontier;
282
+ }
283
+ return patch;
284
+ }
285
+ function assertValidBalanceTarget(value, field, context) {
286
+ assertRepresentableAmount(value, field, context);
287
+ if (value < 0) {
288
+ throw new CreditError(
289
+ `${field} cannot be negative (got ${value})`,
290
+ CreditErrorCode.INVALID_AMOUNT,
291
+ { field, value, ...context }
292
+ );
293
+ }
294
+ }
295
+ function assertValidTierBalanceInput(input, context) {
296
+ if (input.balanceCap === void 0) {
297
+ if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
298
+ return;
299
+ }
300
+ if (input.balance !== void 0) {
301
+ throw new CreditError(
302
+ "A tier update cannot set both balance and balanceCap",
303
+ CreditErrorCode.INVALID_AMOUNT,
304
+ { balance: input.balance, balanceCap: input.balanceCap, ...context }
305
+ );
306
+ }
307
+ assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
308
+ }
147
309
  function assertRepresentableTierAmount(value, field, context) {
148
310
  if (value === Number.POSITIVE_INFINITY) return;
149
311
  assertRepresentableAmount(value, field, context);
@@ -361,9 +523,9 @@ var creditSystemConfigSchema = import_zod.z.object({
361
523
  */
362
524
  reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
363
525
  /**
364
- * Default credits for new users (free tier)
526
+ * Default credits for new users (free tier). 0 means no signup grant.
365
527
  */
366
- defaultFreeCredits: import_zod.z.number().positive().default(25),
528
+ defaultFreeCredits: import_zod.z.number().min(0).default(25),
367
529
  /**
368
530
  * Grace period in days after subscription expires before downgrade
369
531
  */
@@ -793,11 +955,36 @@ function toDate(value) {
793
955
  return /* @__PURE__ */ new Date();
794
956
  }
795
957
  function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
796
- const next = new Date(from);
797
- next.setMonth(next.getMonth() + 1);
798
- next.setDate(1);
799
- next.setHours(0, 0, 0, 0);
800
- return next;
958
+ return new Date(from.getFullYear(), from.getMonth() + 1, 1);
959
+ }
960
+ function validateBalanceUpdate(current, updates) {
961
+ const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
962
+ const balance = next(current.balance, updates.balance, updates.balanceIncrement);
963
+ const bonusCredits = next(
964
+ current.bonusCredits ?? 0,
965
+ updates.bonusCredits,
966
+ updates.bonusCreditsIncrement
967
+ );
968
+ const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
969
+ const errors = [];
970
+ if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
971
+ const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
972
+ if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
973
+ errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
974
+ }
975
+ if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
976
+ if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
977
+ const covering = sumAmounts(balance, bonusCredits);
978
+ if (reserved > covering) {
979
+ errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
980
+ }
981
+ return { valid: errors.length === 0, errors };
982
+ }
983
+ function assertValidBalanceUpdate(current, updates) {
984
+ const result = validateBalanceUpdate(current, updates);
985
+ if (!result.valid) {
986
+ throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
987
+ }
801
988
  }
802
989
 
803
990
  // src/repository/memory/snapshot.ts
@@ -1034,6 +1221,7 @@ function commitTransitionJournal(store, input, existingId) {
1034
1221
  userId: input.userId,
1035
1222
  entryType: input.entryType,
1036
1223
  amount: input.amount,
1224
+ balanceBefore: input.balanceBefore,
1037
1225
  balanceAfter: input.balanceAfter,
1038
1226
  source: input.source,
1039
1227
  referenceId: input.reservationId,
@@ -1078,6 +1266,34 @@ function assertTrustworthy(reservation, transition) {
1078
1266
  assertLockedAmount(reservation, transition);
1079
1267
  assertHoldPlaced(reservation, transition);
1080
1268
  }
1269
+ function openHolds(store, userId) {
1270
+ return [...store.reservations.get(userId)?.values() ?? []].filter(
1271
+ (reservation) => reservation.status === "reserved"
1272
+ );
1273
+ }
1274
+ function expiredBackingInForce(store, credits) {
1275
+ const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1276
+ if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
1277
+ return allocateExpiredBacking(openHolds(store, credits.userId), total);
1278
+ }
1279
+ return { total, frontier: credits.expiredBackingFrontier ?? null };
1280
+ }
1281
+ function writeRecordedShares(store, userId, allocation) {
1282
+ for (const { id, expiredBacking } of allocation.rewrites ?? []) {
1283
+ const hold = getReservation(store, userId, id);
1284
+ if (hold) hold.expiredBacking = expiredBacking;
1285
+ }
1286
+ }
1287
+ function writeExpiredHoldBacking(store, credits, next) {
1288
+ if (next <= 0) {
1289
+ Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
1290
+ return;
1291
+ }
1292
+ if (next === credits.expiredHoldBacking) return;
1293
+ const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
1294
+ writeRecordedShares(store, credits.userId, allocation);
1295
+ Object.assign(credits, expiredBackingPatch(allocation, credits));
1296
+ }
1081
1297
  function getReservation(store, userId, reservationId) {
1082
1298
  return store.reservations.get(userId)?.get(reservationId);
1083
1299
  }
@@ -1125,6 +1341,7 @@ async function reserveCreditsV2(store, input) {
1125
1341
  };
1126
1342
  }
1127
1343
  const now = (/* @__PURE__ */ new Date()).toISOString();
1344
+ const backing = expiredBackingInForce(store, credits);
1128
1345
  const reservation = {
1129
1346
  id: generateId(),
1130
1347
  userId: input.userId,
@@ -1137,7 +1354,7 @@ async function reserveCreditsV2(store, input) {
1137
1354
  // The hold-origin fact. Written here and nowhere else, in the same
1138
1355
  // critical section that raises `reserved` below, so the row and the hold
1139
1356
  // become visible together. `createReservation` cannot set it.
1140
- holdPlacedAt: now
1357
+ holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
1141
1358
  };
1142
1359
  if (!store.reservations.has(input.userId)) {
1143
1360
  store.reservations.set(input.userId, /* @__PURE__ */ new Map());
@@ -1155,6 +1372,8 @@ async function reserveCreditsV2(store, input) {
1155
1372
  );
1156
1373
  }
1157
1374
  credits.reserved = nextReserved;
1375
+ writeRecordedShares(store, input.userId, backing);
1376
+ Object.assign(credits, expiredBackingPatch(backing, credits));
1158
1377
  credits.updatedAt = now;
1159
1378
  return { outcome: "created", reservation: copyRecord(reservation) };
1160
1379
  });
@@ -1175,22 +1394,30 @@ async function commitReservationV2(store, userId, reservationId, options) {
1175
1394
  const amount = reservation.amount;
1176
1395
  await yieldPoint(store);
1177
1396
  assertReservedCoversHold(credits, userId, reservationId, amount);
1178
- if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
1397
+ const backing = expiredBackingInForce(store, credits);
1398
+ const paid = planHoldCommit(
1399
+ credits.balance,
1400
+ credits.bonusCredits,
1401
+ backing.total,
1402
+ holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
1403
+ amount
1404
+ );
1405
+ if (!paid.covered) {
1179
1406
  throw new CreditError(
1180
1407
  `Insufficient credits to commit reservation ${reservationId}`,
1181
1408
  CreditErrorCode.INSUFFICIENT_CREDITS,
1182
1409
  { userId, reservationId, required: amount }
1183
1410
  );
1184
1411
  }
1185
- const fromBalance = Math.min(credits.balance, amount);
1186
- const nextBalance = sumAmounts(credits.balance, -fromBalance);
1187
- const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
1412
+ const nextBalance = paid.balance;
1413
+ const nextBonusCredits = paid.bonusCredits;
1188
1414
  const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
1189
1415
  const journal = {
1190
1416
  userId,
1191
1417
  operation: "commitReservation",
1192
1418
  entryType: "debit",
1193
1419
  amount,
1420
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1194
1421
  balanceAfter,
1195
1422
  source: "operation_commit",
1196
1423
  reservationId,
@@ -1218,6 +1445,11 @@ async function commitReservationV2(store, userId, reservationId, options) {
1218
1445
  credits.bonusCredits = nextBonusCredits;
1219
1446
  credits.reserved = nextReserved;
1220
1447
  credits.monthlyUsed = nextMonthlyUsed;
1448
+ writeRecordedShares(store, userId, backing);
1449
+ Object.assign(
1450
+ credits,
1451
+ expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
1452
+ );
1221
1453
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1222
1454
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
1223
1455
  return {
@@ -1297,19 +1529,28 @@ function finishUnspent(store, reservation, transition) {
1297
1529
  reservation.id,
1298
1530
  reservation.amount
1299
1531
  );
1532
+ const inForce = expiredBackingInForce(store, credits);
1533
+ const backing = settleExpiredHoldBacking(
1534
+ credits.balance,
1535
+ inForce.total,
1536
+ holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
1537
+ );
1538
+ const nextBalance = sumAmounts(credits.balance, -backing.used);
1300
1539
  const journal = {
1301
1540
  userId: reservation.userId,
1302
1541
  operation: `${transition.transition}Reservation`,
1303
1542
  entryType: "credit",
1304
1543
  amount: 0,
1305
- balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
1544
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1545
+ balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1306
1546
  source: transition.source,
1307
1547
  reservationId: reservation.id,
1308
1548
  description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1309
1549
  metadata: {
1310
1550
  ...transition.options?.metadata,
1311
1551
  operationType: reservation.operationType,
1312
- amount: reservation.amount
1552
+ amount: reservation.amount,
1553
+ ...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
1313
1554
  },
1314
1555
  idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
1315
1556
  };
@@ -1322,6 +1563,12 @@ function finishUnspent(store, reservation, transition) {
1322
1563
  reservation.status = transition.status;
1323
1564
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1324
1565
  credits.reserved = nextReserved;
1566
+ credits.balance = nextBalance;
1567
+ writeRecordedShares(store, reservation.userId, inForce);
1568
+ Object.assign(
1569
+ credits,
1570
+ expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
1571
+ );
1325
1572
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1326
1573
  return commitTransitionJournal(store, journal, existingEntryId);
1327
1574
  }
@@ -1332,6 +1579,10 @@ function isoOf(value) {
1332
1579
  }
1333
1580
  var InMemoryCreditRepository = class {
1334
1581
  store = new MemoryStore();
1582
+ options;
1583
+ constructor(options = {}) {
1584
+ this.options = options;
1585
+ }
1335
1586
  // ==================== User Credits ====================
1336
1587
  /**
1337
1588
  * A copy, never the live record.
@@ -1348,7 +1599,8 @@ var InMemoryCreditRepository = class {
1348
1599
  return copyRecord(credits) ?? null;
1349
1600
  }
1350
1601
  async initializeUserCredits(userId, tier, initialBalance) {
1351
- return copyRecord(this.seedUser(userId, tier, initialBalance));
1602
+ const existing = this.store.users.get(userId);
1603
+ return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
1352
1604
  }
1353
1605
  /**
1354
1606
  * The synchronous body of `initializeUserCredits`.
@@ -1359,7 +1611,7 @@ var InMemoryCreditRepository = class {
1359
1611
  * simultaneous first deliveries of the same reference would then both credit.
1360
1612
  */
1361
1613
  seedUser(userId, tier, initialBalance) {
1362
- assertRepresentableAmount(initialBalance, "initialBalance", { userId });
1614
+ assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
1363
1615
  const configured = getConfigMonthlyLimit(tier);
1364
1616
  assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
1365
1617
  const monthlyLimit = storedMonthlyLimit(configured);
@@ -1399,8 +1651,11 @@ var InMemoryCreditRepository = class {
1399
1651
  if (!credits) {
1400
1652
  throw new Error(`User ${userId} not found`);
1401
1653
  }
1654
+ assertValidBalanceUpdate(credits, updates);
1402
1655
  const next = { ...credits };
1403
- if (updates.balance !== void 0) next.balance = updates.balance;
1656
+ if (updates.balance !== void 0) {
1657
+ next.balance = updates.balance;
1658
+ }
1404
1659
  if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
1405
1660
  if (updates.reserved !== void 0) next.reserved = updates.reserved;
1406
1661
  if (updates.tier !== void 0) next.tier = updates.tier;
@@ -1437,6 +1692,11 @@ var InMemoryCreditRepository = class {
1437
1692
  userId,
1438
1693
  operation: "updateUserCredits"
1439
1694
  });
1695
+ writeExpiredHoldBacking(
1696
+ this.store,
1697
+ next,
1698
+ updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
1699
+ );
1440
1700
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1441
1701
  this.store.users.set(userId, next);
1442
1702
  }
@@ -1445,18 +1705,34 @@ var InMemoryCreditRepository = class {
1445
1705
  { monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
1446
1706
  { userId, operation: "updateUserTier" }
1447
1707
  );
1708
+ assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
1448
1709
  const credits = this.store.users.get(userId);
1449
1710
  if (!credits) {
1450
1711
  throw new Error(`User ${userId} not found`);
1451
1712
  }
1713
+ if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1714
+ const capped = planBalanceCap(
1715
+ credits.balance,
1716
+ input.balanceCap,
1717
+ credits.reserved,
1718
+ credits.bonusCredits,
1719
+ credits.expiredHoldBacking
1720
+ );
1721
+ credits.balance = capped.balance;
1722
+ writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
1723
+ }
1452
1724
  credits.tier = input.tier;
1453
1725
  credits.monthlyLimit = input.monthlyLimit;
1454
1726
  if (input.balance !== void 0) {
1455
- credits.balance = Math.max(
1456
- input.balance,
1457
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
1458
- );
1459
- }
1727
+ const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1728
+ credits.balance = planned.balance;
1729
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1730
+ }
1731
+ writeExpiredHoldBacking(
1732
+ this.store,
1733
+ credits,
1734
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1735
+ );
1460
1736
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1461
1737
  if (input.subscriptionExpiresAt !== void 0) {
1462
1738
  if (input.subscriptionExpiresAt === null) {
@@ -1618,6 +1894,11 @@ var InMemoryCreditRepository = class {
1618
1894
  { userId, operation: "addCredits" }
1619
1895
  );
1620
1896
  credits.bonusCredits = nextBonusCredits;
1897
+ writeExpiredHoldBacking(
1898
+ this.store,
1899
+ credits,
1900
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1901
+ );
1621
1902
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1622
1903
  this.store.users.set(userId, credits);
1623
1904
  const transaction = this.recordTransaction({
@@ -1637,6 +1918,7 @@ var InMemoryCreditRepository = class {
1637
1918
  userId,
1638
1919
  entryType: "credit",
1639
1920
  amount,
1921
+ balanceBefore: previousTotal,
1640
1922
  balanceAfter: newTotal,
1641
1923
  source: payload.source,
1642
1924
  referenceId: transaction.id ?? paymentRef ?? "unknown",
@@ -1670,19 +1952,23 @@ var InMemoryCreditRepository = class {
1670
1952
  });
1671
1953
  }
1672
1954
  }
1673
- async deductCreditsAtomic(userId, amount) {
1955
+ async deductCreditsAtomic(userId, amount, journal) {
1674
1956
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
1675
1957
  const credits = this.store.users.get(userId);
1676
1958
  if (!credits) {
1677
1959
  throw new Error(`User credits not found for userId: ${userId}`);
1678
1960
  }
1679
- const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
1961
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
1962
+ const available = Math.min(
1963
+ sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
1964
+ sumAmounts(spendable, credits.bonusCredits)
1965
+ );
1680
1966
  if (available < amount) {
1681
1967
  throw new Error(
1682
1968
  `Insufficient credits. Available: ${available}, requested: ${amount}`
1683
1969
  );
1684
1970
  }
1685
- const balanceDeduction = Math.min(credits.balance, amount);
1971
+ const balanceDeduction = Math.min(spendable, amount);
1686
1972
  const bonusDeduction = sumAmounts(amount, -balanceDeduction);
1687
1973
  const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
1688
1974
  const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
@@ -1692,11 +1978,26 @@ var InMemoryCreditRepository = class {
1692
1978
  { previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
1693
1979
  { userId, operation: "deductCredits" }
1694
1980
  );
1981
+ const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1695
1982
  credits.balance = nextBalance;
1696
1983
  credits.bonusCredits = nextBonusCredits;
1984
+ writeExpiredHoldBacking(this.store, credits, held);
1697
1985
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1698
1986
  this.store.users.set(userId, credits);
1699
- return { previousBalance, newBalance };
1987
+ if (!journal) return { previousBalance, newBalance };
1988
+ const entry = this.recordJournalEntry({
1989
+ userId,
1990
+ entryType: "debit",
1991
+ amount,
1992
+ balanceBefore: previousBalance,
1993
+ balanceAfter: newBalance,
1994
+ source: journal.source ?? "operation_commit",
1995
+ referenceId: journal.referenceId,
1996
+ referenceType: journal.referenceType ?? "adjustment",
1997
+ description: journal.description,
1998
+ metadata: journal.metadata
1999
+ });
2000
+ return { previousBalance, newBalance, journalEntryId: entry.id };
1700
2001
  }
1701
2002
  // ==================== Transactions ====================
1702
2003
  async createTransaction(input) {
@@ -1810,6 +2111,11 @@ var InMemoryCreditRepository = class {
1810
2111
  assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
1811
2112
  userId: input.userId
1812
2113
  });
2114
+ if (input.balanceBefore !== void 0) {
2115
+ assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
2116
+ userId: input.userId
2117
+ });
2118
+ }
1813
2119
  assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
1814
2120
  assertPublicJournalKey(input.idempotencyKey, input.userId);
1815
2121
  const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
@@ -1825,6 +2131,7 @@ var InMemoryCreditRepository = class {
1825
2131
  userId: input.userId,
1826
2132
  entryType: input.entryType,
1827
2133
  amount: input.amount,
2134
+ ...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
1828
2135
  balanceAfter: input.balanceAfter,
1829
2136
  source: input.source,
1830
2137
  referenceId: input.referenceId,
@@ -1919,7 +2226,11 @@ var InMemoryCreditRepository = class {
1919
2226
  return { expiredCount, creditsReleased, errors };
1920
2227
  }
1921
2228
  // ==================== Atomic Monthly Reset ====================
1922
- async atomicMonthlyReset(userId, tier, expectedResetAt) {
2229
+ /**
2230
+ * @param _tier - Not trusted. The caller read it earlier and the account may
2231
+ * have changed tier since, so the reset uses the tier stored on the account.
2232
+ */
2233
+ async atomicMonthlyReset(userId, _tier, expectedResetAt) {
1923
2234
  const credits = this.store.users.get(userId);
1924
2235
  if (!credits) {
1925
2236
  throw new Error(`User ${userId} not found`);
@@ -1929,17 +2240,41 @@ var InMemoryCreditRepository = class {
1929
2240
  if (currentResetAt !== expected) {
1930
2241
  return { wasReset: false, credits: { ...credits } };
1931
2242
  }
1932
- assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2243
+ const tier = credits.tier;
2244
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
2245
+ const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
2246
+ if (decision === null) {
2247
+ return { wasReset: false, credits: { ...credits } };
2248
+ }
2249
+ if (decision) {
2250
+ assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
2251
+ if (decision.monthlyLimit !== void 0) {
2252
+ assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
2253
+ userId,
2254
+ tier
2255
+ });
2256
+ }
2257
+ } else {
2258
+ assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2259
+ }
1933
2260
  const nextReset = getNextMonthlyReset();
1934
- const target = monthlyResetBalance(tier);
1935
- const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
1936
2261
  const previousBalance = credits.balance;
1937
- const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
2262
+ let target;
2263
+ if (decision) {
2264
+ target = decision.balance;
2265
+ } else {
2266
+ const configured = monthlyResetBalance(tier);
2267
+ target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
2268
+ }
2269
+ const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
2270
+ const newBalance = planned.balance;
1938
2271
  const change = sumAmounts(newBalance, -previousBalance);
1939
2272
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
1940
2273
  assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
1941
2274
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
1942
2275
  credits.balance = newBalance;
2276
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2277
+ if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
1943
2278
  credits.monthlyUsed = 0;
1944
2279
  credits.monthlyResetAt = nextReset.toISOString();
1945
2280
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -1949,6 +2284,7 @@ var InMemoryCreditRepository = class {
1949
2284
  userId,
1950
2285
  entryType: change > 0 ? "credit" : "debit",
1951
2286
  amount: Math.abs(change),
2287
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
1952
2288
  balanceAfter,
1953
2289
  source: "monthly_reset",
1954
2290
  referenceId: `reset-${Date.now()}`,
@@ -1993,23 +2329,34 @@ var InMemoryCreditRepository = class {
1993
2329
  };
1994
2330
  }
1995
2331
  const defaultTier = getDefaultTier();
1996
- const configured = getConfigMonthlyLimit(defaultTier);
1997
- assertRepresentableTierAmount(configured, "monthlyLimit", {
1998
- userId,
1999
- tier: defaultTier
2000
- });
2001
- const limit = storedMonthlyLimit(configured);
2332
+ let limit;
2333
+ if (this.options.getDowngradeBalanceCap) {
2334
+ limit = this.options.getDowngradeBalanceCap();
2335
+ assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
2336
+ } else {
2337
+ const configured = getConfigMonthlyLimit(defaultTier);
2338
+ assertRepresentableTierAmount(configured, "monthlyLimit", {
2339
+ userId,
2340
+ tier: defaultTier
2341
+ });
2342
+ limit = storedMonthlyLimit(configured);
2343
+ }
2002
2344
  const previousTier = credits.tier;
2003
2345
  const previousBalance = credits.balance;
2004
- const newBalance = Math.max(
2005
- Math.min(credits.balance, limit),
2006
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
2346
+ const planned = planBalanceCap(
2347
+ credits.balance,
2348
+ limit,
2349
+ credits.reserved,
2350
+ credits.bonusCredits,
2351
+ credits.expiredHoldBacking
2007
2352
  );
2353
+ const newBalance = planned.balance;
2008
2354
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2009
2355
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2010
2356
  credits.tier = defaultTier;
2011
2357
  credits.monthlyLimit = limit;
2012
2358
  credits.balance = newBalance;
2359
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2013
2360
  credits.subscriptionExpiresAt = null;
2014
2361
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2015
2362
  this.store.users.set(userId, credits);
@@ -2018,6 +2365,7 @@ var InMemoryCreditRepository = class {
2018
2365
  entryType: "debit",
2019
2366
  amount: 0,
2020
2367
  // No credits deducted, just tier change
2368
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2021
2369
  balanceAfter,
2022
2370
  source: "subscription_downgrade",
2023
2371
  referenceId: `downgrade-${Date.now()}`,
@@ -2067,13 +2415,14 @@ var InMemoryCreditRepository = class {
2067
2415
  return copyRecords(Array.from(userReservations.values()));
2068
2416
  }
2069
2417
  };
2070
- function createInMemoryCreditRepository() {
2071
- return new InMemoryCreditRepository();
2418
+ function createInMemoryCreditRepository(options = {}) {
2419
+ return new InMemoryCreditRepository(options);
2072
2420
  }
2073
2421
  // Annotate the CommonJS export names for ESM import in node:
2074
2422
  0 && (module.exports = {
2075
2423
  InMemoryCreditRepository,
2076
2424
  addCreditsThroughRepository,
2425
+ assertValidBalanceUpdate,
2077
2426
  commitThroughRepository,
2078
2427
  createInMemoryCreditRepository,
2079
2428
  generateId,
@@ -2084,6 +2433,7 @@ function createInMemoryCreditRepository() {
2084
2433
  supportsCreditsV2,
2085
2434
  supportsIdempotentCredit,
2086
2435
  toClientUserCredits,
2087
- toDate
2436
+ toDate,
2437
+ validateBalanceUpdate
2088
2438
  });
2089
2439
  //# sourceMappingURL=index.cjs.map