@nehorai/credits 2.0.0 → 2.2.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.
- package/LICENSE +21 -21
- package/README.md +384 -377
- package/dist/adapters/index.cjs +12 -8
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.d.cts +3 -2
- package/dist/adapters/index.d.ts +3 -2
- package/dist/adapters/index.js +12 -8
- package/dist/adapters/index.js.map +1 -1
- package/dist/auth/index.cjs.map +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.cjs +106 -4
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +92 -5
- package/dist/core/index.d.ts +92 -5
- package/dist/core/index.js +96 -4
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +282 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -7
- package/dist/index.d.ts +9 -7
- package/dist/index.js +270 -45
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +251 -41
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +34 -6
- package/dist/repository/index.d.ts +34 -6
- package/dist/repository/index.js +248 -40
- package/dist/repository/index.js.map +1 -1
- package/dist/sdk/index.cjs.map +1 -1
- package/dist/sdk/index.d.cts +2 -2
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/index.js.map +1 -1
- package/dist/service/index.cjs +12 -6
- package/dist/service/index.cjs.map +1 -1
- package/dist/service/index.d.cts +2 -2
- package/dist/service/index.d.ts +2 -2
- package/dist/service/index.js +12 -6
- package/dist/service/index.js.map +1 -1
- package/dist/{types-BmG5Omzj.d.ts → types-3c-IAwZI.d.ts} +38 -3
- package/dist/{types-DJ8yqaVr.d.cts → types-MaViK5tD.d.cts} +23 -1
- package/dist/{types-DJ8yqaVr.d.ts → types-MaViK5tD.d.ts} +23 -1
- package/dist/{types-C8hZMe8a.d.cts → types-XSjNXO5l.d.cts} +38 -3
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -54,12 +54,17 @@ __export(src_exports, {
|
|
|
54
54
|
assertRepresentableTierAmount: () => assertRepresentableTierAmount,
|
|
55
55
|
assertUnkeyedDirectReservation: () => assertUnkeyedDirectReservation,
|
|
56
56
|
assertUnreferencedDirectTransaction: () => assertUnreferencedDirectTransaction,
|
|
57
|
+
assertValidBalanceTarget: () => assertValidBalanceTarget,
|
|
58
|
+
assertValidBalanceUpdate: () => assertValidBalanceUpdate,
|
|
57
59
|
assertValidCreditAmount: () => assertValidCreditAmount,
|
|
58
60
|
assertValidIdempotencyKey: () => assertValidIdempotencyKey,
|
|
59
61
|
assertValidStoredAmount: () => assertValidStoredAmount,
|
|
60
62
|
assertValidStoredAmountRaw: () => assertValidStoredAmountRaw,
|
|
63
|
+
assertValidTierBalanceInput: () => assertValidTierBalanceInput,
|
|
64
|
+
attributeExpiredBacking: () => attributeExpiredBacking,
|
|
61
65
|
backedBalanceFloor: () => backedBalanceFloor,
|
|
62
66
|
calculateAvailableCredits: () => calculateAvailableCredits,
|
|
67
|
+
cappedBalance: () => cappedBalance,
|
|
63
68
|
checkAndNotifyLowBalance: () => checkAndNotifyLowBalance,
|
|
64
69
|
classifyDatabaseError: () => classifyDatabaseError,
|
|
65
70
|
clearConfigProvider: () => clearConfigProvider,
|
|
@@ -85,6 +90,7 @@ __export(src_exports, {
|
|
|
85
90
|
createUnsupportedOperationError: () => createUnsupportedOperationError,
|
|
86
91
|
createUserNotFoundError: () => createUserNotFoundError,
|
|
87
92
|
describePaymentMismatch: () => describePaymentMismatch,
|
|
93
|
+
expiredHoldBackingPatch: () => expiredHoldBackingPatch,
|
|
88
94
|
generateId: () => generateId,
|
|
89
95
|
generateRequestId: () => generateRequestId2,
|
|
90
96
|
genericDeferred: () => genericDeferred,
|
|
@@ -143,6 +149,9 @@ __export(src_exports, {
|
|
|
143
149
|
parseApiError: () => parseApiError,
|
|
144
150
|
parseCreditError: () => parseCreditError,
|
|
145
151
|
parseTier: () => parseTier,
|
|
152
|
+
planBalanceCap: () => planBalanceCap,
|
|
153
|
+
planBalanceTarget: () => planBalanceTarget,
|
|
154
|
+
planHoldCommit: () => planHoldCommit,
|
|
146
155
|
registerConfigProvider: () => registerConfigProvider,
|
|
147
156
|
registerNotificationHandler: () => registerNotificationHandler,
|
|
148
157
|
releaseReservationWithJournal: () => releaseReservationWithJournal,
|
|
@@ -152,6 +161,8 @@ __export(src_exports, {
|
|
|
152
161
|
reserveThroughRepository: () => reserveThroughRepository,
|
|
153
162
|
resetConfig: () => resetConfig,
|
|
154
163
|
sameAmount: () => sameAmount,
|
|
164
|
+
settleExpiredHoldBacking: () => settleExpiredHoldBacking,
|
|
165
|
+
spendableBalance: () => spendableBalance,
|
|
155
166
|
storedMonthlyLimit: () => storedMonthlyLimit,
|
|
156
167
|
sumAmounts: () => sumAmounts,
|
|
157
168
|
supportsCreditsV2: () => supportsCreditsV2,
|
|
@@ -163,6 +174,7 @@ __export(src_exports, {
|
|
|
163
174
|
toClientUserCredits: () => toClientUserCredits,
|
|
164
175
|
toDate: () => toDate,
|
|
165
176
|
toPortableTimestamp: () => toPortableTimestamp,
|
|
177
|
+
validateBalanceUpdate: () => validateBalanceUpdate,
|
|
166
178
|
verifyAdminApiKey: () => verifyAdminApiKey,
|
|
167
179
|
verifyBearerToken: () => verifyBearerToken
|
|
168
180
|
});
|
|
@@ -389,6 +401,86 @@ function backedBalanceFloor(reserved, bonusCredits) {
|
|
|
389
401
|
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
390
402
|
return floor;
|
|
391
403
|
}
|
|
404
|
+
function cappedBalance(balance, cap, reserved, bonusCredits) {
|
|
405
|
+
return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
|
|
406
|
+
}
|
|
407
|
+
function spendableBalance(balance, expiredHoldBacking = 0) {
|
|
408
|
+
return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
|
|
409
|
+
}
|
|
410
|
+
function planBalanceTarget(target, reserved, bonusCredits) {
|
|
411
|
+
const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
|
|
412
|
+
return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
|
|
413
|
+
}
|
|
414
|
+
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
|
|
415
|
+
const spendable = spendableBalance(balance, expiredHoldBacking);
|
|
416
|
+
const target = Math.min(spendable, cap);
|
|
417
|
+
const kept = Math.max(
|
|
418
|
+
sumAmounts(balance, -spendable),
|
|
419
|
+
sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
|
|
420
|
+
);
|
|
421
|
+
const next = Math.min(balance, sumAmounts(target, kept));
|
|
422
|
+
return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
|
|
423
|
+
}
|
|
424
|
+
function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
|
|
425
|
+
const held = Math.min(expiredHoldBacking, balance);
|
|
426
|
+
const used = Math.min(held, holdBacking);
|
|
427
|
+
return { used, remaining: sumAmounts(held, -used) };
|
|
428
|
+
}
|
|
429
|
+
function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
|
|
430
|
+
const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
|
|
431
|
+
const rest = sumAmounts(amount, -backing.used);
|
|
432
|
+
const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
|
|
433
|
+
const fromBonus = sumAmounts(rest, -fromSpendable);
|
|
434
|
+
return {
|
|
435
|
+
covered: fromBonus <= bonusCredits,
|
|
436
|
+
balance: sumAmounts(balance, -backing.used, -fromSpendable),
|
|
437
|
+
bonusCredits: sumAmounts(bonusCredits, -fromBonus),
|
|
438
|
+
expiredHoldBacking: backing.remaining
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function attributeExpiredBacking(holds, total) {
|
|
442
|
+
const placed = holds.filter((hold) => hold.holdPlacedAt).sort(
|
|
443
|
+
(a, b) => a.holdPlacedAt === b.holdPlacedAt ? a.id < b.id ? -1 : 1 : a.holdPlacedAt < b.holdPlacedAt ? -1 : 1
|
|
444
|
+
);
|
|
445
|
+
const shares = /* @__PURE__ */ new Map();
|
|
446
|
+
let left = total;
|
|
447
|
+
for (const hold of placed) {
|
|
448
|
+
const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);
|
|
449
|
+
shares.set(hold.id, kept);
|
|
450
|
+
left = sumAmounts(left, -kept);
|
|
451
|
+
}
|
|
452
|
+
for (const hold of placed) {
|
|
453
|
+
const share = shares.get(hold.id);
|
|
454
|
+
const added = Math.min(sumAmounts(hold.amount, -share), left);
|
|
455
|
+
shares.set(hold.id, sumAmounts(share, added));
|
|
456
|
+
left = sumAmounts(left, -added);
|
|
457
|
+
}
|
|
458
|
+
return shares;
|
|
459
|
+
}
|
|
460
|
+
function expiredHoldBackingPatch(next, stored = 0) {
|
|
461
|
+
return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
|
|
462
|
+
}
|
|
463
|
+
function assertValidBalanceTarget(value, field, context) {
|
|
464
|
+
assertRepresentableAmount(value, field, context);
|
|
465
|
+
if (value < 0) {
|
|
466
|
+
throw new CreditError(
|
|
467
|
+
`${field} cannot be negative (got ${value})`,
|
|
468
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
469
|
+
{ field, value, ...context }
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function assertValidTierBalanceInput(input, context) {
|
|
474
|
+
if (input.balanceCap === void 0) return;
|
|
475
|
+
if (input.balance !== void 0) {
|
|
476
|
+
throw new CreditError(
|
|
477
|
+
"A tier update cannot set both balance and balanceCap",
|
|
478
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
479
|
+
{ balance: input.balance, balanceCap: input.balanceCap, ...context }
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
|
|
483
|
+
}
|
|
392
484
|
function assertRepresentableTierAmount(value, field, context) {
|
|
393
485
|
if (value === Number.POSITIVE_INFINITY) return;
|
|
394
486
|
assertRepresentableAmount(value, field, context);
|
|
@@ -769,11 +861,13 @@ var creditSystemConfigSchema = import_zod.z.object({
|
|
|
769
861
|
/**
|
|
770
862
|
* Operation costs in credits
|
|
771
863
|
* Keys are operation type names (any non-empty string)
|
|
772
|
-
* Values are
|
|
864
|
+
* Values are non-negative numbers representing credit cost. 0 marks a free
|
|
865
|
+
* operation: `withCredits` runs it without touching the ledger. Amounts that
|
|
866
|
+
* move credits stay strictly positive, so `reserveCredits` refuses a 0.
|
|
773
867
|
*/
|
|
774
868
|
operationCosts: import_zod.z.record(
|
|
775
869
|
import_zod.z.string().min(1),
|
|
776
|
-
import_zod.z.number().
|
|
870
|
+
import_zod.z.number().nonnegative()
|
|
777
871
|
),
|
|
778
872
|
/**
|
|
779
873
|
* Display labels for operation types (English canonical labels).
|
|
@@ -795,9 +889,9 @@ var creditSystemConfigSchema = import_zod.z.object({
|
|
|
795
889
|
*/
|
|
796
890
|
reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
|
|
797
891
|
/**
|
|
798
|
-
* Default credits for new users (free tier)
|
|
892
|
+
* Default credits for new users (free tier). 0 means no signup grant.
|
|
799
893
|
*/
|
|
800
|
-
defaultFreeCredits: import_zod.z.number().
|
|
894
|
+
defaultFreeCredits: import_zod.z.number().min(0).default(25),
|
|
801
895
|
/**
|
|
802
896
|
* Grace period in days after subscription expires before downgrade
|
|
803
897
|
*/
|
|
@@ -1097,6 +1191,8 @@ function toClientUserCredits(credits) {
|
|
|
1097
1191
|
monthlyUsed: credits.monthlyUsed,
|
|
1098
1192
|
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
1099
1193
|
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
1194
|
+
...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
|
|
1195
|
+
...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
|
|
1100
1196
|
createdAt: toISOString(credits.createdAt),
|
|
1101
1197
|
updatedAt: toISOString(credits.updatedAt)
|
|
1102
1198
|
};
|
|
@@ -1328,11 +1424,36 @@ function toDate2(value) {
|
|
|
1328
1424
|
return /* @__PURE__ */ new Date();
|
|
1329
1425
|
}
|
|
1330
1426
|
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
next
|
|
1335
|
-
|
|
1427
|
+
return new Date(from.getFullYear(), from.getMonth() + 1, 1);
|
|
1428
|
+
}
|
|
1429
|
+
function validateBalanceUpdate(current, updates) {
|
|
1430
|
+
const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
|
|
1431
|
+
const balance = next(current.balance, updates.balance, updates.balanceIncrement);
|
|
1432
|
+
const bonusCredits = next(
|
|
1433
|
+
current.bonusCredits ?? 0,
|
|
1434
|
+
updates.bonusCredits,
|
|
1435
|
+
updates.bonusCreditsIncrement
|
|
1436
|
+
);
|
|
1437
|
+
const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
|
|
1438
|
+
const errors = [];
|
|
1439
|
+
if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
|
|
1440
|
+
const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
|
|
1441
|
+
if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
|
|
1442
|
+
errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
|
|
1443
|
+
}
|
|
1444
|
+
if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
|
|
1445
|
+
if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
|
|
1446
|
+
const covering = sumAmounts(balance, bonusCredits);
|
|
1447
|
+
if (reserved > covering) {
|
|
1448
|
+
errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
|
|
1449
|
+
}
|
|
1450
|
+
return { valid: errors.length === 0, errors };
|
|
1451
|
+
}
|
|
1452
|
+
function assertValidBalanceUpdate(current, updates) {
|
|
1453
|
+
const result = validateBalanceUpdate(current, updates);
|
|
1454
|
+
if (!result.valid) {
|
|
1455
|
+
throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
|
|
1456
|
+
}
|
|
1336
1457
|
}
|
|
1337
1458
|
|
|
1338
1459
|
// src/repository/memory/snapshot.ts
|
|
@@ -1512,6 +1633,22 @@ function assertTrustworthy(reservation, transition) {
|
|
|
1512
1633
|
assertLockedAmount(reservation, transition);
|
|
1513
1634
|
assertHoldPlaced(reservation, transition);
|
|
1514
1635
|
}
|
|
1636
|
+
function openHolds(store, userId) {
|
|
1637
|
+
return [...store.reservations.get(userId)?.values() ?? []].filter(
|
|
1638
|
+
(reservation) => reservation.status === "reserved"
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
function writeExpiredHoldBacking(store, credits, next) {
|
|
1642
|
+
const stored = credits.expiredHoldBacking;
|
|
1643
|
+
if (next <= 0 && !stored) return;
|
|
1644
|
+
const holds = openHolds(store, credits.userId);
|
|
1645
|
+
const shares = attributeExpiredBacking(holds, next);
|
|
1646
|
+
for (const hold of holds) {
|
|
1647
|
+
const share = shares.get(hold.id) ?? 0;
|
|
1648
|
+
if (share !== (hold.expiredBacking ?? 0)) hold.expiredBacking = share;
|
|
1649
|
+
}
|
|
1650
|
+
Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
|
|
1651
|
+
}
|
|
1515
1652
|
function getReservation(store, userId, reservationId) {
|
|
1516
1653
|
return store.reservations.get(userId)?.get(reservationId);
|
|
1517
1654
|
}
|
|
@@ -1609,16 +1746,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1609
1746
|
const amount = reservation.amount;
|
|
1610
1747
|
await yieldPoint(store);
|
|
1611
1748
|
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1612
|
-
|
|
1749
|
+
const paid = planHoldCommit(
|
|
1750
|
+
credits.balance,
|
|
1751
|
+
credits.bonusCredits,
|
|
1752
|
+
credits.expiredHoldBacking ?? 0,
|
|
1753
|
+
reservation.expiredBacking ?? 0,
|
|
1754
|
+
amount
|
|
1755
|
+
);
|
|
1756
|
+
if (!paid.covered) {
|
|
1613
1757
|
throw new CreditError(
|
|
1614
1758
|
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1615
1759
|
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1616
1760
|
{ userId, reservationId, required: amount }
|
|
1617
1761
|
);
|
|
1618
1762
|
}
|
|
1619
|
-
const
|
|
1620
|
-
const
|
|
1621
|
-
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1763
|
+
const nextBalance = paid.balance;
|
|
1764
|
+
const nextBonusCredits = paid.bonusCredits;
|
|
1622
1765
|
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1623
1766
|
const journal = {
|
|
1624
1767
|
userId,
|
|
@@ -1652,6 +1795,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1652
1795
|
credits.bonusCredits = nextBonusCredits;
|
|
1653
1796
|
credits.reserved = nextReserved;
|
|
1654
1797
|
credits.monthlyUsed = nextMonthlyUsed;
|
|
1798
|
+
Object.assign(
|
|
1799
|
+
credits,
|
|
1800
|
+
expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
|
|
1801
|
+
);
|
|
1655
1802
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1656
1803
|
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1657
1804
|
return {
|
|
@@ -1731,19 +1878,26 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1731
1878
|
reservation.id,
|
|
1732
1879
|
reservation.amount
|
|
1733
1880
|
);
|
|
1881
|
+
const backing = settleExpiredHoldBacking(
|
|
1882
|
+
credits.balance,
|
|
1883
|
+
credits.expiredHoldBacking ?? 0,
|
|
1884
|
+
reservation.expiredBacking ?? 0
|
|
1885
|
+
);
|
|
1886
|
+
const nextBalance = sumAmounts(credits.balance, -backing.used);
|
|
1734
1887
|
const journal = {
|
|
1735
1888
|
userId: reservation.userId,
|
|
1736
1889
|
operation: `${transition.transition}Reservation`,
|
|
1737
1890
|
entryType: "credit",
|
|
1738
1891
|
amount: 0,
|
|
1739
|
-
balanceAfter: sumAmounts(
|
|
1892
|
+
balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
|
|
1740
1893
|
source: transition.source,
|
|
1741
1894
|
reservationId: reservation.id,
|
|
1742
1895
|
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1743
1896
|
metadata: {
|
|
1744
1897
|
...transition.options?.metadata,
|
|
1745
1898
|
operationType: reservation.operationType,
|
|
1746
|
-
amount: reservation.amount
|
|
1899
|
+
amount: reservation.amount,
|
|
1900
|
+
...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
|
|
1747
1901
|
},
|
|
1748
1902
|
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1749
1903
|
};
|
|
@@ -1756,6 +1910,8 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1756
1910
|
reservation.status = transition.status;
|
|
1757
1911
|
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1758
1912
|
credits.reserved = nextReserved;
|
|
1913
|
+
credits.balance = nextBalance;
|
|
1914
|
+
Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
|
|
1759
1915
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1760
1916
|
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1761
1917
|
}
|
|
@@ -1766,6 +1922,10 @@ function isoOf(value) {
|
|
|
1766
1922
|
}
|
|
1767
1923
|
var InMemoryCreditRepository = class {
|
|
1768
1924
|
store = new MemoryStore();
|
|
1925
|
+
options;
|
|
1926
|
+
constructor(options = {}) {
|
|
1927
|
+
this.options = options;
|
|
1928
|
+
}
|
|
1769
1929
|
// ==================== User Credits ====================
|
|
1770
1930
|
/**
|
|
1771
1931
|
* A copy, never the live record.
|
|
@@ -1782,7 +1942,8 @@ var InMemoryCreditRepository = class {
|
|
|
1782
1942
|
return copyRecord(credits) ?? null;
|
|
1783
1943
|
}
|
|
1784
1944
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1785
|
-
|
|
1945
|
+
const existing = this.store.users.get(userId);
|
|
1946
|
+
return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
|
|
1786
1947
|
}
|
|
1787
1948
|
/**
|
|
1788
1949
|
* The synchronous body of `initializeUserCredits`.
|
|
@@ -1833,8 +1994,11 @@ var InMemoryCreditRepository = class {
|
|
|
1833
1994
|
if (!credits) {
|
|
1834
1995
|
throw new Error(`User ${userId} not found`);
|
|
1835
1996
|
}
|
|
1997
|
+
assertValidBalanceUpdate(credits, updates);
|
|
1836
1998
|
const next = { ...credits };
|
|
1837
|
-
if (updates.balance !== void 0)
|
|
1999
|
+
if (updates.balance !== void 0) {
|
|
2000
|
+
next.balance = updates.balance;
|
|
2001
|
+
}
|
|
1838
2002
|
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1839
2003
|
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1840
2004
|
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
@@ -1871,6 +2035,9 @@ var InMemoryCreditRepository = class {
|
|
|
1871
2035
|
userId,
|
|
1872
2036
|
operation: "updateUserCredits"
|
|
1873
2037
|
});
|
|
2038
|
+
if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
|
|
2039
|
+
writeExpiredHoldBacking(this.store, next, 0);
|
|
2040
|
+
}
|
|
1874
2041
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1875
2042
|
this.store.users.set(userId, next);
|
|
1876
2043
|
}
|
|
@@ -1879,17 +2046,28 @@ var InMemoryCreditRepository = class {
|
|
|
1879
2046
|
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1880
2047
|
{ userId, operation: "updateUserTier" }
|
|
1881
2048
|
);
|
|
2049
|
+
assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
|
|
1882
2050
|
const credits = this.store.users.get(userId);
|
|
1883
2051
|
if (!credits) {
|
|
1884
2052
|
throw new Error(`User ${userId} not found`);
|
|
1885
2053
|
}
|
|
2054
|
+
if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
|
|
2055
|
+
const capped = planBalanceCap(
|
|
2056
|
+
credits.balance,
|
|
2057
|
+
input.balanceCap,
|
|
2058
|
+
credits.reserved,
|
|
2059
|
+
credits.bonusCredits,
|
|
2060
|
+
credits.expiredHoldBacking
|
|
2061
|
+
);
|
|
2062
|
+
credits.balance = capped.balance;
|
|
2063
|
+
writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
|
|
2064
|
+
}
|
|
1886
2065
|
credits.tier = input.tier;
|
|
1887
2066
|
credits.monthlyLimit = input.monthlyLimit;
|
|
1888
2067
|
if (input.balance !== void 0) {
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
);
|
|
2068
|
+
const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
2069
|
+
credits.balance = planned.balance;
|
|
2070
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1893
2071
|
}
|
|
1894
2072
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
1895
2073
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
@@ -2110,13 +2288,17 @@ var InMemoryCreditRepository = class {
|
|
|
2110
2288
|
if (!credits) {
|
|
2111
2289
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
2112
2290
|
}
|
|
2113
|
-
const
|
|
2291
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
2292
|
+
const available = Math.min(
|
|
2293
|
+
sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
|
|
2294
|
+
sumAmounts(spendable, credits.bonusCredits)
|
|
2295
|
+
);
|
|
2114
2296
|
if (available < amount) {
|
|
2115
2297
|
throw new Error(
|
|
2116
2298
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
2117
2299
|
);
|
|
2118
2300
|
}
|
|
2119
|
-
const balanceDeduction = Math.min(
|
|
2301
|
+
const balanceDeduction = Math.min(spendable, amount);
|
|
2120
2302
|
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
2121
2303
|
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
2122
2304
|
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
@@ -2353,7 +2535,11 @@ var InMemoryCreditRepository = class {
|
|
|
2353
2535
|
return { expiredCount, creditsReleased, errors };
|
|
2354
2536
|
}
|
|
2355
2537
|
// ==================== Atomic Monthly Reset ====================
|
|
2356
|
-
|
|
2538
|
+
/**
|
|
2539
|
+
* @param _tier - Not trusted. The caller read it earlier and the account may
|
|
2540
|
+
* have changed tier since, so the reset uses the tier stored on the account.
|
|
2541
|
+
*/
|
|
2542
|
+
async atomicMonthlyReset(userId, _tier, expectedResetAt) {
|
|
2357
2543
|
const credits = this.store.users.get(userId);
|
|
2358
2544
|
if (!credits) {
|
|
2359
2545
|
throw new Error(`User ${userId} not found`);
|
|
@@ -2363,17 +2549,41 @@ var InMemoryCreditRepository = class {
|
|
|
2363
2549
|
if (currentResetAt !== expected) {
|
|
2364
2550
|
return { wasReset: false, credits: { ...credits } };
|
|
2365
2551
|
}
|
|
2366
|
-
|
|
2552
|
+
const tier = credits.tier;
|
|
2553
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
2554
|
+
const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
|
|
2555
|
+
if (decision === null) {
|
|
2556
|
+
return { wasReset: false, credits: { ...credits } };
|
|
2557
|
+
}
|
|
2558
|
+
if (decision) {
|
|
2559
|
+
assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
|
|
2560
|
+
if (decision.monthlyLimit !== void 0) {
|
|
2561
|
+
assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
|
|
2562
|
+
userId,
|
|
2563
|
+
tier
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
} else {
|
|
2567
|
+
assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
|
|
2568
|
+
}
|
|
2367
2569
|
const nextReset = getNextMonthlyReset();
|
|
2368
|
-
const target = monthlyResetBalance(tier);
|
|
2369
|
-
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
2370
2570
|
const previousBalance = credits.balance;
|
|
2371
|
-
|
|
2571
|
+
let target;
|
|
2572
|
+
if (decision) {
|
|
2573
|
+
target = decision.balance;
|
|
2574
|
+
} else {
|
|
2575
|
+
const configured = monthlyResetBalance(tier);
|
|
2576
|
+
target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
|
|
2577
|
+
}
|
|
2578
|
+
const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
|
|
2579
|
+
const newBalance = planned.balance;
|
|
2372
2580
|
const change = sumAmounts(newBalance, -previousBalance);
|
|
2373
2581
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2374
2582
|
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
2375
2583
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2376
2584
|
credits.balance = newBalance;
|
|
2585
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2586
|
+
if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
|
|
2377
2587
|
credits.monthlyUsed = 0;
|
|
2378
2588
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
2379
2589
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2427,23 +2637,34 @@ var InMemoryCreditRepository = class {
|
|
|
2427
2637
|
};
|
|
2428
2638
|
}
|
|
2429
2639
|
const defaultTier = getDefaultTier();
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
tier: defaultTier
|
|
2434
|
-
}
|
|
2435
|
-
|
|
2640
|
+
let limit;
|
|
2641
|
+
if (this.options.getDowngradeBalanceCap) {
|
|
2642
|
+
limit = this.options.getDowngradeBalanceCap();
|
|
2643
|
+
assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
|
|
2644
|
+
} else {
|
|
2645
|
+
const configured = getConfigMonthlyLimit(defaultTier);
|
|
2646
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", {
|
|
2647
|
+
userId,
|
|
2648
|
+
tier: defaultTier
|
|
2649
|
+
});
|
|
2650
|
+
limit = storedMonthlyLimit(configured);
|
|
2651
|
+
}
|
|
2436
2652
|
const previousTier = credits.tier;
|
|
2437
2653
|
const previousBalance = credits.balance;
|
|
2438
|
-
const
|
|
2439
|
-
|
|
2440
|
-
|
|
2654
|
+
const planned = planBalanceCap(
|
|
2655
|
+
credits.balance,
|
|
2656
|
+
limit,
|
|
2657
|
+
credits.reserved,
|
|
2658
|
+
credits.bonusCredits,
|
|
2659
|
+
credits.expiredHoldBacking
|
|
2441
2660
|
);
|
|
2661
|
+
const newBalance = planned.balance;
|
|
2442
2662
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2443
2663
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2444
2664
|
credits.tier = defaultTier;
|
|
2445
2665
|
credits.monthlyLimit = limit;
|
|
2446
2666
|
credits.balance = newBalance;
|
|
2667
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2447
2668
|
credits.subscriptionExpiresAt = null;
|
|
2448
2669
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2449
2670
|
this.store.users.set(userId, credits);
|
|
@@ -2501,8 +2722,8 @@ var InMemoryCreditRepository = class {
|
|
|
2501
2722
|
return copyRecords(Array.from(userReservations.values()));
|
|
2502
2723
|
}
|
|
2503
2724
|
};
|
|
2504
|
-
function createInMemoryCreditRepository() {
|
|
2505
|
-
return new InMemoryCreditRepository();
|
|
2725
|
+
function createInMemoryCreditRepository(options = {}) {
|
|
2726
|
+
return new InMemoryCreditRepository(options);
|
|
2506
2727
|
}
|
|
2507
2728
|
|
|
2508
2729
|
// src/auth/api-key.ts
|
|
@@ -2665,9 +2886,11 @@ var CreditsService = class {
|
|
|
2665
2886
|
source: "monthly_reset",
|
|
2666
2887
|
referenceId: `reset-${Date.now()}`,
|
|
2667
2888
|
referenceType: "reset",
|
|
2668
|
-
|
|
2889
|
+
// The tier the reset ran on, which a concurrent downgrade may have
|
|
2890
|
+
// changed since `data` was read.
|
|
2891
|
+
description: `Monthly credit reset for ${resetResult.credits.tier} tier.`,
|
|
2669
2892
|
metadata: {
|
|
2670
|
-
tier:
|
|
2893
|
+
tier: resetResult.credits.tier,
|
|
2671
2894
|
previousBalance: data.balance,
|
|
2672
2895
|
newBalance: resetResult.credits.balance
|
|
2673
2896
|
}
|
|
@@ -3106,9 +3329,11 @@ function createGenericAdapter(config) {
|
|
|
3106
3329
|
return handler(user, data, dummyReservation);
|
|
3107
3330
|
}
|
|
3108
3331
|
let reservation;
|
|
3332
|
+
let isFree = false;
|
|
3109
3333
|
try {
|
|
3110
3334
|
const cost = options.customCost ?? getOperationCost2(options.operationType);
|
|
3111
|
-
|
|
3335
|
+
isFree = options.customCost === void 0 && cost === 0;
|
|
3336
|
+
reservation = isFree ? { ...createDummyReservation(user.id, options.operationType), id: "free-operation" } : await reserveCreditsForOperation(
|
|
3112
3337
|
repository,
|
|
3113
3338
|
user.id,
|
|
3114
3339
|
cost,
|
|
@@ -3131,7 +3356,7 @@ function createGenericAdapter(config) {
|
|
|
3131
3356
|
try {
|
|
3132
3357
|
const result = await handler(user, data, reservation);
|
|
3133
3358
|
if (result.success) {
|
|
3134
|
-
await commitReservationWithJournal(repository, user.id, reservation.id);
|
|
3359
|
+
if (!isFree) await commitReservationWithJournal(repository, user.id, reservation.id);
|
|
3135
3360
|
logUsage({
|
|
3136
3361
|
userId: user.id,
|
|
3137
3362
|
operationType: options.operationType,
|
|
@@ -3142,7 +3367,7 @@ function createGenericAdapter(config) {
|
|
|
3142
3367
|
requestId
|
|
3143
3368
|
});
|
|
3144
3369
|
} else {
|
|
3145
|
-
await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
3370
|
+
if (!isFree) await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
3146
3371
|
logUsage({
|
|
3147
3372
|
userId: user.id,
|
|
3148
3373
|
operationType: options.operationType,
|
|
@@ -3157,7 +3382,7 @@ function createGenericAdapter(config) {
|
|
|
3157
3382
|
return result;
|
|
3158
3383
|
} catch (error) {
|
|
3159
3384
|
const message = error instanceof Error ? error.message : "An unexpected error occurred";
|
|
3160
|
-
await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
3385
|
+
if (!isFree) await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
3161
3386
|
logUsage({
|
|
3162
3387
|
userId: user.id,
|
|
3163
3388
|
operationType: options.operationType,
|
|
@@ -3952,12 +4177,17 @@ function createDummyReservation2(userId, operationType) {
|
|
|
3952
4177
|
assertRepresentableTierAmount,
|
|
3953
4178
|
assertUnkeyedDirectReservation,
|
|
3954
4179
|
assertUnreferencedDirectTransaction,
|
|
4180
|
+
assertValidBalanceTarget,
|
|
4181
|
+
assertValidBalanceUpdate,
|
|
3955
4182
|
assertValidCreditAmount,
|
|
3956
4183
|
assertValidIdempotencyKey,
|
|
3957
4184
|
assertValidStoredAmount,
|
|
3958
4185
|
assertValidStoredAmountRaw,
|
|
4186
|
+
assertValidTierBalanceInput,
|
|
4187
|
+
attributeExpiredBacking,
|
|
3959
4188
|
backedBalanceFloor,
|
|
3960
4189
|
calculateAvailableCredits,
|
|
4190
|
+
cappedBalance,
|
|
3961
4191
|
checkAndNotifyLowBalance,
|
|
3962
4192
|
classifyDatabaseError,
|
|
3963
4193
|
clearConfigProvider,
|
|
@@ -3983,6 +4213,7 @@ function createDummyReservation2(userId, operationType) {
|
|
|
3983
4213
|
createUnsupportedOperationError,
|
|
3984
4214
|
createUserNotFoundError,
|
|
3985
4215
|
describePaymentMismatch,
|
|
4216
|
+
expiredHoldBackingPatch,
|
|
3986
4217
|
generateId,
|
|
3987
4218
|
generateRequestId,
|
|
3988
4219
|
genericDeferred,
|
|
@@ -4041,6 +4272,9 @@ function createDummyReservation2(userId, operationType) {
|
|
|
4041
4272
|
parseApiError,
|
|
4042
4273
|
parseCreditError,
|
|
4043
4274
|
parseTier,
|
|
4275
|
+
planBalanceCap,
|
|
4276
|
+
planBalanceTarget,
|
|
4277
|
+
planHoldCommit,
|
|
4044
4278
|
registerConfigProvider,
|
|
4045
4279
|
registerNotificationHandler,
|
|
4046
4280
|
releaseReservationWithJournal,
|
|
@@ -4050,6 +4284,8 @@ function createDummyReservation2(userId, operationType) {
|
|
|
4050
4284
|
reserveThroughRepository,
|
|
4051
4285
|
resetConfig,
|
|
4052
4286
|
sameAmount,
|
|
4287
|
+
settleExpiredHoldBacking,
|
|
4288
|
+
spendableBalance,
|
|
4053
4289
|
storedMonthlyLimit,
|
|
4054
4290
|
sumAmounts,
|
|
4055
4291
|
supportsCreditsV2,
|
|
@@ -4061,6 +4297,7 @@ function createDummyReservation2(userId, operationType) {
|
|
|
4061
4297
|
toClientUserCredits,
|
|
4062
4298
|
toDate,
|
|
4063
4299
|
toPortableTimestamp,
|
|
4300
|
+
validateBalanceUpdate,
|
|
4064
4301
|
verifyAdminApiKey,
|
|
4065
4302
|
verifyBearerToken
|
|
4066
4303
|
});
|