@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
|
@@ -22,6 +22,7 @@ var repository_exports = {};
|
|
|
22
22
|
__export(repository_exports, {
|
|
23
23
|
InMemoryCreditRepository: () => InMemoryCreditRepository,
|
|
24
24
|
addCreditsThroughRepository: () => addCreditsThroughRepository,
|
|
25
|
+
assertValidBalanceUpdate: () => assertValidBalanceUpdate,
|
|
25
26
|
commitThroughRepository: () => commitThroughRepository,
|
|
26
27
|
createInMemoryCreditRepository: () => createInMemoryCreditRepository,
|
|
27
28
|
generateId: () => generateId,
|
|
@@ -32,7 +33,8 @@ __export(repository_exports, {
|
|
|
32
33
|
supportsCreditsV2: () => supportsCreditsV2,
|
|
33
34
|
supportsIdempotentCredit: () => supportsIdempotentCredit,
|
|
34
35
|
toClientUserCredits: () => toClientUserCredits,
|
|
35
|
-
toDate: () => toDate
|
|
36
|
+
toDate: () => toDate,
|
|
37
|
+
validateBalanceUpdate: () => validateBalanceUpdate
|
|
36
38
|
});
|
|
37
39
|
module.exports = __toCommonJS(repository_exports);
|
|
38
40
|
|
|
@@ -54,6 +56,8 @@ function toClientUserCredits(credits) {
|
|
|
54
56
|
monthlyUsed: credits.monthlyUsed,
|
|
55
57
|
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
56
58
|
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
59
|
+
...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
|
|
60
|
+
...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
|
|
57
61
|
createdAt: toISOString(credits.createdAt),
|
|
58
62
|
updatedAt: toISOString(credits.updatedAt)
|
|
59
63
|
};
|
|
@@ -144,6 +148,83 @@ function backedBalanceFloor(reserved, bonusCredits) {
|
|
|
144
148
|
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
145
149
|
return floor;
|
|
146
150
|
}
|
|
151
|
+
function spendableBalance(balance, expiredHoldBacking = 0) {
|
|
152
|
+
return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
|
|
153
|
+
}
|
|
154
|
+
function planBalanceTarget(target, reserved, bonusCredits) {
|
|
155
|
+
const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
|
|
156
|
+
return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
|
|
157
|
+
}
|
|
158
|
+
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
|
|
159
|
+
const spendable = spendableBalance(balance, expiredHoldBacking);
|
|
160
|
+
const target = Math.min(spendable, cap);
|
|
161
|
+
const kept = Math.max(
|
|
162
|
+
sumAmounts(balance, -spendable),
|
|
163
|
+
sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
|
|
164
|
+
);
|
|
165
|
+
const next = Math.min(balance, sumAmounts(target, kept));
|
|
166
|
+
return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
|
|
167
|
+
}
|
|
168
|
+
function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
|
|
169
|
+
const held = Math.min(expiredHoldBacking, balance);
|
|
170
|
+
const used = Math.min(held, holdBacking);
|
|
171
|
+
return { used, remaining: sumAmounts(held, -used) };
|
|
172
|
+
}
|
|
173
|
+
function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
|
|
174
|
+
const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
|
|
175
|
+
const rest = sumAmounts(amount, -backing.used);
|
|
176
|
+
const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
|
|
177
|
+
const fromBonus = sumAmounts(rest, -fromSpendable);
|
|
178
|
+
return {
|
|
179
|
+
covered: fromBonus <= bonusCredits,
|
|
180
|
+
balance: sumAmounts(balance, -backing.used, -fromSpendable),
|
|
181
|
+
bonusCredits: sumAmounts(bonusCredits, -fromBonus),
|
|
182
|
+
expiredHoldBacking: backing.remaining
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function attributeExpiredBacking(holds, total) {
|
|
186
|
+
const placed = holds.filter((hold) => hold.holdPlacedAt).sort(
|
|
187
|
+
(a, b) => a.holdPlacedAt === b.holdPlacedAt ? a.id < b.id ? -1 : 1 : a.holdPlacedAt < b.holdPlacedAt ? -1 : 1
|
|
188
|
+
);
|
|
189
|
+
const shares = /* @__PURE__ */ new Map();
|
|
190
|
+
let left = total;
|
|
191
|
+
for (const hold of placed) {
|
|
192
|
+
const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);
|
|
193
|
+
shares.set(hold.id, kept);
|
|
194
|
+
left = sumAmounts(left, -kept);
|
|
195
|
+
}
|
|
196
|
+
for (const hold of placed) {
|
|
197
|
+
const share = shares.get(hold.id);
|
|
198
|
+
const added = Math.min(sumAmounts(hold.amount, -share), left);
|
|
199
|
+
shares.set(hold.id, sumAmounts(share, added));
|
|
200
|
+
left = sumAmounts(left, -added);
|
|
201
|
+
}
|
|
202
|
+
return shares;
|
|
203
|
+
}
|
|
204
|
+
function expiredHoldBackingPatch(next, stored = 0) {
|
|
205
|
+
return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
|
|
206
|
+
}
|
|
207
|
+
function assertValidBalanceTarget(value, field, context) {
|
|
208
|
+
assertRepresentableAmount(value, field, context);
|
|
209
|
+
if (value < 0) {
|
|
210
|
+
throw new CreditError(
|
|
211
|
+
`${field} cannot be negative (got ${value})`,
|
|
212
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
213
|
+
{ field, value, ...context }
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function assertValidTierBalanceInput(input, context) {
|
|
218
|
+
if (input.balanceCap === void 0) return;
|
|
219
|
+
if (input.balance !== void 0) {
|
|
220
|
+
throw new CreditError(
|
|
221
|
+
"A tier update cannot set both balance and balanceCap",
|
|
222
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
223
|
+
{ balance: input.balance, balanceCap: input.balanceCap, ...context }
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
|
|
227
|
+
}
|
|
147
228
|
function assertRepresentableTierAmount(value, field, context) {
|
|
148
229
|
if (value === Number.POSITIVE_INFINITY) return;
|
|
149
230
|
assertRepresentableAmount(value, field, context);
|
|
@@ -361,9 +442,9 @@ var creditSystemConfigSchema = import_zod.z.object({
|
|
|
361
442
|
*/
|
|
362
443
|
reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
|
|
363
444
|
/**
|
|
364
|
-
* Default credits for new users (free tier)
|
|
445
|
+
* Default credits for new users (free tier). 0 means no signup grant.
|
|
365
446
|
*/
|
|
366
|
-
defaultFreeCredits: import_zod.z.number().
|
|
447
|
+
defaultFreeCredits: import_zod.z.number().min(0).default(25),
|
|
367
448
|
/**
|
|
368
449
|
* Grace period in days after subscription expires before downgrade
|
|
369
450
|
*/
|
|
@@ -793,11 +874,36 @@ function toDate(value) {
|
|
|
793
874
|
return /* @__PURE__ */ new Date();
|
|
794
875
|
}
|
|
795
876
|
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
next
|
|
800
|
-
|
|
877
|
+
return new Date(from.getFullYear(), from.getMonth() + 1, 1);
|
|
878
|
+
}
|
|
879
|
+
function validateBalanceUpdate(current, updates) {
|
|
880
|
+
const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
|
|
881
|
+
const balance = next(current.balance, updates.balance, updates.balanceIncrement);
|
|
882
|
+
const bonusCredits = next(
|
|
883
|
+
current.bonusCredits ?? 0,
|
|
884
|
+
updates.bonusCredits,
|
|
885
|
+
updates.bonusCreditsIncrement
|
|
886
|
+
);
|
|
887
|
+
const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
|
|
888
|
+
const errors = [];
|
|
889
|
+
if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
|
|
890
|
+
const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
|
|
891
|
+
if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
|
|
892
|
+
errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
|
|
893
|
+
}
|
|
894
|
+
if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
|
|
895
|
+
if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
|
|
896
|
+
const covering = sumAmounts(balance, bonusCredits);
|
|
897
|
+
if (reserved > covering) {
|
|
898
|
+
errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
|
|
899
|
+
}
|
|
900
|
+
return { valid: errors.length === 0, errors };
|
|
901
|
+
}
|
|
902
|
+
function assertValidBalanceUpdate(current, updates) {
|
|
903
|
+
const result = validateBalanceUpdate(current, updates);
|
|
904
|
+
if (!result.valid) {
|
|
905
|
+
throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
|
|
906
|
+
}
|
|
801
907
|
}
|
|
802
908
|
|
|
803
909
|
// src/repository/memory/snapshot.ts
|
|
@@ -1078,6 +1184,22 @@ function assertTrustworthy(reservation, transition) {
|
|
|
1078
1184
|
assertLockedAmount(reservation, transition);
|
|
1079
1185
|
assertHoldPlaced(reservation, transition);
|
|
1080
1186
|
}
|
|
1187
|
+
function openHolds(store, userId) {
|
|
1188
|
+
return [...store.reservations.get(userId)?.values() ?? []].filter(
|
|
1189
|
+
(reservation) => reservation.status === "reserved"
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
function writeExpiredHoldBacking(store, credits, next) {
|
|
1193
|
+
const stored = credits.expiredHoldBacking;
|
|
1194
|
+
if (next <= 0 && !stored) return;
|
|
1195
|
+
const holds = openHolds(store, credits.userId);
|
|
1196
|
+
const shares = attributeExpiredBacking(holds, next);
|
|
1197
|
+
for (const hold of holds) {
|
|
1198
|
+
const share = shares.get(hold.id) ?? 0;
|
|
1199
|
+
if (share !== (hold.expiredBacking ?? 0)) hold.expiredBacking = share;
|
|
1200
|
+
}
|
|
1201
|
+
Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
|
|
1202
|
+
}
|
|
1081
1203
|
function getReservation(store, userId, reservationId) {
|
|
1082
1204
|
return store.reservations.get(userId)?.get(reservationId);
|
|
1083
1205
|
}
|
|
@@ -1175,16 +1297,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1175
1297
|
const amount = reservation.amount;
|
|
1176
1298
|
await yieldPoint(store);
|
|
1177
1299
|
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1178
|
-
|
|
1300
|
+
const paid = planHoldCommit(
|
|
1301
|
+
credits.balance,
|
|
1302
|
+
credits.bonusCredits,
|
|
1303
|
+
credits.expiredHoldBacking ?? 0,
|
|
1304
|
+
reservation.expiredBacking ?? 0,
|
|
1305
|
+
amount
|
|
1306
|
+
);
|
|
1307
|
+
if (!paid.covered) {
|
|
1179
1308
|
throw new CreditError(
|
|
1180
1309
|
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1181
1310
|
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1182
1311
|
{ userId, reservationId, required: amount }
|
|
1183
1312
|
);
|
|
1184
1313
|
}
|
|
1185
|
-
const
|
|
1186
|
-
const
|
|
1187
|
-
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1314
|
+
const nextBalance = paid.balance;
|
|
1315
|
+
const nextBonusCredits = paid.bonusCredits;
|
|
1188
1316
|
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1189
1317
|
const journal = {
|
|
1190
1318
|
userId,
|
|
@@ -1218,6 +1346,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1218
1346
|
credits.bonusCredits = nextBonusCredits;
|
|
1219
1347
|
credits.reserved = nextReserved;
|
|
1220
1348
|
credits.monthlyUsed = nextMonthlyUsed;
|
|
1349
|
+
Object.assign(
|
|
1350
|
+
credits,
|
|
1351
|
+
expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
|
|
1352
|
+
);
|
|
1221
1353
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1222
1354
|
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1223
1355
|
return {
|
|
@@ -1297,19 +1429,26 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1297
1429
|
reservation.id,
|
|
1298
1430
|
reservation.amount
|
|
1299
1431
|
);
|
|
1432
|
+
const backing = settleExpiredHoldBacking(
|
|
1433
|
+
credits.balance,
|
|
1434
|
+
credits.expiredHoldBacking ?? 0,
|
|
1435
|
+
reservation.expiredBacking ?? 0
|
|
1436
|
+
);
|
|
1437
|
+
const nextBalance = sumAmounts(credits.balance, -backing.used);
|
|
1300
1438
|
const journal = {
|
|
1301
1439
|
userId: reservation.userId,
|
|
1302
1440
|
operation: `${transition.transition}Reservation`,
|
|
1303
1441
|
entryType: "credit",
|
|
1304
1442
|
amount: 0,
|
|
1305
|
-
balanceAfter: sumAmounts(
|
|
1443
|
+
balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
|
|
1306
1444
|
source: transition.source,
|
|
1307
1445
|
reservationId: reservation.id,
|
|
1308
1446
|
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1309
1447
|
metadata: {
|
|
1310
1448
|
...transition.options?.metadata,
|
|
1311
1449
|
operationType: reservation.operationType,
|
|
1312
|
-
amount: reservation.amount
|
|
1450
|
+
amount: reservation.amount,
|
|
1451
|
+
...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
|
|
1313
1452
|
},
|
|
1314
1453
|
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1315
1454
|
};
|
|
@@ -1322,6 +1461,8 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1322
1461
|
reservation.status = transition.status;
|
|
1323
1462
|
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1324
1463
|
credits.reserved = nextReserved;
|
|
1464
|
+
credits.balance = nextBalance;
|
|
1465
|
+
Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
|
|
1325
1466
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1326
1467
|
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1327
1468
|
}
|
|
@@ -1332,6 +1473,10 @@ function isoOf(value) {
|
|
|
1332
1473
|
}
|
|
1333
1474
|
var InMemoryCreditRepository = class {
|
|
1334
1475
|
store = new MemoryStore();
|
|
1476
|
+
options;
|
|
1477
|
+
constructor(options = {}) {
|
|
1478
|
+
this.options = options;
|
|
1479
|
+
}
|
|
1335
1480
|
// ==================== User Credits ====================
|
|
1336
1481
|
/**
|
|
1337
1482
|
* A copy, never the live record.
|
|
@@ -1348,7 +1493,8 @@ var InMemoryCreditRepository = class {
|
|
|
1348
1493
|
return copyRecord(credits) ?? null;
|
|
1349
1494
|
}
|
|
1350
1495
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1351
|
-
|
|
1496
|
+
const existing = this.store.users.get(userId);
|
|
1497
|
+
return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
|
|
1352
1498
|
}
|
|
1353
1499
|
/**
|
|
1354
1500
|
* The synchronous body of `initializeUserCredits`.
|
|
@@ -1399,8 +1545,11 @@ var InMemoryCreditRepository = class {
|
|
|
1399
1545
|
if (!credits) {
|
|
1400
1546
|
throw new Error(`User ${userId} not found`);
|
|
1401
1547
|
}
|
|
1548
|
+
assertValidBalanceUpdate(credits, updates);
|
|
1402
1549
|
const next = { ...credits };
|
|
1403
|
-
if (updates.balance !== void 0)
|
|
1550
|
+
if (updates.balance !== void 0) {
|
|
1551
|
+
next.balance = updates.balance;
|
|
1552
|
+
}
|
|
1404
1553
|
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1405
1554
|
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1406
1555
|
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
@@ -1437,6 +1586,9 @@ var InMemoryCreditRepository = class {
|
|
|
1437
1586
|
userId,
|
|
1438
1587
|
operation: "updateUserCredits"
|
|
1439
1588
|
});
|
|
1589
|
+
if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
|
|
1590
|
+
writeExpiredHoldBacking(this.store, next, 0);
|
|
1591
|
+
}
|
|
1440
1592
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1441
1593
|
this.store.users.set(userId, next);
|
|
1442
1594
|
}
|
|
@@ -1445,17 +1597,28 @@ var InMemoryCreditRepository = class {
|
|
|
1445
1597
|
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1446
1598
|
{ userId, operation: "updateUserTier" }
|
|
1447
1599
|
);
|
|
1600
|
+
assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
|
|
1448
1601
|
const credits = this.store.users.get(userId);
|
|
1449
1602
|
if (!credits) {
|
|
1450
1603
|
throw new Error(`User ${userId} not found`);
|
|
1451
1604
|
}
|
|
1605
|
+
if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
|
|
1606
|
+
const capped = planBalanceCap(
|
|
1607
|
+
credits.balance,
|
|
1608
|
+
input.balanceCap,
|
|
1609
|
+
credits.reserved,
|
|
1610
|
+
credits.bonusCredits,
|
|
1611
|
+
credits.expiredHoldBacking
|
|
1612
|
+
);
|
|
1613
|
+
credits.balance = capped.balance;
|
|
1614
|
+
writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
|
|
1615
|
+
}
|
|
1452
1616
|
credits.tier = input.tier;
|
|
1453
1617
|
credits.monthlyLimit = input.monthlyLimit;
|
|
1454
1618
|
if (input.balance !== void 0) {
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
);
|
|
1619
|
+
const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
1620
|
+
credits.balance = planned.balance;
|
|
1621
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1459
1622
|
}
|
|
1460
1623
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
1461
1624
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
@@ -1676,13 +1839,17 @@ var InMemoryCreditRepository = class {
|
|
|
1676
1839
|
if (!credits) {
|
|
1677
1840
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
1678
1841
|
}
|
|
1679
|
-
const
|
|
1842
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
1843
|
+
const available = Math.min(
|
|
1844
|
+
sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
|
|
1845
|
+
sumAmounts(spendable, credits.bonusCredits)
|
|
1846
|
+
);
|
|
1680
1847
|
if (available < amount) {
|
|
1681
1848
|
throw new Error(
|
|
1682
1849
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
1683
1850
|
);
|
|
1684
1851
|
}
|
|
1685
|
-
const balanceDeduction = Math.min(
|
|
1852
|
+
const balanceDeduction = Math.min(spendable, amount);
|
|
1686
1853
|
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
1687
1854
|
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
1688
1855
|
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
@@ -1919,7 +2086,11 @@ var InMemoryCreditRepository = class {
|
|
|
1919
2086
|
return { expiredCount, creditsReleased, errors };
|
|
1920
2087
|
}
|
|
1921
2088
|
// ==================== Atomic Monthly Reset ====================
|
|
1922
|
-
|
|
2089
|
+
/**
|
|
2090
|
+
* @param _tier - Not trusted. The caller read it earlier and the account may
|
|
2091
|
+
* have changed tier since, so the reset uses the tier stored on the account.
|
|
2092
|
+
*/
|
|
2093
|
+
async atomicMonthlyReset(userId, _tier, expectedResetAt) {
|
|
1923
2094
|
const credits = this.store.users.get(userId);
|
|
1924
2095
|
if (!credits) {
|
|
1925
2096
|
throw new Error(`User ${userId} not found`);
|
|
@@ -1929,17 +2100,41 @@ var InMemoryCreditRepository = class {
|
|
|
1929
2100
|
if (currentResetAt !== expected) {
|
|
1930
2101
|
return { wasReset: false, credits: { ...credits } };
|
|
1931
2102
|
}
|
|
1932
|
-
|
|
2103
|
+
const tier = credits.tier;
|
|
2104
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
2105
|
+
const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
|
|
2106
|
+
if (decision === null) {
|
|
2107
|
+
return { wasReset: false, credits: { ...credits } };
|
|
2108
|
+
}
|
|
2109
|
+
if (decision) {
|
|
2110
|
+
assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
|
|
2111
|
+
if (decision.monthlyLimit !== void 0) {
|
|
2112
|
+
assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
|
|
2113
|
+
userId,
|
|
2114
|
+
tier
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
} else {
|
|
2118
|
+
assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
|
|
2119
|
+
}
|
|
1933
2120
|
const nextReset = getNextMonthlyReset();
|
|
1934
|
-
const target = monthlyResetBalance(tier);
|
|
1935
|
-
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
1936
2121
|
const previousBalance = credits.balance;
|
|
1937
|
-
|
|
2122
|
+
let target;
|
|
2123
|
+
if (decision) {
|
|
2124
|
+
target = decision.balance;
|
|
2125
|
+
} else {
|
|
2126
|
+
const configured = monthlyResetBalance(tier);
|
|
2127
|
+
target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
|
|
2128
|
+
}
|
|
2129
|
+
const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
|
|
2130
|
+
const newBalance = planned.balance;
|
|
1938
2131
|
const change = sumAmounts(newBalance, -previousBalance);
|
|
1939
2132
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
1940
2133
|
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
1941
2134
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
1942
2135
|
credits.balance = newBalance;
|
|
2136
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2137
|
+
if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
|
|
1943
2138
|
credits.monthlyUsed = 0;
|
|
1944
2139
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
1945
2140
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1993,23 +2188,34 @@ var InMemoryCreditRepository = class {
|
|
|
1993
2188
|
};
|
|
1994
2189
|
}
|
|
1995
2190
|
const defaultTier = getDefaultTier();
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
tier: defaultTier
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2191
|
+
let limit;
|
|
2192
|
+
if (this.options.getDowngradeBalanceCap) {
|
|
2193
|
+
limit = this.options.getDowngradeBalanceCap();
|
|
2194
|
+
assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
|
|
2195
|
+
} else {
|
|
2196
|
+
const configured = getConfigMonthlyLimit(defaultTier);
|
|
2197
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", {
|
|
2198
|
+
userId,
|
|
2199
|
+
tier: defaultTier
|
|
2200
|
+
});
|
|
2201
|
+
limit = storedMonthlyLimit(configured);
|
|
2202
|
+
}
|
|
2002
2203
|
const previousTier = credits.tier;
|
|
2003
2204
|
const previousBalance = credits.balance;
|
|
2004
|
-
const
|
|
2005
|
-
|
|
2006
|
-
|
|
2205
|
+
const planned = planBalanceCap(
|
|
2206
|
+
credits.balance,
|
|
2207
|
+
limit,
|
|
2208
|
+
credits.reserved,
|
|
2209
|
+
credits.bonusCredits,
|
|
2210
|
+
credits.expiredHoldBacking
|
|
2007
2211
|
);
|
|
2212
|
+
const newBalance = planned.balance;
|
|
2008
2213
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2009
2214
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2010
2215
|
credits.tier = defaultTier;
|
|
2011
2216
|
credits.monthlyLimit = limit;
|
|
2012
2217
|
credits.balance = newBalance;
|
|
2218
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2013
2219
|
credits.subscriptionExpiresAt = null;
|
|
2014
2220
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2015
2221
|
this.store.users.set(userId, credits);
|
|
@@ -2067,13 +2273,14 @@ var InMemoryCreditRepository = class {
|
|
|
2067
2273
|
return copyRecords(Array.from(userReservations.values()));
|
|
2068
2274
|
}
|
|
2069
2275
|
};
|
|
2070
|
-
function createInMemoryCreditRepository() {
|
|
2071
|
-
return new InMemoryCreditRepository();
|
|
2276
|
+
function createInMemoryCreditRepository(options = {}) {
|
|
2277
|
+
return new InMemoryCreditRepository(options);
|
|
2072
2278
|
}
|
|
2073
2279
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2074
2280
|
0 && (module.exports = {
|
|
2075
2281
|
InMemoryCreditRepository,
|
|
2076
2282
|
addCreditsThroughRepository,
|
|
2283
|
+
assertValidBalanceUpdate,
|
|
2077
2284
|
commitThroughRepository,
|
|
2078
2285
|
createInMemoryCreditRepository,
|
|
2079
2286
|
generateId,
|
|
@@ -2084,6 +2291,7 @@ function createInMemoryCreditRepository() {
|
|
|
2084
2291
|
supportsCreditsV2,
|
|
2085
2292
|
supportsIdempotentCredit,
|
|
2086
2293
|
toClientUserCredits,
|
|
2087
|
-
toDate
|
|
2294
|
+
toDate,
|
|
2295
|
+
validateBalanceUpdate
|
|
2088
2296
|
});
|
|
2089
2297
|
//# sourceMappingURL=index.cjs.map
|