@nehorai/credits 2.2.0 → 3.1.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 (43) hide show
  1. package/dist/adapters/index.cjs +169 -155
  2. package/dist/adapters/index.cjs.map +1 -1
  3. package/dist/adapters/index.d.cts +2 -2
  4. package/dist/adapters/index.d.ts +2 -2
  5. package/dist/adapters/index.js +169 -155
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/core/index.cjs +148 -24
  8. package/dist/core/index.cjs.map +1 -1
  9. package/dist/core/index.d.cts +113 -22
  10. package/dist/core/index.d.ts +113 -22
  11. package/dist/core/index.js +137 -22
  12. package/dist/core/index.js.map +1 -1
  13. package/dist/index-BrogG0Gc.d.cts +277 -0
  14. package/dist/index-DOvJES2m.d.ts +277 -0
  15. package/dist/index.cjs +324 -71
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +5 -5
  18. package/dist/index.d.ts +5 -5
  19. package/dist/index.js +311 -69
  20. package/dist/index.js.map +1 -1
  21. package/dist/repository/index.cjs +254 -49
  22. package/dist/repository/index.cjs.map +1 -1
  23. package/dist/repository/index.d.cts +3 -254
  24. package/dist/repository/index.d.ts +3 -254
  25. package/dist/repository/index.js +254 -49
  26. package/dist/repository/index.js.map +1 -1
  27. package/dist/sdk/index.cjs +1 -0
  28. package/dist/sdk/index.cjs.map +1 -1
  29. package/dist/sdk/index.d.cts +2 -2
  30. package/dist/sdk/index.d.ts +2 -2
  31. package/dist/sdk/index.js +1 -0
  32. package/dist/sdk/index.js.map +1 -1
  33. package/dist/service/index.cjs +42 -19
  34. package/dist/service/index.cjs.map +1 -1
  35. package/dist/service/index.d.cts +2 -2
  36. package/dist/service/index.d.ts +2 -2
  37. package/dist/service/index.js +42 -19
  38. package/dist/service/index.js.map +1 -1
  39. package/dist/{types-3c-IAwZI.d.ts → types-BL3YqxUD.d.ts} +28 -6
  40. package/dist/{types-MaViK5tD.d.cts → types-C3sxDvNb.d.cts} +29 -6
  41. package/dist/{types-MaViK5tD.d.ts → types-C3sxDvNb.d.ts} +29 -6
  42. package/dist/{types-XSjNXO5l.d.cts → types-YLAB8bFk.d.cts} +28 -6
  43. package/package.json +1 -1
@@ -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,
@@ -42,9 +44,9 @@ __export(core_exports, {
42
44
  assertValidStoredAmount: () => assertValidStoredAmount,
43
45
  assertValidStoredAmountRaw: () => assertValidStoredAmountRaw,
44
46
  assertValidTierBalanceInput: () => assertValidTierBalanceInput,
45
- attributeExpiredBacking: () => attributeExpiredBacking,
46
47
  backedBalanceFloor: () => backedBalanceFloor,
47
48
  calculateAvailableCredits: () => calculateAvailableCredits,
49
+ canonicalAmount: () => canonicalAmount,
48
50
  cappedBalance: () => cappedBalance,
49
51
  classifyDatabaseError: () => classifyDatabaseError,
50
52
  commitReservationWithJournal: () => commitReservationWithJournal,
@@ -61,10 +63,13 @@ __export(core_exports, {
61
63
  createUnsupportedOperationError: () => createUnsupportedOperationError,
62
64
  createUserNotFoundError: () => createUserNotFoundError,
63
65
  describePaymentMismatch: () => describePaymentMismatch,
64
- expiredHoldBackingPatch: () => expiredHoldBackingPatch,
66
+ expiredBackingPatch: () => expiredBackingPatch,
65
67
  genericDeferred: () => genericDeferred,
66
68
  getSqlState: () => getSqlState,
67
69
  hasPlacedHold: () => hasPlacedHold,
70
+ heldExpiredBacking: () => heldExpiredBacking,
71
+ holdExpiredBacking: () => holdExpiredBacking,
72
+ holdPlacedAtAfter: () => holdPlacedAtAfter,
68
73
  isCreditError: () => isCreditError,
69
74
  isCreditedOutcome: () => isCreditedOutcome,
70
75
  isIdempotencyConflictError: () => isIdempotencyConflictError,
@@ -80,17 +85,21 @@ __export(core_exports, {
80
85
  isValidCreditAmount: () => isValidCreditAmount,
81
86
  isValidIdempotencyKey: () => isValidIdempotencyKey,
82
87
  isWinningOutcome: () => isWinningOutcome,
88
+ multiplyAmount: () => multiplyAmount,
83
89
  noopDeferred: () => noopDeferred,
84
90
  normalizePaymentRef: () => normalizePaymentRef,
85
91
  numericToCents: () => numericToCents,
86
92
  planBalanceCap: () => planBalanceCap,
87
93
  planBalanceTarget: () => planBalanceTarget,
88
94
  planHoldCommit: () => planHoldCommit,
95
+ recordedExpiredBacking: () => recordedExpiredBacking,
89
96
  releaseReservationWithJournal: () => releaseReservationWithJournal,
90
97
  reserveCreditsForOperation: () => reserveCreditsForOperation,
91
98
  sameAmount: () => sameAmount,
92
99
  settleExpiredHoldBacking: () => settleExpiredHoldBacking,
93
100
  spendableBalance: () => spendableBalance,
101
+ storedAmountCeil: () => storedAmountCeil,
102
+ storedAmountFloor: () => storedAmountFloor,
94
103
  storedMonthlyLimit: () => storedMonthlyLimit,
95
104
  sumAmounts: () => sumAmounts,
96
105
  synchronousDeferred: () => synchronousDeferred,
@@ -270,6 +279,40 @@ function floatSum(values) {
270
279
  for (const value of values) total += value;
271
280
  return total;
272
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
+ }
273
316
  function isValidCreditAmount(value) {
274
317
  if (typeof value !== "number") return false;
275
318
  const cents = toCents(value);
@@ -325,14 +368,19 @@ function backedBalanceFloor(reserved, bonusCredits) {
325
368
  function cappedBalance(balance, cap, reserved, bonusCredits) {
326
369
  return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
327
370
  }
328
- function spendableBalance(balance, expiredHoldBacking = 0) {
329
- return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
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));
330
377
  }
331
378
  function planBalanceTarget(target, reserved, bonusCredits) {
379
+ assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
332
380
  const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
333
381
  return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
334
382
  }
335
- function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
383
+ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
336
384
  const spendable = spendableBalance(balance, expiredHoldBacking);
337
385
  const target = Math.min(spendable, cap);
338
386
  const kept = Math.max(
@@ -343,8 +391,8 @@ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
343
391
  return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
344
392
  }
345
393
  function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
346
- const held = Math.min(expiredHoldBacking, balance);
347
- const used = Math.min(held, holdBacking);
394
+ const held = heldExpiredBacking(balance, expiredHoldBacking);
395
+ const used = Math.max(0, Math.min(held, holdBacking));
348
396
  return { used, remaining: sumAmounts(held, -used) };
349
397
  }
350
398
  function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
@@ -359,27 +407,83 @@ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking,
359
407
  expiredHoldBacking: backing.remaining
360
408
  };
361
409
  }
362
- function attributeExpiredBacking(holds, total) {
363
- const placed = holds.filter((hold) => hold.holdPlacedAt).sort(
364
- (a, b) => a.holdPlacedAt === b.holdPlacedAt ? a.id < b.id ? -1 : 1 : a.holdPlacedAt < b.holdPlacedAt ? -1 : 1
365
- );
366
- const shares = /* @__PURE__ */ new Map();
367
- let left = total;
368
- for (const hold of placed) {
369
- const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);
370
- shares.set(hold.id, kept);
371
- left = sumAmounts(left, -kept);
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);
372
439
  }
440
+ let frontier = NO_EXPIRED_BACKING_PREFIX;
373
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;
374
450
  const share = shares.get(hold.id);
375
451
  const added = Math.min(sumAmounts(hold.amount, -share), left);
376
452
  shares.set(hold.id, sumAmounts(share, added));
377
453
  left = sumAmounts(left, -added);
378
454
  }
379
- return shares;
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 } : {} };
380
457
  }
381
- function expiredHoldBackingPatch(next, stored = 0) {
382
- return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
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;
383
487
  }
384
488
  function assertValidBalanceTarget(value, field, context) {
385
489
  assertRepresentableAmount(value, field, context);
@@ -392,7 +496,10 @@ function assertValidBalanceTarget(value, field, context) {
392
496
  }
393
497
  }
394
498
  function assertValidTierBalanceInput(input, context) {
395
- if (input.balanceCap === void 0) return;
499
+ if (input.balanceCap === void 0) {
500
+ if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
501
+ return;
502
+ }
396
503
  if (input.balance !== void 0) {
397
504
  throw new CreditError(
398
505
  "A tier update cannot set both balance and balanceCap",
@@ -912,17 +1019,25 @@ var DEFAULT_CONFIG = {
912
1019
  }
913
1020
  };
914
1021
  var currentConfig = DEFAULT_CONFIG;
1022
+ function isOperationCostMap(value) {
1023
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1024
+ return Object.values(value).every((cost) => isRepresentableAmount(cost) && cost >= 0);
1025
+ }
915
1026
  function loadConfigFromEnv() {
916
1027
  const envConfig = {};
917
1028
  const operationCostsEnv = process.env.CREDITS_OPERATION_COSTS;
918
1029
  if (operationCostsEnv) {
919
1030
  try {
920
1031
  const parsed = JSON.parse(operationCostsEnv);
921
- if (typeof parsed === "object" && parsed !== null) {
1032
+ if (isOperationCostMap(parsed)) {
922
1033
  envConfig.operationCosts = {
923
1034
  ...DEFAULT_CONFIG.operationCosts,
924
1035
  ...parsed
925
1036
  };
1037
+ } else {
1038
+ console.warn(
1039
+ "[Credits Config] Ignoring CREDITS_OPERATION_COSTS: it must be a JSON object whose costs are numbers >= 0, exact to the cent"
1040
+ );
926
1041
  }
927
1042
  } catch {
928
1043
  console.warn("[Credits Config] Failed to parse CREDITS_OPERATION_COSTS env var as JSON");
@@ -1178,9 +1293,11 @@ async function reserveCreditsForOperation(repository, userId, amount, operationT
1178
1293
  CREDIT_AMOUNT_SCALE,
1179
1294
  CreditError,
1180
1295
  CreditErrorCode,
1296
+ NO_EXPIRED_BACKING_PREFIX,
1181
1297
  RESERVATION_STATUSES,
1182
1298
  RESERVED_JOURNAL_KEY_PREFIX,
1183
1299
  TERMINAL_RESERVATION_STATUSES,
1300
+ allocateExpiredBacking,
1184
1301
  assertDirectStatusWriteAllowed,
1185
1302
  assertHoldPlaced,
1186
1303
  assertKnownReservationStatus,
@@ -1196,9 +1313,9 @@ async function reserveCreditsForOperation(repository, userId, amount, operationT
1196
1313
  assertValidStoredAmount,
1197
1314
  assertValidStoredAmountRaw,
1198
1315
  assertValidTierBalanceInput,
1199
- attributeExpiredBacking,
1200
1316
  backedBalanceFloor,
1201
1317
  calculateAvailableCredits,
1318
+ canonicalAmount,
1202
1319
  cappedBalance,
1203
1320
  classifyDatabaseError,
1204
1321
  commitReservationWithJournal,
@@ -1215,10 +1332,13 @@ async function reserveCreditsForOperation(repository, userId, amount, operationT
1215
1332
  createUnsupportedOperationError,
1216
1333
  createUserNotFoundError,
1217
1334
  describePaymentMismatch,
1218
- expiredHoldBackingPatch,
1335
+ expiredBackingPatch,
1219
1336
  genericDeferred,
1220
1337
  getSqlState,
1221
1338
  hasPlacedHold,
1339
+ heldExpiredBacking,
1340
+ holdExpiredBacking,
1341
+ holdPlacedAtAfter,
1222
1342
  isCreditError,
1223
1343
  isCreditedOutcome,
1224
1344
  isIdempotencyConflictError,
@@ -1234,17 +1354,21 @@ async function reserveCreditsForOperation(repository, userId, amount, operationT
1234
1354
  isValidCreditAmount,
1235
1355
  isValidIdempotencyKey,
1236
1356
  isWinningOutcome,
1357
+ multiplyAmount,
1237
1358
  noopDeferred,
1238
1359
  normalizePaymentRef,
1239
1360
  numericToCents,
1240
1361
  planBalanceCap,
1241
1362
  planBalanceTarget,
1242
1363
  planHoldCommit,
1364
+ recordedExpiredBacking,
1243
1365
  releaseReservationWithJournal,
1244
1366
  reserveCreditsForOperation,
1245
1367
  sameAmount,
1246
1368
  settleExpiredHoldBacking,
1247
1369
  spendableBalance,
1370
+ storedAmountCeil,
1371
+ storedAmountFloor,
1248
1372
  storedMonthlyLimit,
1249
1373
  sumAmounts,
1250
1374
  synchronousDeferred,