@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
|
@@ -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);
|
|
@@ -333,11 +414,13 @@ var creditSystemConfigSchema = import_zod.z.object({
|
|
|
333
414
|
/**
|
|
334
415
|
* Operation costs in credits
|
|
335
416
|
* Keys are operation type names (any non-empty string)
|
|
336
|
-
* Values are
|
|
417
|
+
* Values are non-negative numbers representing credit cost. 0 marks a free
|
|
418
|
+
* operation: `withCredits` runs it without touching the ledger. Amounts that
|
|
419
|
+
* move credits stay strictly positive, so `reserveCredits` refuses a 0.
|
|
337
420
|
*/
|
|
338
421
|
operationCosts: import_zod.z.record(
|
|
339
422
|
import_zod.z.string().min(1),
|
|
340
|
-
import_zod.z.number().
|
|
423
|
+
import_zod.z.number().nonnegative()
|
|
341
424
|
),
|
|
342
425
|
/**
|
|
343
426
|
* Display labels for operation types (English canonical labels).
|
|
@@ -359,9 +442,9 @@ var creditSystemConfigSchema = import_zod.z.object({
|
|
|
359
442
|
*/
|
|
360
443
|
reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
|
|
361
444
|
/**
|
|
362
|
-
* Default credits for new users (free tier)
|
|
445
|
+
* Default credits for new users (free tier). 0 means no signup grant.
|
|
363
446
|
*/
|
|
364
|
-
defaultFreeCredits: import_zod.z.number().
|
|
447
|
+
defaultFreeCredits: import_zod.z.number().min(0).default(25),
|
|
365
448
|
/**
|
|
366
449
|
* Grace period in days after subscription expires before downgrade
|
|
367
450
|
*/
|
|
@@ -791,11 +874,36 @@ function toDate(value) {
|
|
|
791
874
|
return /* @__PURE__ */ new Date();
|
|
792
875
|
}
|
|
793
876
|
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
next
|
|
798
|
-
|
|
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
|
+
}
|
|
799
907
|
}
|
|
800
908
|
|
|
801
909
|
// src/repository/memory/snapshot.ts
|
|
@@ -1076,6 +1184,22 @@ function assertTrustworthy(reservation, transition) {
|
|
|
1076
1184
|
assertLockedAmount(reservation, transition);
|
|
1077
1185
|
assertHoldPlaced(reservation, transition);
|
|
1078
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
|
+
}
|
|
1079
1203
|
function getReservation(store, userId, reservationId) {
|
|
1080
1204
|
return store.reservations.get(userId)?.get(reservationId);
|
|
1081
1205
|
}
|
|
@@ -1173,16 +1297,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1173
1297
|
const amount = reservation.amount;
|
|
1174
1298
|
await yieldPoint(store);
|
|
1175
1299
|
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1176
|
-
|
|
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) {
|
|
1177
1308
|
throw new CreditError(
|
|
1178
1309
|
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1179
1310
|
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1180
1311
|
{ userId, reservationId, required: amount }
|
|
1181
1312
|
);
|
|
1182
1313
|
}
|
|
1183
|
-
const
|
|
1184
|
-
const
|
|
1185
|
-
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1314
|
+
const nextBalance = paid.balance;
|
|
1315
|
+
const nextBonusCredits = paid.bonusCredits;
|
|
1186
1316
|
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1187
1317
|
const journal = {
|
|
1188
1318
|
userId,
|
|
@@ -1216,6 +1346,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1216
1346
|
credits.bonusCredits = nextBonusCredits;
|
|
1217
1347
|
credits.reserved = nextReserved;
|
|
1218
1348
|
credits.monthlyUsed = nextMonthlyUsed;
|
|
1349
|
+
Object.assign(
|
|
1350
|
+
credits,
|
|
1351
|
+
expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
|
|
1352
|
+
);
|
|
1219
1353
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1220
1354
|
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1221
1355
|
return {
|
|
@@ -1295,19 +1429,26 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1295
1429
|
reservation.id,
|
|
1296
1430
|
reservation.amount
|
|
1297
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);
|
|
1298
1438
|
const journal = {
|
|
1299
1439
|
userId: reservation.userId,
|
|
1300
1440
|
operation: `${transition.transition}Reservation`,
|
|
1301
1441
|
entryType: "credit",
|
|
1302
1442
|
amount: 0,
|
|
1303
|
-
balanceAfter: sumAmounts(
|
|
1443
|
+
balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
|
|
1304
1444
|
source: transition.source,
|
|
1305
1445
|
reservationId: reservation.id,
|
|
1306
1446
|
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1307
1447
|
metadata: {
|
|
1308
1448
|
...transition.options?.metadata,
|
|
1309
1449
|
operationType: reservation.operationType,
|
|
1310
|
-
amount: reservation.amount
|
|
1450
|
+
amount: reservation.amount,
|
|
1451
|
+
...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
|
|
1311
1452
|
},
|
|
1312
1453
|
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1313
1454
|
};
|
|
@@ -1320,6 +1461,8 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1320
1461
|
reservation.status = transition.status;
|
|
1321
1462
|
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1322
1463
|
credits.reserved = nextReserved;
|
|
1464
|
+
credits.balance = nextBalance;
|
|
1465
|
+
Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
|
|
1323
1466
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1324
1467
|
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1325
1468
|
}
|
|
@@ -1330,6 +1473,10 @@ function isoOf(value) {
|
|
|
1330
1473
|
}
|
|
1331
1474
|
var InMemoryCreditRepository = class {
|
|
1332
1475
|
store = new MemoryStore();
|
|
1476
|
+
options;
|
|
1477
|
+
constructor(options = {}) {
|
|
1478
|
+
this.options = options;
|
|
1479
|
+
}
|
|
1333
1480
|
// ==================== User Credits ====================
|
|
1334
1481
|
/**
|
|
1335
1482
|
* A copy, never the live record.
|
|
@@ -1346,7 +1493,8 @@ var InMemoryCreditRepository = class {
|
|
|
1346
1493
|
return copyRecord(credits) ?? null;
|
|
1347
1494
|
}
|
|
1348
1495
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1349
|
-
|
|
1496
|
+
const existing = this.store.users.get(userId);
|
|
1497
|
+
return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
|
|
1350
1498
|
}
|
|
1351
1499
|
/**
|
|
1352
1500
|
* The synchronous body of `initializeUserCredits`.
|
|
@@ -1397,8 +1545,11 @@ var InMemoryCreditRepository = class {
|
|
|
1397
1545
|
if (!credits) {
|
|
1398
1546
|
throw new Error(`User ${userId} not found`);
|
|
1399
1547
|
}
|
|
1548
|
+
assertValidBalanceUpdate(credits, updates);
|
|
1400
1549
|
const next = { ...credits };
|
|
1401
|
-
if (updates.balance !== void 0)
|
|
1550
|
+
if (updates.balance !== void 0) {
|
|
1551
|
+
next.balance = updates.balance;
|
|
1552
|
+
}
|
|
1402
1553
|
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1403
1554
|
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1404
1555
|
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
@@ -1435,6 +1586,9 @@ var InMemoryCreditRepository = class {
|
|
|
1435
1586
|
userId,
|
|
1436
1587
|
operation: "updateUserCredits"
|
|
1437
1588
|
});
|
|
1589
|
+
if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
|
|
1590
|
+
writeExpiredHoldBacking(this.store, next, 0);
|
|
1591
|
+
}
|
|
1438
1592
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1439
1593
|
this.store.users.set(userId, next);
|
|
1440
1594
|
}
|
|
@@ -1443,17 +1597,28 @@ var InMemoryCreditRepository = class {
|
|
|
1443
1597
|
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1444
1598
|
{ userId, operation: "updateUserTier" }
|
|
1445
1599
|
);
|
|
1600
|
+
assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
|
|
1446
1601
|
const credits = this.store.users.get(userId);
|
|
1447
1602
|
if (!credits) {
|
|
1448
1603
|
throw new Error(`User ${userId} not found`);
|
|
1449
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
|
+
}
|
|
1450
1616
|
credits.tier = input.tier;
|
|
1451
1617
|
credits.monthlyLimit = input.monthlyLimit;
|
|
1452
1618
|
if (input.balance !== void 0) {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
);
|
|
1619
|
+
const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
1620
|
+
credits.balance = planned.balance;
|
|
1621
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1457
1622
|
}
|
|
1458
1623
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
1459
1624
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
@@ -1674,13 +1839,17 @@ var InMemoryCreditRepository = class {
|
|
|
1674
1839
|
if (!credits) {
|
|
1675
1840
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
1676
1841
|
}
|
|
1677
|
-
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
|
+
);
|
|
1678
1847
|
if (available < amount) {
|
|
1679
1848
|
throw new Error(
|
|
1680
1849
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
1681
1850
|
);
|
|
1682
1851
|
}
|
|
1683
|
-
const balanceDeduction = Math.min(
|
|
1852
|
+
const balanceDeduction = Math.min(spendable, amount);
|
|
1684
1853
|
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
1685
1854
|
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
1686
1855
|
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
@@ -1917,7 +2086,11 @@ var InMemoryCreditRepository = class {
|
|
|
1917
2086
|
return { expiredCount, creditsReleased, errors };
|
|
1918
2087
|
}
|
|
1919
2088
|
// ==================== Atomic Monthly Reset ====================
|
|
1920
|
-
|
|
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) {
|
|
1921
2094
|
const credits = this.store.users.get(userId);
|
|
1922
2095
|
if (!credits) {
|
|
1923
2096
|
throw new Error(`User ${userId} not found`);
|
|
@@ -1927,17 +2100,41 @@ var InMemoryCreditRepository = class {
|
|
|
1927
2100
|
if (currentResetAt !== expected) {
|
|
1928
2101
|
return { wasReset: false, credits: { ...credits } };
|
|
1929
2102
|
}
|
|
1930
|
-
|
|
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
|
+
}
|
|
1931
2120
|
const nextReset = getNextMonthlyReset();
|
|
1932
|
-
const target = monthlyResetBalance(tier);
|
|
1933
|
-
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
1934
2121
|
const previousBalance = credits.balance;
|
|
1935
|
-
|
|
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;
|
|
1936
2131
|
const change = sumAmounts(newBalance, -previousBalance);
|
|
1937
2132
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
1938
2133
|
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
1939
2134
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
1940
2135
|
credits.balance = newBalance;
|
|
2136
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2137
|
+
if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
|
|
1941
2138
|
credits.monthlyUsed = 0;
|
|
1942
2139
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
1943
2140
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1991,23 +2188,34 @@ var InMemoryCreditRepository = class {
|
|
|
1991
2188
|
};
|
|
1992
2189
|
}
|
|
1993
2190
|
const defaultTier = getDefaultTier();
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
tier: defaultTier
|
|
1998
|
-
}
|
|
1999
|
-
|
|
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
|
+
}
|
|
2000
2203
|
const previousTier = credits.tier;
|
|
2001
2204
|
const previousBalance = credits.balance;
|
|
2002
|
-
const
|
|
2003
|
-
|
|
2004
|
-
|
|
2205
|
+
const planned = planBalanceCap(
|
|
2206
|
+
credits.balance,
|
|
2207
|
+
limit,
|
|
2208
|
+
credits.reserved,
|
|
2209
|
+
credits.bonusCredits,
|
|
2210
|
+
credits.expiredHoldBacking
|
|
2005
2211
|
);
|
|
2212
|
+
const newBalance = planned.balance;
|
|
2006
2213
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2007
2214
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2008
2215
|
credits.tier = defaultTier;
|
|
2009
2216
|
credits.monthlyLimit = limit;
|
|
2010
2217
|
credits.balance = newBalance;
|
|
2218
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2011
2219
|
credits.subscriptionExpiresAt = null;
|
|
2012
2220
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2013
2221
|
this.store.users.set(userId, credits);
|
|
@@ -2065,13 +2273,14 @@ var InMemoryCreditRepository = class {
|
|
|
2065
2273
|
return copyRecords(Array.from(userReservations.values()));
|
|
2066
2274
|
}
|
|
2067
2275
|
};
|
|
2068
|
-
function createInMemoryCreditRepository() {
|
|
2069
|
-
return new InMemoryCreditRepository();
|
|
2276
|
+
function createInMemoryCreditRepository(options = {}) {
|
|
2277
|
+
return new InMemoryCreditRepository(options);
|
|
2070
2278
|
}
|
|
2071
2279
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2072
2280
|
0 && (module.exports = {
|
|
2073
2281
|
InMemoryCreditRepository,
|
|
2074
2282
|
addCreditsThroughRepository,
|
|
2283
|
+
assertValidBalanceUpdate,
|
|
2075
2284
|
commitThroughRepository,
|
|
2076
2285
|
createInMemoryCreditRepository,
|
|
2077
2286
|
generateId,
|
|
@@ -2082,6 +2291,7 @@ function createInMemoryCreditRepository() {
|
|
|
2082
2291
|
supportsCreditsV2,
|
|
2083
2292
|
supportsIdempotentCredit,
|
|
2084
2293
|
toClientUserCredits,
|
|
2085
|
-
toDate
|
|
2294
|
+
toDate,
|
|
2295
|
+
validateBalanceUpdate
|
|
2086
2296
|
});
|
|
2087
2297
|
//# sourceMappingURL=index.cjs.map
|