@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
@@ -167,6 +167,40 @@ function floatSum(values) {
167
167
  for (const value of values) total += value;
168
168
  return total;
169
169
  }
170
+ function multiplyAmount(unit, quantity) {
171
+ const cents = toCents(unit);
172
+ const product = cents === null ? Number.NaN : cents * quantity;
173
+ if (cents === null || cents < 0 || !Number.isSafeInteger(quantity) || quantity < 0 || !Number.isSafeInteger(product) || product > MAX_CENTS) {
174
+ throw new CreditError(
175
+ `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}`,
176
+ CreditErrorCode.INVALID_AMOUNT,
177
+ { unit, quantity, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE }
178
+ );
179
+ }
180
+ return product / 100;
181
+ }
182
+ var FLOAT_NOISE_FLOOR = 1e-6;
183
+ var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
184
+ function floatNoise(value) {
185
+ return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
186
+ }
187
+ function canonicalAmount(value) {
188
+ if (typeof value !== "number" || !Number.isFinite(value)) return value;
189
+ const cents = Math.round(value * 100);
190
+ if (!Number.isSafeInteger(cents)) return value;
191
+ const onGrid = cents / 100 + 0;
192
+ return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
193
+ }
194
+ function storedAmountFloor(value) {
195
+ const canonical = canonicalAmount(value);
196
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
197
+ return Math.floor(canonical * 100) / 100;
198
+ }
199
+ function storedAmountCeil(value) {
200
+ const canonical = canonicalAmount(value);
201
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
202
+ return Math.ceil(canonical * 100) / 100;
203
+ }
170
204
  function isValidCreditAmount(value) {
171
205
  if (typeof value !== "number") return false;
172
206
  const cents = toCents(value);
@@ -219,6 +253,150 @@ function backedBalanceFloor(reserved, bonusCredits) {
219
253
  assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
220
254
  return floor;
221
255
  }
256
+ function cappedBalance(balance, cap, reserved, bonusCredits) {
257
+ return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
258
+ }
259
+ function heldExpiredBacking(balance, stored) {
260
+ if (!(stored !== void 0 && stored > 0)) return 0;
261
+ return Math.max(0, Math.min(stored, balance));
262
+ }
263
+ function spendableBalance(balance, expiredHoldBacking) {
264
+ return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
265
+ }
266
+ function planBalanceTarget(target, reserved, bonusCredits) {
267
+ assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
268
+ const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
269
+ return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
270
+ }
271
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
272
+ const spendable = spendableBalance(balance, expiredHoldBacking);
273
+ const target = Math.min(spendable, cap);
274
+ const kept = Math.max(
275
+ sumAmounts(balance, -spendable),
276
+ sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
277
+ );
278
+ const next = Math.min(balance, sumAmounts(target, kept));
279
+ return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
280
+ }
281
+ function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
282
+ const held = heldExpiredBacking(balance, expiredHoldBacking);
283
+ const used = Math.max(0, Math.min(held, holdBacking));
284
+ return { used, remaining: sumAmounts(held, -used) };
285
+ }
286
+ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
287
+ const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
288
+ const rest = sumAmounts(amount, -backing.used);
289
+ const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
290
+ const fromBonus = sumAmounts(rest, -fromSpendable);
291
+ return {
292
+ covered: fromBonus <= bonusCredits,
293
+ balance: sumAmounts(balance, -backing.used, -fromSpendable),
294
+ bonusCredits: sumAmounts(bonusCredits, -fromBonus),
295
+ expiredHoldBacking: backing.remaining
296
+ };
297
+ }
298
+ var NO_EXPIRED_BACKING_PREFIX = {
299
+ holdPlacedAt: "",
300
+ id: "",
301
+ share: 0
302
+ };
303
+ function compareHolds(a, b) {
304
+ if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
305
+ if (a.id === b.id) return 0;
306
+ return a.id < b.id ? -1 : 1;
307
+ }
308
+ function recordedExpiredBacking(hold) {
309
+ const stored = hold.expiredBacking;
310
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
311
+ if (!isValidCreditAmount(hold.amount)) return 0;
312
+ return Math.min(storedAmountCeil(stored), hold.amount);
313
+ }
314
+ function allocateExpiredBacking(holds, total) {
315
+ const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
316
+ const wanted = Math.max(0, total);
317
+ if (wanted === 0) return { total: 0, frontier: null };
318
+ const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
319
+ const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
320
+ let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
321
+ for (const hold of [...recorded].reverse()) {
322
+ if (left >= 0) break;
323
+ const share = shares.get(hold.id);
324
+ const cut = Math.min(share, -left);
325
+ shares.set(hold.id, sumAmounts(share, -cut));
326
+ left = sumAmounts(left, cut);
327
+ }
328
+ let frontier = NO_EXPIRED_BACKING_PREFIX;
329
+ for (const hold of placed) {
330
+ if (left <= 0) break;
331
+ if (shares.has(hold.id)) continue;
332
+ const share = Math.min(hold.amount, left);
333
+ frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
334
+ left = sumAmounts(left, -share);
335
+ }
336
+ for (const hold of recorded) {
337
+ if (left <= 0) break;
338
+ const share = shares.get(hold.id);
339
+ const added = Math.min(sumAmounts(hold.amount, -share), left);
340
+ shares.set(hold.id, sumAmounts(share, added));
341
+ left = sumAmounts(left, -added);
342
+ }
343
+ const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
344
+ return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
345
+ }
346
+ function holdExpiredBacking(hold, frontier, rewrites) {
347
+ const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
348
+ const recorded = recordedExpiredBacking(
349
+ rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
350
+ );
351
+ if (recorded > 0) return recorded;
352
+ if (!frontier || !hold.holdPlacedAt) return 0;
353
+ const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
354
+ if (order > 0) return 0;
355
+ return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
356
+ }
357
+ function holdPlacedAtAfter(now, frontier) {
358
+ if (!frontier?.holdPlacedAt) return now;
359
+ const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
360
+ return now > earliest ? now : earliest;
361
+ }
362
+ function expiredBackingPatch(next, stored) {
363
+ const total = Math.max(0, next.total);
364
+ const patch = {};
365
+ const storedTotal = stored.expiredHoldBacking;
366
+ if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
367
+ patch.expiredHoldBacking = total;
368
+ }
369
+ if (total <= 0) {
370
+ if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
371
+ } else if (next.frontier) {
372
+ patch.expiredBackingFrontier = next.frontier;
373
+ }
374
+ return patch;
375
+ }
376
+ function assertValidBalanceTarget(value, field, context) {
377
+ assertRepresentableAmount(value, field, context);
378
+ if (value < 0) {
379
+ throw new CreditError(
380
+ `${field} cannot be negative (got ${value})`,
381
+ CreditErrorCode.INVALID_AMOUNT,
382
+ { field, value, ...context }
383
+ );
384
+ }
385
+ }
386
+ function assertValidTierBalanceInput(input, context) {
387
+ if (input.balanceCap === void 0) {
388
+ if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
389
+ return;
390
+ }
391
+ if (input.balance !== void 0) {
392
+ throw new CreditError(
393
+ "A tier update cannot set both balance and balanceCap",
394
+ CreditErrorCode.INVALID_AMOUNT,
395
+ { balance: input.balance, balanceCap: input.balanceCap, ...context }
396
+ );
397
+ }
398
+ assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
399
+ }
222
400
  function assertRepresentableTierAmount(value, field, context) {
223
401
  if (value === Number.POSITIVE_INFINITY) return;
224
402
  assertRepresentableAmount(value, field, context);
@@ -621,9 +799,9 @@ var creditSystemConfigSchema = z.object({
621
799
  */
622
800
  reservationExpiryMs: z.number().positive().default(5 * 60 * 1e3),
623
801
  /**
624
- * Default credits for new users (free tier)
802
+ * Default credits for new users (free tier). 0 means no signup grant.
625
803
  */
626
- defaultFreeCredits: z.number().positive().default(25),
804
+ defaultFreeCredits: z.number().min(0).default(25),
627
805
  /**
628
806
  * Grace period in days after subscription expires before downgrade
629
807
  */
@@ -994,9 +1172,11 @@ export {
994
1172
  CREDIT_AMOUNT_SCALE,
995
1173
  CreditError,
996
1174
  CreditErrorCode,
1175
+ NO_EXPIRED_BACKING_PREFIX,
997
1176
  RESERVATION_STATUSES,
998
1177
  RESERVED_JOURNAL_KEY_PREFIX,
999
1178
  TERMINAL_RESERVATION_STATUSES,
1179
+ allocateExpiredBacking,
1000
1180
  assertDirectStatusWriteAllowed,
1001
1181
  assertHoldPlaced,
1002
1182
  assertKnownReservationStatus,
@@ -1006,12 +1186,16 @@ export {
1006
1186
  assertRepresentableTierAmount,
1007
1187
  assertUnkeyedDirectReservation,
1008
1188
  assertUnreferencedDirectTransaction,
1189
+ assertValidBalanceTarget,
1009
1190
  assertValidCreditAmount,
1010
1191
  assertValidIdempotencyKey,
1011
1192
  assertValidStoredAmount,
1012
1193
  assertValidStoredAmountRaw,
1194
+ assertValidTierBalanceInput,
1013
1195
  backedBalanceFloor,
1014
1196
  calculateAvailableCredits,
1197
+ canonicalAmount,
1198
+ cappedBalance,
1015
1199
  classifyDatabaseError,
1016
1200
  commitReservationWithJournal,
1017
1201
  createDeferredExecutor,
@@ -1027,9 +1211,13 @@ export {
1027
1211
  createUnsupportedOperationError,
1028
1212
  createUserNotFoundError,
1029
1213
  describePaymentMismatch,
1214
+ expiredBackingPatch,
1030
1215
  genericDeferred,
1031
1216
  getSqlState,
1032
1217
  hasPlacedHold,
1218
+ heldExpiredBacking,
1219
+ holdExpiredBacking,
1220
+ holdPlacedAtAfter,
1033
1221
  isCreditError,
1034
1222
  isCreditedOutcome,
1035
1223
  isIdempotencyConflictError,
@@ -1045,12 +1233,21 @@ export {
1045
1233
  isValidCreditAmount,
1046
1234
  isValidIdempotencyKey,
1047
1235
  isWinningOutcome,
1236
+ multiplyAmount,
1048
1237
  noopDeferred,
1049
1238
  normalizePaymentRef,
1050
1239
  numericToCents,
1240
+ planBalanceCap,
1241
+ planBalanceTarget,
1242
+ planHoldCommit,
1243
+ recordedExpiredBacking,
1051
1244
  releaseReservationWithJournal,
1052
1245
  reserveCreditsForOperation,
1053
1246
  sameAmount,
1247
+ settleExpiredHoldBacking,
1248
+ spendableBalance,
1249
+ storedAmountCeil,
1250
+ storedAmountFloor,
1054
1251
  storedMonthlyLimit,
1055
1252
  sumAmounts,
1056
1253
  synchronousDeferred,