@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
@@ -16,6 +16,8 @@ function toClientUserCredits(credits) {
16
16
  monthlyUsed: credits.monthlyUsed,
17
17
  monthlyResetAt: toISOString(credits.monthlyResetAt),
18
18
  subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
19
+ ...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
20
+ ...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
19
21
  createdAt: toISOString(credits.createdAt),
20
22
  updatedAt: toISOString(credits.updatedAt)
21
23
  };
@@ -54,6 +56,23 @@ function floatSum(values) {
54
56
  for (const value of values) total += value;
55
57
  return total;
56
58
  }
59
+ var FLOAT_NOISE_FLOOR = 1e-6;
60
+ var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
61
+ function floatNoise(value) {
62
+ return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
63
+ }
64
+ function canonicalAmount(value) {
65
+ if (typeof value !== "number" || !Number.isFinite(value)) return value;
66
+ const cents = Math.round(value * 100);
67
+ if (!Number.isSafeInteger(cents)) return value;
68
+ const onGrid = cents / 100 + 0;
69
+ return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
70
+ }
71
+ function storedAmountCeil(value) {
72
+ const canonical = canonicalAmount(value);
73
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
74
+ return Math.ceil(canonical * 100) / 100;
75
+ }
57
76
  function isValidCreditAmount(value) {
58
77
  if (typeof value !== "number") return false;
59
78
  const cents = toCents(value);
@@ -106,6 +125,147 @@ function backedBalanceFloor(reserved, bonusCredits) {
106
125
  assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
107
126
  return floor;
108
127
  }
128
+ function heldExpiredBacking(balance, stored) {
129
+ if (!(stored !== void 0 && stored > 0)) return 0;
130
+ return Math.max(0, Math.min(stored, balance));
131
+ }
132
+ function spendableBalance(balance, expiredHoldBacking) {
133
+ return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
134
+ }
135
+ function planBalanceTarget(target, reserved, bonusCredits) {
136
+ assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
137
+ const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
138
+ return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
139
+ }
140
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
141
+ const spendable = spendableBalance(balance, expiredHoldBacking);
142
+ const target = Math.min(spendable, cap);
143
+ const kept = Math.max(
144
+ sumAmounts(balance, -spendable),
145
+ sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
146
+ );
147
+ const next = Math.min(balance, sumAmounts(target, kept));
148
+ return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
149
+ }
150
+ function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
151
+ const held = heldExpiredBacking(balance, expiredHoldBacking);
152
+ const used = Math.max(0, Math.min(held, holdBacking));
153
+ return { used, remaining: sumAmounts(held, -used) };
154
+ }
155
+ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
156
+ const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
157
+ const rest = sumAmounts(amount, -backing.used);
158
+ const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
159
+ const fromBonus = sumAmounts(rest, -fromSpendable);
160
+ return {
161
+ covered: fromBonus <= bonusCredits,
162
+ balance: sumAmounts(balance, -backing.used, -fromSpendable),
163
+ bonusCredits: sumAmounts(bonusCredits, -fromBonus),
164
+ expiredHoldBacking: backing.remaining
165
+ };
166
+ }
167
+ var NO_EXPIRED_BACKING_PREFIX = {
168
+ holdPlacedAt: "",
169
+ id: "",
170
+ share: 0
171
+ };
172
+ function compareHolds(a, b) {
173
+ if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
174
+ if (a.id === b.id) return 0;
175
+ return a.id < b.id ? -1 : 1;
176
+ }
177
+ function recordedExpiredBacking(hold) {
178
+ const stored = hold.expiredBacking;
179
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
180
+ if (!isValidCreditAmount(hold.amount)) return 0;
181
+ return Math.min(storedAmountCeil(stored), hold.amount);
182
+ }
183
+ function allocateExpiredBacking(holds, total) {
184
+ const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
185
+ const wanted = Math.max(0, total);
186
+ if (wanted === 0) return { total: 0, frontier: null };
187
+ const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
188
+ const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
189
+ let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
190
+ for (const hold of [...recorded].reverse()) {
191
+ if (left >= 0) break;
192
+ const share = shares.get(hold.id);
193
+ const cut = Math.min(share, -left);
194
+ shares.set(hold.id, sumAmounts(share, -cut));
195
+ left = sumAmounts(left, cut);
196
+ }
197
+ let frontier = NO_EXPIRED_BACKING_PREFIX;
198
+ for (const hold of placed) {
199
+ if (left <= 0) break;
200
+ if (shares.has(hold.id)) continue;
201
+ const share = Math.min(hold.amount, left);
202
+ frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
203
+ left = sumAmounts(left, -share);
204
+ }
205
+ for (const hold of recorded) {
206
+ if (left <= 0) break;
207
+ const share = shares.get(hold.id);
208
+ const added = Math.min(sumAmounts(hold.amount, -share), left);
209
+ shares.set(hold.id, sumAmounts(share, added));
210
+ left = sumAmounts(left, -added);
211
+ }
212
+ const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
213
+ return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
214
+ }
215
+ function holdExpiredBacking(hold, frontier, rewrites) {
216
+ const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
217
+ const recorded = recordedExpiredBacking(
218
+ rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
219
+ );
220
+ if (recorded > 0) return recorded;
221
+ if (!frontier || !hold.holdPlacedAt) return 0;
222
+ const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
223
+ if (order > 0) return 0;
224
+ return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
225
+ }
226
+ function holdPlacedAtAfter(now, frontier) {
227
+ if (!frontier?.holdPlacedAt) return now;
228
+ const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
229
+ return now > earliest ? now : earliest;
230
+ }
231
+ function expiredBackingPatch(next, stored) {
232
+ const total = Math.max(0, next.total);
233
+ const patch = {};
234
+ const storedTotal = stored.expiredHoldBacking;
235
+ if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
236
+ patch.expiredHoldBacking = total;
237
+ }
238
+ if (total <= 0) {
239
+ if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
240
+ } else if (next.frontier) {
241
+ patch.expiredBackingFrontier = next.frontier;
242
+ }
243
+ return patch;
244
+ }
245
+ function assertValidBalanceTarget(value, field, context) {
246
+ assertRepresentableAmount(value, field, context);
247
+ if (value < 0) {
248
+ throw new CreditError(
249
+ `${field} cannot be negative (got ${value})`,
250
+ CreditErrorCode.INVALID_AMOUNT,
251
+ { field, value, ...context }
252
+ );
253
+ }
254
+ }
255
+ function assertValidTierBalanceInput(input, context) {
256
+ if (input.balanceCap === void 0) {
257
+ if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
258
+ return;
259
+ }
260
+ if (input.balance !== void 0) {
261
+ throw new CreditError(
262
+ "A tier update cannot set both balance and balanceCap",
263
+ CreditErrorCode.INVALID_AMOUNT,
264
+ { balance: input.balance, balanceCap: input.balanceCap, ...context }
265
+ );
266
+ }
267
+ assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
268
+ }
109
269
  function assertRepresentableTierAmount(value, field, context) {
110
270
  if (value === Number.POSITIVE_INFINITY) return;
111
271
  assertRepresentableAmount(value, field, context);
@@ -323,9 +483,9 @@ var creditSystemConfigSchema = z.object({
323
483
  */
324
484
  reservationExpiryMs: z.number().positive().default(5 * 60 * 1e3),
325
485
  /**
326
- * Default credits for new users (free tier)
486
+ * Default credits for new users (free tier). 0 means no signup grant.
327
487
  */
328
- defaultFreeCredits: z.number().positive().default(25),
488
+ defaultFreeCredits: z.number().min(0).default(25),
329
489
  /**
330
490
  * Grace period in days after subscription expires before downgrade
331
491
  */
@@ -755,11 +915,36 @@ function toDate(value) {
755
915
  return /* @__PURE__ */ new Date();
756
916
  }
757
917
  function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
758
- const next = new Date(from);
759
- next.setMonth(next.getMonth() + 1);
760
- next.setDate(1);
761
- next.setHours(0, 0, 0, 0);
762
- return next;
918
+ return new Date(from.getFullYear(), from.getMonth() + 1, 1);
919
+ }
920
+ function validateBalanceUpdate(current, updates) {
921
+ const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
922
+ const balance = next(current.balance, updates.balance, updates.balanceIncrement);
923
+ const bonusCredits = next(
924
+ current.bonusCredits ?? 0,
925
+ updates.bonusCredits,
926
+ updates.bonusCreditsIncrement
927
+ );
928
+ const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
929
+ const errors = [];
930
+ if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
931
+ const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
932
+ if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
933
+ errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
934
+ }
935
+ if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
936
+ if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
937
+ const covering = sumAmounts(balance, bonusCredits);
938
+ if (reserved > covering) {
939
+ errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
940
+ }
941
+ return { valid: errors.length === 0, errors };
942
+ }
943
+ function assertValidBalanceUpdate(current, updates) {
944
+ const result = validateBalanceUpdate(current, updates);
945
+ if (!result.valid) {
946
+ throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
947
+ }
763
948
  }
764
949
 
765
950
  // src/repository/memory/snapshot.ts
@@ -996,6 +1181,7 @@ function commitTransitionJournal(store, input, existingId) {
996
1181
  userId: input.userId,
997
1182
  entryType: input.entryType,
998
1183
  amount: input.amount,
1184
+ balanceBefore: input.balanceBefore,
999
1185
  balanceAfter: input.balanceAfter,
1000
1186
  source: input.source,
1001
1187
  referenceId: input.reservationId,
@@ -1040,6 +1226,34 @@ function assertTrustworthy(reservation, transition) {
1040
1226
  assertLockedAmount(reservation, transition);
1041
1227
  assertHoldPlaced(reservation, transition);
1042
1228
  }
1229
+ function openHolds(store, userId) {
1230
+ return [...store.reservations.get(userId)?.values() ?? []].filter(
1231
+ (reservation) => reservation.status === "reserved"
1232
+ );
1233
+ }
1234
+ function expiredBackingInForce(store, credits) {
1235
+ const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1236
+ if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
1237
+ return allocateExpiredBacking(openHolds(store, credits.userId), total);
1238
+ }
1239
+ return { total, frontier: credits.expiredBackingFrontier ?? null };
1240
+ }
1241
+ function writeRecordedShares(store, userId, allocation) {
1242
+ for (const { id, expiredBacking } of allocation.rewrites ?? []) {
1243
+ const hold = getReservation(store, userId, id);
1244
+ if (hold) hold.expiredBacking = expiredBacking;
1245
+ }
1246
+ }
1247
+ function writeExpiredHoldBacking(store, credits, next) {
1248
+ if (next <= 0) {
1249
+ Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
1250
+ return;
1251
+ }
1252
+ if (next === credits.expiredHoldBacking) return;
1253
+ const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
1254
+ writeRecordedShares(store, credits.userId, allocation);
1255
+ Object.assign(credits, expiredBackingPatch(allocation, credits));
1256
+ }
1043
1257
  function getReservation(store, userId, reservationId) {
1044
1258
  return store.reservations.get(userId)?.get(reservationId);
1045
1259
  }
@@ -1087,6 +1301,7 @@ async function reserveCreditsV2(store, input) {
1087
1301
  };
1088
1302
  }
1089
1303
  const now = (/* @__PURE__ */ new Date()).toISOString();
1304
+ const backing = expiredBackingInForce(store, credits);
1090
1305
  const reservation = {
1091
1306
  id: generateId(),
1092
1307
  userId: input.userId,
@@ -1099,7 +1314,7 @@ async function reserveCreditsV2(store, input) {
1099
1314
  // The hold-origin fact. Written here and nowhere else, in the same
1100
1315
  // critical section that raises `reserved` below, so the row and the hold
1101
1316
  // become visible together. `createReservation` cannot set it.
1102
- holdPlacedAt: now
1317
+ holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
1103
1318
  };
1104
1319
  if (!store.reservations.has(input.userId)) {
1105
1320
  store.reservations.set(input.userId, /* @__PURE__ */ new Map());
@@ -1117,6 +1332,8 @@ async function reserveCreditsV2(store, input) {
1117
1332
  );
1118
1333
  }
1119
1334
  credits.reserved = nextReserved;
1335
+ writeRecordedShares(store, input.userId, backing);
1336
+ Object.assign(credits, expiredBackingPatch(backing, credits));
1120
1337
  credits.updatedAt = now;
1121
1338
  return { outcome: "created", reservation: copyRecord(reservation) };
1122
1339
  });
@@ -1137,22 +1354,30 @@ async function commitReservationV2(store, userId, reservationId, options) {
1137
1354
  const amount = reservation.amount;
1138
1355
  await yieldPoint(store);
1139
1356
  assertReservedCoversHold(credits, userId, reservationId, amount);
1140
- if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
1357
+ const backing = expiredBackingInForce(store, credits);
1358
+ const paid = planHoldCommit(
1359
+ credits.balance,
1360
+ credits.bonusCredits,
1361
+ backing.total,
1362
+ holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
1363
+ amount
1364
+ );
1365
+ if (!paid.covered) {
1141
1366
  throw new CreditError(
1142
1367
  `Insufficient credits to commit reservation ${reservationId}`,
1143
1368
  CreditErrorCode.INSUFFICIENT_CREDITS,
1144
1369
  { userId, reservationId, required: amount }
1145
1370
  );
1146
1371
  }
1147
- const fromBalance = Math.min(credits.balance, amount);
1148
- const nextBalance = sumAmounts(credits.balance, -fromBalance);
1149
- const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
1372
+ const nextBalance = paid.balance;
1373
+ const nextBonusCredits = paid.bonusCredits;
1150
1374
  const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
1151
1375
  const journal = {
1152
1376
  userId,
1153
1377
  operation: "commitReservation",
1154
1378
  entryType: "debit",
1155
1379
  amount,
1380
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1156
1381
  balanceAfter,
1157
1382
  source: "operation_commit",
1158
1383
  reservationId,
@@ -1180,6 +1405,11 @@ async function commitReservationV2(store, userId, reservationId, options) {
1180
1405
  credits.bonusCredits = nextBonusCredits;
1181
1406
  credits.reserved = nextReserved;
1182
1407
  credits.monthlyUsed = nextMonthlyUsed;
1408
+ writeRecordedShares(store, userId, backing);
1409
+ Object.assign(
1410
+ credits,
1411
+ expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
1412
+ );
1183
1413
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1184
1414
  const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
1185
1415
  return {
@@ -1259,19 +1489,28 @@ function finishUnspent(store, reservation, transition) {
1259
1489
  reservation.id,
1260
1490
  reservation.amount
1261
1491
  );
1492
+ const inForce = expiredBackingInForce(store, credits);
1493
+ const backing = settleExpiredHoldBacking(
1494
+ credits.balance,
1495
+ inForce.total,
1496
+ holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
1497
+ );
1498
+ const nextBalance = sumAmounts(credits.balance, -backing.used);
1262
1499
  const journal = {
1263
1500
  userId: reservation.userId,
1264
1501
  operation: `${transition.transition}Reservation`,
1265
1502
  entryType: "credit",
1266
1503
  amount: 0,
1267
- balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
1504
+ balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
1505
+ balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
1268
1506
  source: transition.source,
1269
1507
  reservationId: reservation.id,
1270
1508
  description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1271
1509
  metadata: {
1272
1510
  ...transition.options?.metadata,
1273
1511
  operationType: reservation.operationType,
1274
- amount: reservation.amount
1512
+ amount: reservation.amount,
1513
+ ...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
1275
1514
  },
1276
1515
  idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
1277
1516
  };
@@ -1284,6 +1523,12 @@ function finishUnspent(store, reservation, transition) {
1284
1523
  reservation.status = transition.status;
1285
1524
  reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1286
1525
  credits.reserved = nextReserved;
1526
+ credits.balance = nextBalance;
1527
+ writeRecordedShares(store, reservation.userId, inForce);
1528
+ Object.assign(
1529
+ credits,
1530
+ expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
1531
+ );
1287
1532
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1288
1533
  return commitTransitionJournal(store, journal, existingEntryId);
1289
1534
  }
@@ -1294,6 +1539,10 @@ function isoOf(value) {
1294
1539
  }
1295
1540
  var InMemoryCreditRepository = class {
1296
1541
  store = new MemoryStore();
1542
+ options;
1543
+ constructor(options = {}) {
1544
+ this.options = options;
1545
+ }
1297
1546
  // ==================== User Credits ====================
1298
1547
  /**
1299
1548
  * A copy, never the live record.
@@ -1310,7 +1559,8 @@ var InMemoryCreditRepository = class {
1310
1559
  return copyRecord(credits) ?? null;
1311
1560
  }
1312
1561
  async initializeUserCredits(userId, tier, initialBalance) {
1313
- return copyRecord(this.seedUser(userId, tier, initialBalance));
1562
+ const existing = this.store.users.get(userId);
1563
+ return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
1314
1564
  }
1315
1565
  /**
1316
1566
  * The synchronous body of `initializeUserCredits`.
@@ -1321,7 +1571,7 @@ var InMemoryCreditRepository = class {
1321
1571
  * simultaneous first deliveries of the same reference would then both credit.
1322
1572
  */
1323
1573
  seedUser(userId, tier, initialBalance) {
1324
- assertRepresentableAmount(initialBalance, "initialBalance", { userId });
1574
+ assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
1325
1575
  const configured = getConfigMonthlyLimit(tier);
1326
1576
  assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
1327
1577
  const monthlyLimit = storedMonthlyLimit(configured);
@@ -1361,8 +1611,11 @@ var InMemoryCreditRepository = class {
1361
1611
  if (!credits) {
1362
1612
  throw new Error(`User ${userId} not found`);
1363
1613
  }
1614
+ assertValidBalanceUpdate(credits, updates);
1364
1615
  const next = { ...credits };
1365
- if (updates.balance !== void 0) next.balance = updates.balance;
1616
+ if (updates.balance !== void 0) {
1617
+ next.balance = updates.balance;
1618
+ }
1366
1619
  if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
1367
1620
  if (updates.reserved !== void 0) next.reserved = updates.reserved;
1368
1621
  if (updates.tier !== void 0) next.tier = updates.tier;
@@ -1399,6 +1652,11 @@ var InMemoryCreditRepository = class {
1399
1652
  userId,
1400
1653
  operation: "updateUserCredits"
1401
1654
  });
1655
+ writeExpiredHoldBacking(
1656
+ this.store,
1657
+ next,
1658
+ updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
1659
+ );
1402
1660
  next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1403
1661
  this.store.users.set(userId, next);
1404
1662
  }
@@ -1407,18 +1665,34 @@ var InMemoryCreditRepository = class {
1407
1665
  { monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
1408
1666
  { userId, operation: "updateUserTier" }
1409
1667
  );
1668
+ assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
1410
1669
  const credits = this.store.users.get(userId);
1411
1670
  if (!credits) {
1412
1671
  throw new Error(`User ${userId} not found`);
1413
1672
  }
1673
+ if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1674
+ const capped = planBalanceCap(
1675
+ credits.balance,
1676
+ input.balanceCap,
1677
+ credits.reserved,
1678
+ credits.bonusCredits,
1679
+ credits.expiredHoldBacking
1680
+ );
1681
+ credits.balance = capped.balance;
1682
+ writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
1683
+ }
1414
1684
  credits.tier = input.tier;
1415
1685
  credits.monthlyLimit = input.monthlyLimit;
1416
1686
  if (input.balance !== void 0) {
1417
- credits.balance = Math.max(
1418
- input.balance,
1419
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
1420
- );
1421
- }
1687
+ const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1688
+ credits.balance = planned.balance;
1689
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1690
+ }
1691
+ writeExpiredHoldBacking(
1692
+ this.store,
1693
+ credits,
1694
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1695
+ );
1422
1696
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
1423
1697
  if (input.subscriptionExpiresAt !== void 0) {
1424
1698
  if (input.subscriptionExpiresAt === null) {
@@ -1580,6 +1854,11 @@ var InMemoryCreditRepository = class {
1580
1854
  { userId, operation: "addCredits" }
1581
1855
  );
1582
1856
  credits.bonusCredits = nextBonusCredits;
1857
+ writeExpiredHoldBacking(
1858
+ this.store,
1859
+ credits,
1860
+ heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
1861
+ );
1583
1862
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1584
1863
  this.store.users.set(userId, credits);
1585
1864
  const transaction = this.recordTransaction({
@@ -1599,6 +1878,7 @@ var InMemoryCreditRepository = class {
1599
1878
  userId,
1600
1879
  entryType: "credit",
1601
1880
  amount,
1881
+ balanceBefore: previousTotal,
1602
1882
  balanceAfter: newTotal,
1603
1883
  source: payload.source,
1604
1884
  referenceId: transaction.id ?? paymentRef ?? "unknown",
@@ -1632,19 +1912,23 @@ var InMemoryCreditRepository = class {
1632
1912
  });
1633
1913
  }
1634
1914
  }
1635
- async deductCreditsAtomic(userId, amount) {
1915
+ async deductCreditsAtomic(userId, amount, journal) {
1636
1916
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
1637
1917
  const credits = this.store.users.get(userId);
1638
1918
  if (!credits) {
1639
1919
  throw new Error(`User credits not found for userId: ${userId}`);
1640
1920
  }
1641
- const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
1921
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
1922
+ const available = Math.min(
1923
+ sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
1924
+ sumAmounts(spendable, credits.bonusCredits)
1925
+ );
1642
1926
  if (available < amount) {
1643
1927
  throw new Error(
1644
1928
  `Insufficient credits. Available: ${available}, requested: ${amount}`
1645
1929
  );
1646
1930
  }
1647
- const balanceDeduction = Math.min(credits.balance, amount);
1931
+ const balanceDeduction = Math.min(spendable, amount);
1648
1932
  const bonusDeduction = sumAmounts(amount, -balanceDeduction);
1649
1933
  const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
1650
1934
  const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
@@ -1654,11 +1938,26 @@ var InMemoryCreditRepository = class {
1654
1938
  { previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
1655
1939
  { userId, operation: "deductCredits" }
1656
1940
  );
1941
+ const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
1657
1942
  credits.balance = nextBalance;
1658
1943
  credits.bonusCredits = nextBonusCredits;
1944
+ writeExpiredHoldBacking(this.store, credits, held);
1659
1945
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1660
1946
  this.store.users.set(userId, credits);
1661
- return { previousBalance, newBalance };
1947
+ if (!journal) return { previousBalance, newBalance };
1948
+ const entry = this.recordJournalEntry({
1949
+ userId,
1950
+ entryType: "debit",
1951
+ amount,
1952
+ balanceBefore: previousBalance,
1953
+ balanceAfter: newBalance,
1954
+ source: journal.source ?? "operation_commit",
1955
+ referenceId: journal.referenceId,
1956
+ referenceType: journal.referenceType ?? "adjustment",
1957
+ description: journal.description,
1958
+ metadata: journal.metadata
1959
+ });
1960
+ return { previousBalance, newBalance, journalEntryId: entry.id };
1662
1961
  }
1663
1962
  // ==================== Transactions ====================
1664
1963
  async createTransaction(input) {
@@ -1772,6 +2071,11 @@ var InMemoryCreditRepository = class {
1772
2071
  assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
1773
2072
  userId: input.userId
1774
2073
  });
2074
+ if (input.balanceBefore !== void 0) {
2075
+ assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
2076
+ userId: input.userId
2077
+ });
2078
+ }
1775
2079
  assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
1776
2080
  assertPublicJournalKey(input.idempotencyKey, input.userId);
1777
2081
  const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
@@ -1787,6 +2091,7 @@ var InMemoryCreditRepository = class {
1787
2091
  userId: input.userId,
1788
2092
  entryType: input.entryType,
1789
2093
  amount: input.amount,
2094
+ ...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
1790
2095
  balanceAfter: input.balanceAfter,
1791
2096
  source: input.source,
1792
2097
  referenceId: input.referenceId,
@@ -1881,7 +2186,11 @@ var InMemoryCreditRepository = class {
1881
2186
  return { expiredCount, creditsReleased, errors };
1882
2187
  }
1883
2188
  // ==================== Atomic Monthly Reset ====================
1884
- async atomicMonthlyReset(userId, tier, expectedResetAt) {
2189
+ /**
2190
+ * @param _tier - Not trusted. The caller read it earlier and the account may
2191
+ * have changed tier since, so the reset uses the tier stored on the account.
2192
+ */
2193
+ async atomicMonthlyReset(userId, _tier, expectedResetAt) {
1885
2194
  const credits = this.store.users.get(userId);
1886
2195
  if (!credits) {
1887
2196
  throw new Error(`User ${userId} not found`);
@@ -1891,17 +2200,41 @@ var InMemoryCreditRepository = class {
1891
2200
  if (currentResetAt !== expected) {
1892
2201
  return { wasReset: false, credits: { ...credits } };
1893
2202
  }
1894
- assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2203
+ const tier = credits.tier;
2204
+ const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
2205
+ const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
2206
+ if (decision === null) {
2207
+ return { wasReset: false, credits: { ...credits } };
2208
+ }
2209
+ if (decision) {
2210
+ assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
2211
+ if (decision.monthlyLimit !== void 0) {
2212
+ assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
2213
+ userId,
2214
+ tier
2215
+ });
2216
+ }
2217
+ } else {
2218
+ assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
2219
+ }
1895
2220
  const nextReset = getNextMonthlyReset();
1896
- const target = monthlyResetBalance(tier);
1897
- const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
1898
2221
  const previousBalance = credits.balance;
1899
- const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
2222
+ let target;
2223
+ if (decision) {
2224
+ target = decision.balance;
2225
+ } else {
2226
+ const configured = monthlyResetBalance(tier);
2227
+ target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
2228
+ }
2229
+ const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
2230
+ const newBalance = planned.balance;
1900
2231
  const change = sumAmounts(newBalance, -previousBalance);
1901
2232
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
1902
2233
  assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
1903
2234
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
1904
2235
  credits.balance = newBalance;
2236
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
2237
+ if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
1905
2238
  credits.monthlyUsed = 0;
1906
2239
  credits.monthlyResetAt = nextReset.toISOString();
1907
2240
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -1911,6 +2244,7 @@ var InMemoryCreditRepository = class {
1911
2244
  userId,
1912
2245
  entryType: change > 0 ? "credit" : "debit",
1913
2246
  amount: Math.abs(change),
2247
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
1914
2248
  balanceAfter,
1915
2249
  source: "monthly_reset",
1916
2250
  referenceId: `reset-${Date.now()}`,
@@ -1955,23 +2289,34 @@ var InMemoryCreditRepository = class {
1955
2289
  };
1956
2290
  }
1957
2291
  const defaultTier = getDefaultTier();
1958
- const configured = getConfigMonthlyLimit(defaultTier);
1959
- assertRepresentableTierAmount(configured, "monthlyLimit", {
1960
- userId,
1961
- tier: defaultTier
1962
- });
1963
- const limit = storedMonthlyLimit(configured);
2292
+ let limit;
2293
+ if (this.options.getDowngradeBalanceCap) {
2294
+ limit = this.options.getDowngradeBalanceCap();
2295
+ assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
2296
+ } else {
2297
+ const configured = getConfigMonthlyLimit(defaultTier);
2298
+ assertRepresentableTierAmount(configured, "monthlyLimit", {
2299
+ userId,
2300
+ tier: defaultTier
2301
+ });
2302
+ limit = storedMonthlyLimit(configured);
2303
+ }
1964
2304
  const previousTier = credits.tier;
1965
2305
  const previousBalance = credits.balance;
1966
- const newBalance = Math.max(
1967
- Math.min(credits.balance, limit),
1968
- backedBalanceFloor(credits.reserved, credits.bonusCredits)
2306
+ const planned = planBalanceCap(
2307
+ credits.balance,
2308
+ limit,
2309
+ credits.reserved,
2310
+ credits.bonusCredits,
2311
+ credits.expiredHoldBacking
1969
2312
  );
2313
+ const newBalance = planned.balance;
1970
2314
  const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
1971
2315
  assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
1972
2316
  credits.tier = defaultTier;
1973
2317
  credits.monthlyLimit = limit;
1974
2318
  credits.balance = newBalance;
2319
+ writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1975
2320
  credits.subscriptionExpiresAt = null;
1976
2321
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1977
2322
  this.store.users.set(userId, credits);
@@ -1980,6 +2325,7 @@ var InMemoryCreditRepository = class {
1980
2325
  entryType: "debit",
1981
2326
  amount: 0,
1982
2327
  // No credits deducted, just tier change
2328
+ balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
1983
2329
  balanceAfter,
1984
2330
  source: "subscription_downgrade",
1985
2331
  referenceId: `downgrade-${Date.now()}`,
@@ -2029,12 +2375,13 @@ var InMemoryCreditRepository = class {
2029
2375
  return copyRecords(Array.from(userReservations.values()));
2030
2376
  }
2031
2377
  };
2032
- function createInMemoryCreditRepository() {
2033
- return new InMemoryCreditRepository();
2378
+ function createInMemoryCreditRepository(options = {}) {
2379
+ return new InMemoryCreditRepository(options);
2034
2380
  }
2035
2381
  export {
2036
2382
  InMemoryCreditRepository,
2037
2383
  addCreditsThroughRepository,
2384
+ assertValidBalanceUpdate,
2038
2385
  commitThroughRepository,
2039
2386
  createInMemoryCreditRepository,
2040
2387
  generateId,
@@ -2045,6 +2392,7 @@ export {
2045
2392
  supportsCreditsV2,
2046
2393
  supportsIdempotentCredit,
2047
2394
  toClientUserCredits,
2048
- toDate
2395
+ toDate,
2396
+ validateBalanceUpdate
2049
2397
  };
2050
2398
  //# sourceMappingURL=index.js.map