@nehorai/credits 2.1.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 -0
- package/dist/adapters/index.cjs +2 -2
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.d.cts +2 -2
- package/dist/adapters/index.d.ts +2 -2
- package/dist/adapters/index.js +2 -2
- package/dist/adapters/index.js.map +1 -1
- package/dist/core/index.cjs +102 -2
- 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 +92 -2
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +272 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +260 -39
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +247 -39
- 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 +244 -38
- 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 +8 -4
- 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 +8 -4
- package/dist/service/index.js.map +1 -1
- package/dist/{types-BhCdep_t.d.ts → types-3c-IAwZI.d.ts} +38 -3
- package/dist/{types-Dzc443vy.d.cts → types-MaViK5tD.d.cts} +18 -0
- package/dist/{types-Dzc443vy.d.ts → types-MaViK5tD.d.ts} +18 -0
- package/dist/{types-wtksMa4n.d.cts → types-XSjNXO5l.d.cts} +38 -3
- package/package.json +70 -71
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);
|
|
@@ -797,9 +889,9 @@ var creditSystemConfigSchema = import_zod.z.object({
|
|
|
797
889
|
*/
|
|
798
890
|
reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
|
|
799
891
|
/**
|
|
800
|
-
* Default credits for new users (free tier)
|
|
892
|
+
* Default credits for new users (free tier). 0 means no signup grant.
|
|
801
893
|
*/
|
|
802
|
-
defaultFreeCredits: import_zod.z.number().
|
|
894
|
+
defaultFreeCredits: import_zod.z.number().min(0).default(25),
|
|
803
895
|
/**
|
|
804
896
|
* Grace period in days after subscription expires before downgrade
|
|
805
897
|
*/
|
|
@@ -1099,6 +1191,8 @@ function toClientUserCredits(credits) {
|
|
|
1099
1191
|
monthlyUsed: credits.monthlyUsed,
|
|
1100
1192
|
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
1101
1193
|
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
1194
|
+
...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
|
|
1195
|
+
...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
|
|
1102
1196
|
createdAt: toISOString(credits.createdAt),
|
|
1103
1197
|
updatedAt: toISOString(credits.updatedAt)
|
|
1104
1198
|
};
|
|
@@ -1330,11 +1424,36 @@ function toDate2(value) {
|
|
|
1330
1424
|
return /* @__PURE__ */ new Date();
|
|
1331
1425
|
}
|
|
1332
1426
|
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
next
|
|
1337
|
-
|
|
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
|
+
}
|
|
1338
1457
|
}
|
|
1339
1458
|
|
|
1340
1459
|
// src/repository/memory/snapshot.ts
|
|
@@ -1514,6 +1633,22 @@ function assertTrustworthy(reservation, transition) {
|
|
|
1514
1633
|
assertLockedAmount(reservation, transition);
|
|
1515
1634
|
assertHoldPlaced(reservation, transition);
|
|
1516
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
|
+
}
|
|
1517
1652
|
function getReservation(store, userId, reservationId) {
|
|
1518
1653
|
return store.reservations.get(userId)?.get(reservationId);
|
|
1519
1654
|
}
|
|
@@ -1611,16 +1746,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1611
1746
|
const amount = reservation.amount;
|
|
1612
1747
|
await yieldPoint(store);
|
|
1613
1748
|
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1614
|
-
|
|
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) {
|
|
1615
1757
|
throw new CreditError(
|
|
1616
1758
|
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1617
1759
|
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1618
1760
|
{ userId, reservationId, required: amount }
|
|
1619
1761
|
);
|
|
1620
1762
|
}
|
|
1621
|
-
const
|
|
1622
|
-
const
|
|
1623
|
-
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1763
|
+
const nextBalance = paid.balance;
|
|
1764
|
+
const nextBonusCredits = paid.bonusCredits;
|
|
1624
1765
|
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1625
1766
|
const journal = {
|
|
1626
1767
|
userId,
|
|
@@ -1654,6 +1795,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1654
1795
|
credits.bonusCredits = nextBonusCredits;
|
|
1655
1796
|
credits.reserved = nextReserved;
|
|
1656
1797
|
credits.monthlyUsed = nextMonthlyUsed;
|
|
1798
|
+
Object.assign(
|
|
1799
|
+
credits,
|
|
1800
|
+
expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
|
|
1801
|
+
);
|
|
1657
1802
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1658
1803
|
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1659
1804
|
return {
|
|
@@ -1733,19 +1878,26 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1733
1878
|
reservation.id,
|
|
1734
1879
|
reservation.amount
|
|
1735
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);
|
|
1736
1887
|
const journal = {
|
|
1737
1888
|
userId: reservation.userId,
|
|
1738
1889
|
operation: `${transition.transition}Reservation`,
|
|
1739
1890
|
entryType: "credit",
|
|
1740
1891
|
amount: 0,
|
|
1741
|
-
balanceAfter: sumAmounts(
|
|
1892
|
+
balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
|
|
1742
1893
|
source: transition.source,
|
|
1743
1894
|
reservationId: reservation.id,
|
|
1744
1895
|
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1745
1896
|
metadata: {
|
|
1746
1897
|
...transition.options?.metadata,
|
|
1747
1898
|
operationType: reservation.operationType,
|
|
1748
|
-
amount: reservation.amount
|
|
1899
|
+
amount: reservation.amount,
|
|
1900
|
+
...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
|
|
1749
1901
|
},
|
|
1750
1902
|
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1751
1903
|
};
|
|
@@ -1758,6 +1910,8 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1758
1910
|
reservation.status = transition.status;
|
|
1759
1911
|
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1760
1912
|
credits.reserved = nextReserved;
|
|
1913
|
+
credits.balance = nextBalance;
|
|
1914
|
+
Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
|
|
1761
1915
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1762
1916
|
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1763
1917
|
}
|
|
@@ -1768,6 +1922,10 @@ function isoOf(value) {
|
|
|
1768
1922
|
}
|
|
1769
1923
|
var InMemoryCreditRepository = class {
|
|
1770
1924
|
store = new MemoryStore();
|
|
1925
|
+
options;
|
|
1926
|
+
constructor(options = {}) {
|
|
1927
|
+
this.options = options;
|
|
1928
|
+
}
|
|
1771
1929
|
// ==================== User Credits ====================
|
|
1772
1930
|
/**
|
|
1773
1931
|
* A copy, never the live record.
|
|
@@ -1784,7 +1942,8 @@ var InMemoryCreditRepository = class {
|
|
|
1784
1942
|
return copyRecord(credits) ?? null;
|
|
1785
1943
|
}
|
|
1786
1944
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1787
|
-
|
|
1945
|
+
const existing = this.store.users.get(userId);
|
|
1946
|
+
return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
|
|
1788
1947
|
}
|
|
1789
1948
|
/**
|
|
1790
1949
|
* The synchronous body of `initializeUserCredits`.
|
|
@@ -1835,8 +1994,11 @@ var InMemoryCreditRepository = class {
|
|
|
1835
1994
|
if (!credits) {
|
|
1836
1995
|
throw new Error(`User ${userId} not found`);
|
|
1837
1996
|
}
|
|
1997
|
+
assertValidBalanceUpdate(credits, updates);
|
|
1838
1998
|
const next = { ...credits };
|
|
1839
|
-
if (updates.balance !== void 0)
|
|
1999
|
+
if (updates.balance !== void 0) {
|
|
2000
|
+
next.balance = updates.balance;
|
|
2001
|
+
}
|
|
1840
2002
|
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1841
2003
|
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1842
2004
|
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
@@ -1873,6 +2035,9 @@ var InMemoryCreditRepository = class {
|
|
|
1873
2035
|
userId,
|
|
1874
2036
|
operation: "updateUserCredits"
|
|
1875
2037
|
});
|
|
2038
|
+
if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
|
|
2039
|
+
writeExpiredHoldBacking(this.store, next, 0);
|
|
2040
|
+
}
|
|
1876
2041
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1877
2042
|
this.store.users.set(userId, next);
|
|
1878
2043
|
}
|
|
@@ -1881,17 +2046,28 @@ var InMemoryCreditRepository = class {
|
|
|
1881
2046
|
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1882
2047
|
{ userId, operation: "updateUserTier" }
|
|
1883
2048
|
);
|
|
2049
|
+
assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
|
|
1884
2050
|
const credits = this.store.users.get(userId);
|
|
1885
2051
|
if (!credits) {
|
|
1886
2052
|
throw new Error(`User ${userId} not found`);
|
|
1887
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
|
+
}
|
|
1888
2065
|
credits.tier = input.tier;
|
|
1889
2066
|
credits.monthlyLimit = input.monthlyLimit;
|
|
1890
2067
|
if (input.balance !== void 0) {
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
);
|
|
2068
|
+
const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
2069
|
+
credits.balance = planned.balance;
|
|
2070
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1895
2071
|
}
|
|
1896
2072
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
1897
2073
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
@@ -2112,13 +2288,17 @@ var InMemoryCreditRepository = class {
|
|
|
2112
2288
|
if (!credits) {
|
|
2113
2289
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
2114
2290
|
}
|
|
2115
|
-
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
|
+
);
|
|
2116
2296
|
if (available < amount) {
|
|
2117
2297
|
throw new Error(
|
|
2118
2298
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
2119
2299
|
);
|
|
2120
2300
|
}
|
|
2121
|
-
const balanceDeduction = Math.min(
|
|
2301
|
+
const balanceDeduction = Math.min(spendable, amount);
|
|
2122
2302
|
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
2123
2303
|
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
2124
2304
|
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
@@ -2355,7 +2535,11 @@ var InMemoryCreditRepository = class {
|
|
|
2355
2535
|
return { expiredCount, creditsReleased, errors };
|
|
2356
2536
|
}
|
|
2357
2537
|
// ==================== Atomic Monthly Reset ====================
|
|
2358
|
-
|
|
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) {
|
|
2359
2543
|
const credits = this.store.users.get(userId);
|
|
2360
2544
|
if (!credits) {
|
|
2361
2545
|
throw new Error(`User ${userId} not found`);
|
|
@@ -2365,17 +2549,41 @@ var InMemoryCreditRepository = class {
|
|
|
2365
2549
|
if (currentResetAt !== expected) {
|
|
2366
2550
|
return { wasReset: false, credits: { ...credits } };
|
|
2367
2551
|
}
|
|
2368
|
-
|
|
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
|
+
}
|
|
2369
2569
|
const nextReset = getNextMonthlyReset();
|
|
2370
|
-
const target = monthlyResetBalance(tier);
|
|
2371
|
-
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
2372
2570
|
const previousBalance = credits.balance;
|
|
2373
|
-
|
|
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;
|
|
2374
2580
|
const change = sumAmounts(newBalance, -previousBalance);
|
|
2375
2581
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2376
2582
|
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
2377
2583
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2378
2584
|
credits.balance = newBalance;
|
|
2585
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2586
|
+
if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
|
|
2379
2587
|
credits.monthlyUsed = 0;
|
|
2380
2588
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
2381
2589
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2429,23 +2637,34 @@ var InMemoryCreditRepository = class {
|
|
|
2429
2637
|
};
|
|
2430
2638
|
}
|
|
2431
2639
|
const defaultTier = getDefaultTier();
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
tier: defaultTier
|
|
2436
|
-
}
|
|
2437
|
-
|
|
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
|
+
}
|
|
2438
2652
|
const previousTier = credits.tier;
|
|
2439
2653
|
const previousBalance = credits.balance;
|
|
2440
|
-
const
|
|
2441
|
-
|
|
2442
|
-
|
|
2654
|
+
const planned = planBalanceCap(
|
|
2655
|
+
credits.balance,
|
|
2656
|
+
limit,
|
|
2657
|
+
credits.reserved,
|
|
2658
|
+
credits.bonusCredits,
|
|
2659
|
+
credits.expiredHoldBacking
|
|
2443
2660
|
);
|
|
2661
|
+
const newBalance = planned.balance;
|
|
2444
2662
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2445
2663
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2446
2664
|
credits.tier = defaultTier;
|
|
2447
2665
|
credits.monthlyLimit = limit;
|
|
2448
2666
|
credits.balance = newBalance;
|
|
2667
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2449
2668
|
credits.subscriptionExpiresAt = null;
|
|
2450
2669
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2451
2670
|
this.store.users.set(userId, credits);
|
|
@@ -2503,8 +2722,8 @@ var InMemoryCreditRepository = class {
|
|
|
2503
2722
|
return copyRecords(Array.from(userReservations.values()));
|
|
2504
2723
|
}
|
|
2505
2724
|
};
|
|
2506
|
-
function createInMemoryCreditRepository() {
|
|
2507
|
-
return new InMemoryCreditRepository();
|
|
2725
|
+
function createInMemoryCreditRepository(options = {}) {
|
|
2726
|
+
return new InMemoryCreditRepository(options);
|
|
2508
2727
|
}
|
|
2509
2728
|
|
|
2510
2729
|
// src/auth/api-key.ts
|
|
@@ -2667,9 +2886,11 @@ var CreditsService = class {
|
|
|
2667
2886
|
source: "monthly_reset",
|
|
2668
2887
|
referenceId: `reset-${Date.now()}`,
|
|
2669
2888
|
referenceType: "reset",
|
|
2670
|
-
|
|
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.`,
|
|
2671
2892
|
metadata: {
|
|
2672
|
-
tier:
|
|
2893
|
+
tier: resetResult.credits.tier,
|
|
2673
2894
|
previousBalance: data.balance,
|
|
2674
2895
|
newBalance: resetResult.credits.balance
|
|
2675
2896
|
}
|
|
@@ -3956,12 +4177,17 @@ function createDummyReservation2(userId, operationType) {
|
|
|
3956
4177
|
assertRepresentableTierAmount,
|
|
3957
4178
|
assertUnkeyedDirectReservation,
|
|
3958
4179
|
assertUnreferencedDirectTransaction,
|
|
4180
|
+
assertValidBalanceTarget,
|
|
4181
|
+
assertValidBalanceUpdate,
|
|
3959
4182
|
assertValidCreditAmount,
|
|
3960
4183
|
assertValidIdempotencyKey,
|
|
3961
4184
|
assertValidStoredAmount,
|
|
3962
4185
|
assertValidStoredAmountRaw,
|
|
4186
|
+
assertValidTierBalanceInput,
|
|
4187
|
+
attributeExpiredBacking,
|
|
3963
4188
|
backedBalanceFloor,
|
|
3964
4189
|
calculateAvailableCredits,
|
|
4190
|
+
cappedBalance,
|
|
3965
4191
|
checkAndNotifyLowBalance,
|
|
3966
4192
|
classifyDatabaseError,
|
|
3967
4193
|
clearConfigProvider,
|
|
@@ -3987,6 +4213,7 @@ function createDummyReservation2(userId, operationType) {
|
|
|
3987
4213
|
createUnsupportedOperationError,
|
|
3988
4214
|
createUserNotFoundError,
|
|
3989
4215
|
describePaymentMismatch,
|
|
4216
|
+
expiredHoldBackingPatch,
|
|
3990
4217
|
generateId,
|
|
3991
4218
|
generateRequestId,
|
|
3992
4219
|
genericDeferred,
|
|
@@ -4045,6 +4272,9 @@ function createDummyReservation2(userId, operationType) {
|
|
|
4045
4272
|
parseApiError,
|
|
4046
4273
|
parseCreditError,
|
|
4047
4274
|
parseTier,
|
|
4275
|
+
planBalanceCap,
|
|
4276
|
+
planBalanceTarget,
|
|
4277
|
+
planHoldCommit,
|
|
4048
4278
|
registerConfigProvider,
|
|
4049
4279
|
registerNotificationHandler,
|
|
4050
4280
|
releaseReservationWithJournal,
|
|
@@ -4054,6 +4284,8 @@ function createDummyReservation2(userId, operationType) {
|
|
|
4054
4284
|
reserveThroughRepository,
|
|
4055
4285
|
resetConfig,
|
|
4056
4286
|
sameAmount,
|
|
4287
|
+
settleExpiredHoldBacking,
|
|
4288
|
+
spendableBalance,
|
|
4057
4289
|
storedMonthlyLimit,
|
|
4058
4290
|
sumAmounts,
|
|
4059
4291
|
supportsCreditsV2,
|
|
@@ -4065,6 +4297,7 @@ function createDummyReservation2(userId, operationType) {
|
|
|
4065
4297
|
toClientUserCredits,
|
|
4066
4298
|
toDate,
|
|
4067
4299
|
toPortableTimestamp,
|
|
4300
|
+
validateBalanceUpdate,
|
|
4068
4301
|
verifyAdminApiKey,
|
|
4069
4302
|
verifyBearerToken
|
|
4070
4303
|
});
|