@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
@@ -24,9 +24,11 @@ __export(core_exports, {
24
24
  CREDIT_AMOUNT_SCALE: () => CREDIT_AMOUNT_SCALE,
25
25
  CreditError: () => CreditError,
26
26
  CreditErrorCode: () => CreditErrorCode,
27
+ NO_EXPIRED_BACKING_PREFIX: () => NO_EXPIRED_BACKING_PREFIX,
27
28
  RESERVATION_STATUSES: () => RESERVATION_STATUSES,
28
29
  RESERVED_JOURNAL_KEY_PREFIX: () => RESERVED_JOURNAL_KEY_PREFIX,
29
30
  TERMINAL_RESERVATION_STATUSES: () => TERMINAL_RESERVATION_STATUSES,
31
+ allocateExpiredBacking: () => allocateExpiredBacking,
30
32
  assertDirectStatusWriteAllowed: () => assertDirectStatusWriteAllowed,
31
33
  assertHoldPlaced: () => assertHoldPlaced,
32
34
  assertKnownReservationStatus: () => assertKnownReservationStatus,
@@ -36,12 +38,16 @@ __export(core_exports, {
36
38
  assertRepresentableTierAmount: () => assertRepresentableTierAmount,
37
39
  assertUnkeyedDirectReservation: () => assertUnkeyedDirectReservation,
38
40
  assertUnreferencedDirectTransaction: () => assertUnreferencedDirectTransaction,
41
+ assertValidBalanceTarget: () => assertValidBalanceTarget,
39
42
  assertValidCreditAmount: () => assertValidCreditAmount,
40
43
  assertValidIdempotencyKey: () => assertValidIdempotencyKey,
41
44
  assertValidStoredAmount: () => assertValidStoredAmount,
42
45
  assertValidStoredAmountRaw: () => assertValidStoredAmountRaw,
46
+ assertValidTierBalanceInput: () => assertValidTierBalanceInput,
43
47
  backedBalanceFloor: () => backedBalanceFloor,
44
48
  calculateAvailableCredits: () => calculateAvailableCredits,
49
+ canonicalAmount: () => canonicalAmount,
50
+ cappedBalance: () => cappedBalance,
45
51
  classifyDatabaseError: () => classifyDatabaseError,
46
52
  commitReservationWithJournal: () => commitReservationWithJournal,
47
53
  createDeferredExecutor: () => createDeferredExecutor,
@@ -57,9 +63,13 @@ __export(core_exports, {
57
63
  createUnsupportedOperationError: () => createUnsupportedOperationError,
58
64
  createUserNotFoundError: () => createUserNotFoundError,
59
65
  describePaymentMismatch: () => describePaymentMismatch,
66
+ expiredBackingPatch: () => expiredBackingPatch,
60
67
  genericDeferred: () => genericDeferred,
61
68
  getSqlState: () => getSqlState,
62
69
  hasPlacedHold: () => hasPlacedHold,
70
+ heldExpiredBacking: () => heldExpiredBacking,
71
+ holdExpiredBacking: () => holdExpiredBacking,
72
+ holdPlacedAtAfter: () => holdPlacedAtAfter,
63
73
  isCreditError: () => isCreditError,
64
74
  isCreditedOutcome: () => isCreditedOutcome,
65
75
  isIdempotencyConflictError: () => isIdempotencyConflictError,
@@ -75,12 +85,21 @@ __export(core_exports, {
75
85
  isValidCreditAmount: () => isValidCreditAmount,
76
86
  isValidIdempotencyKey: () => isValidIdempotencyKey,
77
87
  isWinningOutcome: () => isWinningOutcome,
88
+ multiplyAmount: () => multiplyAmount,
78
89
  noopDeferred: () => noopDeferred,
79
90
  normalizePaymentRef: () => normalizePaymentRef,
80
91
  numericToCents: () => numericToCents,
92
+ planBalanceCap: () => planBalanceCap,
93
+ planBalanceTarget: () => planBalanceTarget,
94
+ planHoldCommit: () => planHoldCommit,
95
+ recordedExpiredBacking: () => recordedExpiredBacking,
81
96
  releaseReservationWithJournal: () => releaseReservationWithJournal,
82
97
  reserveCreditsForOperation: () => reserveCreditsForOperation,
83
98
  sameAmount: () => sameAmount,
99
+ settleExpiredHoldBacking: () => settleExpiredHoldBacking,
100
+ spendableBalance: () => spendableBalance,
101
+ storedAmountCeil: () => storedAmountCeil,
102
+ storedAmountFloor: () => storedAmountFloor,
84
103
  storedMonthlyLimit: () => storedMonthlyLimit,
85
104
  sumAmounts: () => sumAmounts,
86
105
  synchronousDeferred: () => synchronousDeferred,
@@ -260,6 +279,40 @@ function floatSum(values) {
260
279
  for (const value of values) total += value;
261
280
  return total;
262
281
  }
282
+ function multiplyAmount(unit, quantity) {
283
+ const cents = toCents(unit);
284
+ const product = cents === null ? Number.NaN : cents * quantity;
285
+ if (cents === null || cents < 0 || !Number.isSafeInteger(quantity) || quantity < 0 || !Number.isSafeInteger(product) || product > MAX_CENTS) {
286
+ throw new CreditError(
287
+ `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}`,
288
+ CreditErrorCode.INVALID_AMOUNT,
289
+ { unit, quantity, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE }
290
+ );
291
+ }
292
+ return product / 100;
293
+ }
294
+ var FLOAT_NOISE_FLOOR = 1e-6;
295
+ var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
296
+ function floatNoise(value) {
297
+ return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
298
+ }
299
+ function canonicalAmount(value) {
300
+ if (typeof value !== "number" || !Number.isFinite(value)) return value;
301
+ const cents = Math.round(value * 100);
302
+ if (!Number.isSafeInteger(cents)) return value;
303
+ const onGrid = cents / 100 + 0;
304
+ return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
305
+ }
306
+ function storedAmountFloor(value) {
307
+ const canonical = canonicalAmount(value);
308
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
309
+ return Math.floor(canonical * 100) / 100;
310
+ }
311
+ function storedAmountCeil(value) {
312
+ const canonical = canonicalAmount(value);
313
+ if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
314
+ return Math.ceil(canonical * 100) / 100;
315
+ }
263
316
  function isValidCreditAmount(value) {
264
317
  if (typeof value !== "number") return false;
265
318
  const cents = toCents(value);
@@ -312,6 +365,150 @@ function backedBalanceFloor(reserved, bonusCredits) {
312
365
  assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
313
366
  return floor;
314
367
  }
368
+ function cappedBalance(balance, cap, reserved, bonusCredits) {
369
+ return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
370
+ }
371
+ function heldExpiredBacking(balance, stored) {
372
+ if (!(stored !== void 0 && stored > 0)) return 0;
373
+ return Math.max(0, Math.min(stored, balance));
374
+ }
375
+ function spendableBalance(balance, expiredHoldBacking) {
376
+ return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
377
+ }
378
+ function planBalanceTarget(target, reserved, bonusCredits) {
379
+ assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
380
+ const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
381
+ return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
382
+ }
383
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
384
+ const spendable = spendableBalance(balance, expiredHoldBacking);
385
+ const target = Math.min(spendable, cap);
386
+ const kept = Math.max(
387
+ sumAmounts(balance, -spendable),
388
+ sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
389
+ );
390
+ const next = Math.min(balance, sumAmounts(target, kept));
391
+ return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
392
+ }
393
+ function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
394
+ const held = heldExpiredBacking(balance, expiredHoldBacking);
395
+ const used = Math.max(0, Math.min(held, holdBacking));
396
+ return { used, remaining: sumAmounts(held, -used) };
397
+ }
398
+ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
399
+ const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
400
+ const rest = sumAmounts(amount, -backing.used);
401
+ const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
402
+ const fromBonus = sumAmounts(rest, -fromSpendable);
403
+ return {
404
+ covered: fromBonus <= bonusCredits,
405
+ balance: sumAmounts(balance, -backing.used, -fromSpendable),
406
+ bonusCredits: sumAmounts(bonusCredits, -fromBonus),
407
+ expiredHoldBacking: backing.remaining
408
+ };
409
+ }
410
+ var NO_EXPIRED_BACKING_PREFIX = {
411
+ holdPlacedAt: "",
412
+ id: "",
413
+ share: 0
414
+ };
415
+ function compareHolds(a, b) {
416
+ if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
417
+ if (a.id === b.id) return 0;
418
+ return a.id < b.id ? -1 : 1;
419
+ }
420
+ function recordedExpiredBacking(hold) {
421
+ const stored = hold.expiredBacking;
422
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
423
+ if (!isValidCreditAmount(hold.amount)) return 0;
424
+ return Math.min(storedAmountCeil(stored), hold.amount);
425
+ }
426
+ function allocateExpiredBacking(holds, total) {
427
+ const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
428
+ const wanted = Math.max(0, total);
429
+ if (wanted === 0) return { total: 0, frontier: null };
430
+ const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
431
+ const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
432
+ let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
433
+ for (const hold of [...recorded].reverse()) {
434
+ if (left >= 0) break;
435
+ const share = shares.get(hold.id);
436
+ const cut = Math.min(share, -left);
437
+ shares.set(hold.id, sumAmounts(share, -cut));
438
+ left = sumAmounts(left, cut);
439
+ }
440
+ let frontier = NO_EXPIRED_BACKING_PREFIX;
441
+ for (const hold of placed) {
442
+ if (left <= 0) break;
443
+ if (shares.has(hold.id)) continue;
444
+ const share = Math.min(hold.amount, left);
445
+ frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
446
+ left = sumAmounts(left, -share);
447
+ }
448
+ for (const hold of recorded) {
449
+ if (left <= 0) break;
450
+ const share = shares.get(hold.id);
451
+ const added = Math.min(sumAmounts(hold.amount, -share), left);
452
+ shares.set(hold.id, sumAmounts(share, added));
453
+ left = sumAmounts(left, -added);
454
+ }
455
+ const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
456
+ return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
457
+ }
458
+ function holdExpiredBacking(hold, frontier, rewrites) {
459
+ const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
460
+ const recorded = recordedExpiredBacking(
461
+ rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
462
+ );
463
+ if (recorded > 0) return recorded;
464
+ if (!frontier || !hold.holdPlacedAt) return 0;
465
+ const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
466
+ if (order > 0) return 0;
467
+ return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
468
+ }
469
+ function holdPlacedAtAfter(now, frontier) {
470
+ if (!frontier?.holdPlacedAt) return now;
471
+ const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
472
+ return now > earliest ? now : earliest;
473
+ }
474
+ function expiredBackingPatch(next, stored) {
475
+ const total = Math.max(0, next.total);
476
+ const patch = {};
477
+ const storedTotal = stored.expiredHoldBacking;
478
+ if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
479
+ patch.expiredHoldBacking = total;
480
+ }
481
+ if (total <= 0) {
482
+ if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
483
+ } else if (next.frontier) {
484
+ patch.expiredBackingFrontier = next.frontier;
485
+ }
486
+ return patch;
487
+ }
488
+ function assertValidBalanceTarget(value, field, context) {
489
+ assertRepresentableAmount(value, field, context);
490
+ if (value < 0) {
491
+ throw new CreditError(
492
+ `${field} cannot be negative (got ${value})`,
493
+ CreditErrorCode.INVALID_AMOUNT,
494
+ { field, value, ...context }
495
+ );
496
+ }
497
+ }
498
+ function assertValidTierBalanceInput(input, context) {
499
+ if (input.balanceCap === void 0) {
500
+ if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
501
+ return;
502
+ }
503
+ if (input.balance !== void 0) {
504
+ throw new CreditError(
505
+ "A tier update cannot set both balance and balanceCap",
506
+ CreditErrorCode.INVALID_AMOUNT,
507
+ { balance: input.balance, balanceCap: input.balanceCap, ...context }
508
+ );
509
+ }
510
+ assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
511
+ }
315
512
  function assertRepresentableTierAmount(value, field, context) {
316
513
  if (value === Number.POSITIVE_INFINITY) return;
317
514
  assertRepresentableAmount(value, field, context);
@@ -714,9 +911,9 @@ var creditSystemConfigSchema = import_zod.z.object({
714
911
  */
715
912
  reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
716
913
  /**
717
- * Default credits for new users (free tier)
914
+ * Default credits for new users (free tier). 0 means no signup grant.
718
915
  */
719
- defaultFreeCredits: import_zod.z.number().positive().default(25),
916
+ defaultFreeCredits: import_zod.z.number().min(0).default(25),
720
917
  /**
721
918
  * Grace period in days after subscription expires before downgrade
722
919
  */
@@ -1088,9 +1285,11 @@ async function reserveCreditsForOperation(repository, userId, amount, operationT
1088
1285
  CREDIT_AMOUNT_SCALE,
1089
1286
  CreditError,
1090
1287
  CreditErrorCode,
1288
+ NO_EXPIRED_BACKING_PREFIX,
1091
1289
  RESERVATION_STATUSES,
1092
1290
  RESERVED_JOURNAL_KEY_PREFIX,
1093
1291
  TERMINAL_RESERVATION_STATUSES,
1292
+ allocateExpiredBacking,
1094
1293
  assertDirectStatusWriteAllowed,
1095
1294
  assertHoldPlaced,
1096
1295
  assertKnownReservationStatus,
@@ -1100,12 +1299,16 @@ async function reserveCreditsForOperation(repository, userId, amount, operationT
1100
1299
  assertRepresentableTierAmount,
1101
1300
  assertUnkeyedDirectReservation,
1102
1301
  assertUnreferencedDirectTransaction,
1302
+ assertValidBalanceTarget,
1103
1303
  assertValidCreditAmount,
1104
1304
  assertValidIdempotencyKey,
1105
1305
  assertValidStoredAmount,
1106
1306
  assertValidStoredAmountRaw,
1307
+ assertValidTierBalanceInput,
1107
1308
  backedBalanceFloor,
1108
1309
  calculateAvailableCredits,
1310
+ canonicalAmount,
1311
+ cappedBalance,
1109
1312
  classifyDatabaseError,
1110
1313
  commitReservationWithJournal,
1111
1314
  createDeferredExecutor,
@@ -1121,9 +1324,13 @@ async function reserveCreditsForOperation(repository, userId, amount, operationT
1121
1324
  createUnsupportedOperationError,
1122
1325
  createUserNotFoundError,
1123
1326
  describePaymentMismatch,
1327
+ expiredBackingPatch,
1124
1328
  genericDeferred,
1125
1329
  getSqlState,
1126
1330
  hasPlacedHold,
1331
+ heldExpiredBacking,
1332
+ holdExpiredBacking,
1333
+ holdPlacedAtAfter,
1127
1334
  isCreditError,
1128
1335
  isCreditedOutcome,
1129
1336
  isIdempotencyConflictError,
@@ -1139,12 +1346,21 @@ async function reserveCreditsForOperation(repository, userId, amount, operationT
1139
1346
  isValidCreditAmount,
1140
1347
  isValidIdempotencyKey,
1141
1348
  isWinningOutcome,
1349
+ multiplyAmount,
1142
1350
  noopDeferred,
1143
1351
  normalizePaymentRef,
1144
1352
  numericToCents,
1353
+ planBalanceCap,
1354
+ planBalanceTarget,
1355
+ planHoldCommit,
1356
+ recordedExpiredBacking,
1145
1357
  releaseReservationWithJournal,
1146
1358
  reserveCreditsForOperation,
1147
1359
  sameAmount,
1360
+ settleExpiredHoldBacking,
1361
+ spendableBalance,
1362
+ storedAmountCeil,
1363
+ storedAmountFloor,
1148
1364
  storedMonthlyLimit,
1149
1365
  sumAmounts,
1150
1366
  synchronousDeferred,