@nehorai/credits 2.1.0 → 3.0.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 +3 -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 +3 -2
- package/dist/adapters/index.js.map +1 -1
- package/dist/core/index.cjs +218 -2
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +183 -5
- package/dist/core/index.d.ts +183 -5
- package/dist/core/index.js +199 -2
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +477 -59
- 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 +456 -59
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +394 -44
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +36 -7
- package/dist/repository/index.d.ts +36 -7
- package/dist/repository/index.js +391 -43
- package/dist/repository/index.js.map +1 -1
- package/dist/sdk/index.cjs +1 -0
- 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 +1 -0
- package/dist/sdk/index.js.map +1 -1
- package/dist/service/index.cjs +33 -20
- 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 +33 -20
- package/dist/service/index.js.map +1 -1
- package/dist/{types-Dzc443vy.d.cts → types-C3sxDvNb.d.cts} +43 -2
- package/dist/{types-Dzc443vy.d.ts → types-C3sxDvNb.d.ts} +43 -2
- package/dist/{types-BhCdep_t.d.ts → types-CY-oHIMA.d.ts} +55 -4
- package/dist/{types-wtksMa4n.d.cts → types-DOjguhEX.d.cts} +55 -4
- package/package.json +70 -71
package/dist/index.js
CHANGED
|
@@ -167,6 +167,40 @@ function floatSum(values) {
|
|
|
167
167
|
for (const value of values) total += value;
|
|
168
168
|
return total;
|
|
169
169
|
}
|
|
170
|
+
function multiplyAmount(unit, quantity) {
|
|
171
|
+
const cents = toCents(unit);
|
|
172
|
+
const product = cents === null ? Number.NaN : cents * quantity;
|
|
173
|
+
if (cents === null || cents < 0 || !Number.isSafeInteger(quantity) || quantity < 0 || !Number.isSafeInteger(product) || product > MAX_CENTS) {
|
|
174
|
+
throw new CreditError(
|
|
175
|
+
`Cannot price ${quantity} units at ${unit}: the price must be a non-negative amount exact to ${CREDIT_AMOUNT_SCALE} decimals, the quantity a whole number, and the result at most ${CREDIT_AMOUNT_MAX}`,
|
|
176
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
177
|
+
{ unit, quantity, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE }
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return product / 100;
|
|
181
|
+
}
|
|
182
|
+
var FLOAT_NOISE_FLOOR = 1e-6;
|
|
183
|
+
var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
|
|
184
|
+
function floatNoise(value) {
|
|
185
|
+
return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
|
|
186
|
+
}
|
|
187
|
+
function canonicalAmount(value) {
|
|
188
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return value;
|
|
189
|
+
const cents = Math.round(value * 100);
|
|
190
|
+
if (!Number.isSafeInteger(cents)) return value;
|
|
191
|
+
const onGrid = cents / 100 + 0;
|
|
192
|
+
return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
|
|
193
|
+
}
|
|
194
|
+
function storedAmountFloor(value) {
|
|
195
|
+
const canonical = canonicalAmount(value);
|
|
196
|
+
if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
|
|
197
|
+
return Math.floor(canonical * 100) / 100;
|
|
198
|
+
}
|
|
199
|
+
function storedAmountCeil(value) {
|
|
200
|
+
const canonical = canonicalAmount(value);
|
|
201
|
+
if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
|
|
202
|
+
return Math.ceil(canonical * 100) / 100;
|
|
203
|
+
}
|
|
170
204
|
function isValidCreditAmount(value) {
|
|
171
205
|
if (typeof value !== "number") return false;
|
|
172
206
|
const cents = toCents(value);
|
|
@@ -219,6 +253,150 @@ function backedBalanceFloor(reserved, bonusCredits) {
|
|
|
219
253
|
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
220
254
|
return floor;
|
|
221
255
|
}
|
|
256
|
+
function cappedBalance(balance, cap, reserved, bonusCredits) {
|
|
257
|
+
return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
|
|
258
|
+
}
|
|
259
|
+
function heldExpiredBacking(balance, stored) {
|
|
260
|
+
if (!(stored !== void 0 && stored > 0)) return 0;
|
|
261
|
+
return Math.max(0, Math.min(stored, balance));
|
|
262
|
+
}
|
|
263
|
+
function spendableBalance(balance, expiredHoldBacking) {
|
|
264
|
+
return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
|
|
265
|
+
}
|
|
266
|
+
function planBalanceTarget(target, reserved, bonusCredits) {
|
|
267
|
+
assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
|
|
268
|
+
const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
|
|
269
|
+
return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
|
|
270
|
+
}
|
|
271
|
+
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
|
|
272
|
+
const spendable = spendableBalance(balance, expiredHoldBacking);
|
|
273
|
+
const target = Math.min(spendable, cap);
|
|
274
|
+
const kept = Math.max(
|
|
275
|
+
sumAmounts(balance, -spendable),
|
|
276
|
+
sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
|
|
277
|
+
);
|
|
278
|
+
const next = Math.min(balance, sumAmounts(target, kept));
|
|
279
|
+
return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
|
|
280
|
+
}
|
|
281
|
+
function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
|
|
282
|
+
const held = heldExpiredBacking(balance, expiredHoldBacking);
|
|
283
|
+
const used = Math.max(0, Math.min(held, holdBacking));
|
|
284
|
+
return { used, remaining: sumAmounts(held, -used) };
|
|
285
|
+
}
|
|
286
|
+
function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
|
|
287
|
+
const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
|
|
288
|
+
const rest = sumAmounts(amount, -backing.used);
|
|
289
|
+
const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
|
|
290
|
+
const fromBonus = sumAmounts(rest, -fromSpendable);
|
|
291
|
+
return {
|
|
292
|
+
covered: fromBonus <= bonusCredits,
|
|
293
|
+
balance: sumAmounts(balance, -backing.used, -fromSpendable),
|
|
294
|
+
bonusCredits: sumAmounts(bonusCredits, -fromBonus),
|
|
295
|
+
expiredHoldBacking: backing.remaining
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
var NO_EXPIRED_BACKING_PREFIX = {
|
|
299
|
+
holdPlacedAt: "",
|
|
300
|
+
id: "",
|
|
301
|
+
share: 0
|
|
302
|
+
};
|
|
303
|
+
function compareHolds(a, b) {
|
|
304
|
+
if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
|
|
305
|
+
if (a.id === b.id) return 0;
|
|
306
|
+
return a.id < b.id ? -1 : 1;
|
|
307
|
+
}
|
|
308
|
+
function recordedExpiredBacking(hold) {
|
|
309
|
+
const stored = hold.expiredBacking;
|
|
310
|
+
if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
|
|
311
|
+
if (!isValidCreditAmount(hold.amount)) return 0;
|
|
312
|
+
return Math.min(storedAmountCeil(stored), hold.amount);
|
|
313
|
+
}
|
|
314
|
+
function allocateExpiredBacking(holds, total) {
|
|
315
|
+
const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
|
|
316
|
+
const wanted = Math.max(0, total);
|
|
317
|
+
if (wanted === 0) return { total: 0, frontier: null };
|
|
318
|
+
const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
|
|
319
|
+
const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
|
|
320
|
+
let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
|
|
321
|
+
for (const hold of [...recorded].reverse()) {
|
|
322
|
+
if (left >= 0) break;
|
|
323
|
+
const share = shares.get(hold.id);
|
|
324
|
+
const cut = Math.min(share, -left);
|
|
325
|
+
shares.set(hold.id, sumAmounts(share, -cut));
|
|
326
|
+
left = sumAmounts(left, cut);
|
|
327
|
+
}
|
|
328
|
+
let frontier = NO_EXPIRED_BACKING_PREFIX;
|
|
329
|
+
for (const hold of placed) {
|
|
330
|
+
if (left <= 0) break;
|
|
331
|
+
if (shares.has(hold.id)) continue;
|
|
332
|
+
const share = Math.min(hold.amount, left);
|
|
333
|
+
frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
|
|
334
|
+
left = sumAmounts(left, -share);
|
|
335
|
+
}
|
|
336
|
+
for (const hold of recorded) {
|
|
337
|
+
if (left <= 0) break;
|
|
338
|
+
const share = shares.get(hold.id);
|
|
339
|
+
const added = Math.min(sumAmounts(hold.amount, -share), left);
|
|
340
|
+
shares.set(hold.id, sumAmounts(share, added));
|
|
341
|
+
left = sumAmounts(left, -added);
|
|
342
|
+
}
|
|
343
|
+
const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
|
|
344
|
+
return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
|
|
345
|
+
}
|
|
346
|
+
function holdExpiredBacking(hold, frontier, rewrites) {
|
|
347
|
+
const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
|
|
348
|
+
const recorded = recordedExpiredBacking(
|
|
349
|
+
rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
|
|
350
|
+
);
|
|
351
|
+
if (recorded > 0) return recorded;
|
|
352
|
+
if (!frontier || !hold.holdPlacedAt) return 0;
|
|
353
|
+
const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
|
|
354
|
+
if (order > 0) return 0;
|
|
355
|
+
return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
|
|
356
|
+
}
|
|
357
|
+
function holdPlacedAtAfter(now, frontier) {
|
|
358
|
+
if (!frontier?.holdPlacedAt) return now;
|
|
359
|
+
const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
|
|
360
|
+
return now > earliest ? now : earliest;
|
|
361
|
+
}
|
|
362
|
+
function expiredBackingPatch(next, stored) {
|
|
363
|
+
const total = Math.max(0, next.total);
|
|
364
|
+
const patch = {};
|
|
365
|
+
const storedTotal = stored.expiredHoldBacking;
|
|
366
|
+
if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
|
|
367
|
+
patch.expiredHoldBacking = total;
|
|
368
|
+
}
|
|
369
|
+
if (total <= 0) {
|
|
370
|
+
if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
|
|
371
|
+
} else if (next.frontier) {
|
|
372
|
+
patch.expiredBackingFrontier = next.frontier;
|
|
373
|
+
}
|
|
374
|
+
return patch;
|
|
375
|
+
}
|
|
376
|
+
function assertValidBalanceTarget(value, field, context) {
|
|
377
|
+
assertRepresentableAmount(value, field, context);
|
|
378
|
+
if (value < 0) {
|
|
379
|
+
throw new CreditError(
|
|
380
|
+
`${field} cannot be negative (got ${value})`,
|
|
381
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
382
|
+
{ field, value, ...context }
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function assertValidTierBalanceInput(input, context) {
|
|
387
|
+
if (input.balanceCap === void 0) {
|
|
388
|
+
if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (input.balance !== void 0) {
|
|
392
|
+
throw new CreditError(
|
|
393
|
+
"A tier update cannot set both balance and balanceCap",
|
|
394
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
395
|
+
{ balance: input.balance, balanceCap: input.balanceCap, ...context }
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
|
|
399
|
+
}
|
|
222
400
|
function assertRepresentableTierAmount(value, field, context) {
|
|
223
401
|
if (value === Number.POSITIVE_INFINITY) return;
|
|
224
402
|
assertRepresentableAmount(value, field, context);
|
|
@@ -627,9 +805,9 @@ var creditSystemConfigSchema = z.object({
|
|
|
627
805
|
*/
|
|
628
806
|
reservationExpiryMs: z.number().positive().default(5 * 60 * 1e3),
|
|
629
807
|
/**
|
|
630
|
-
* Default credits for new users (free tier)
|
|
808
|
+
* Default credits for new users (free tier). 0 means no signup grant.
|
|
631
809
|
*/
|
|
632
|
-
defaultFreeCredits: z.number().
|
|
810
|
+
defaultFreeCredits: z.number().min(0).default(25),
|
|
633
811
|
/**
|
|
634
812
|
* Grace period in days after subscription expires before downgrade
|
|
635
813
|
*/
|
|
@@ -929,6 +1107,8 @@ function toClientUserCredits(credits) {
|
|
|
929
1107
|
monthlyUsed: credits.monthlyUsed,
|
|
930
1108
|
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
931
1109
|
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
1110
|
+
...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
|
|
1111
|
+
...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
|
|
932
1112
|
createdAt: toISOString(credits.createdAt),
|
|
933
1113
|
updatedAt: toISOString(credits.updatedAt)
|
|
934
1114
|
};
|
|
@@ -1160,11 +1340,36 @@ function toDate2(value) {
|
|
|
1160
1340
|
return /* @__PURE__ */ new Date();
|
|
1161
1341
|
}
|
|
1162
1342
|
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
next
|
|
1167
|
-
|
|
1343
|
+
return new Date(from.getFullYear(), from.getMonth() + 1, 1);
|
|
1344
|
+
}
|
|
1345
|
+
function validateBalanceUpdate(current, updates) {
|
|
1346
|
+
const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
|
|
1347
|
+
const balance = next(current.balance, updates.balance, updates.balanceIncrement);
|
|
1348
|
+
const bonusCredits = next(
|
|
1349
|
+
current.bonusCredits ?? 0,
|
|
1350
|
+
updates.bonusCredits,
|
|
1351
|
+
updates.bonusCreditsIncrement
|
|
1352
|
+
);
|
|
1353
|
+
const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
|
|
1354
|
+
const errors = [];
|
|
1355
|
+
if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
|
|
1356
|
+
const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
|
|
1357
|
+
if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
|
|
1358
|
+
errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
|
|
1359
|
+
}
|
|
1360
|
+
if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
|
|
1361
|
+
if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
|
|
1362
|
+
const covering = sumAmounts(balance, bonusCredits);
|
|
1363
|
+
if (reserved > covering) {
|
|
1364
|
+
errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
|
|
1365
|
+
}
|
|
1366
|
+
return { valid: errors.length === 0, errors };
|
|
1367
|
+
}
|
|
1368
|
+
function assertValidBalanceUpdate(current, updates) {
|
|
1369
|
+
const result = validateBalanceUpdate(current, updates);
|
|
1370
|
+
if (!result.valid) {
|
|
1371
|
+
throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
|
|
1372
|
+
}
|
|
1168
1373
|
}
|
|
1169
1374
|
|
|
1170
1375
|
// src/repository/memory/snapshot.ts
|
|
@@ -1300,6 +1505,7 @@ function commitTransitionJournal(store, input, existingId) {
|
|
|
1300
1505
|
userId: input.userId,
|
|
1301
1506
|
entryType: input.entryType,
|
|
1302
1507
|
amount: input.amount,
|
|
1508
|
+
balanceBefore: input.balanceBefore,
|
|
1303
1509
|
balanceAfter: input.balanceAfter,
|
|
1304
1510
|
source: input.source,
|
|
1305
1511
|
referenceId: input.reservationId,
|
|
@@ -1344,6 +1550,34 @@ function assertTrustworthy(reservation, transition) {
|
|
|
1344
1550
|
assertLockedAmount(reservation, transition);
|
|
1345
1551
|
assertHoldPlaced(reservation, transition);
|
|
1346
1552
|
}
|
|
1553
|
+
function openHolds(store, userId) {
|
|
1554
|
+
return [...store.reservations.get(userId)?.values() ?? []].filter(
|
|
1555
|
+
(reservation) => reservation.status === "reserved"
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
function expiredBackingInForce(store, credits) {
|
|
1559
|
+
const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
1560
|
+
if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
|
|
1561
|
+
return allocateExpiredBacking(openHolds(store, credits.userId), total);
|
|
1562
|
+
}
|
|
1563
|
+
return { total, frontier: credits.expiredBackingFrontier ?? null };
|
|
1564
|
+
}
|
|
1565
|
+
function writeRecordedShares(store, userId, allocation) {
|
|
1566
|
+
for (const { id, expiredBacking } of allocation.rewrites ?? []) {
|
|
1567
|
+
const hold = getReservation(store, userId, id);
|
|
1568
|
+
if (hold) hold.expiredBacking = expiredBacking;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
function writeExpiredHoldBacking(store, credits, next) {
|
|
1572
|
+
if (next <= 0) {
|
|
1573
|
+
Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
if (next === credits.expiredHoldBacking) return;
|
|
1577
|
+
const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
|
|
1578
|
+
writeRecordedShares(store, credits.userId, allocation);
|
|
1579
|
+
Object.assign(credits, expiredBackingPatch(allocation, credits));
|
|
1580
|
+
}
|
|
1347
1581
|
function getReservation(store, userId, reservationId) {
|
|
1348
1582
|
return store.reservations.get(userId)?.get(reservationId);
|
|
1349
1583
|
}
|
|
@@ -1391,6 +1625,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1391
1625
|
};
|
|
1392
1626
|
}
|
|
1393
1627
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1628
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1394
1629
|
const reservation = {
|
|
1395
1630
|
id: generateId(),
|
|
1396
1631
|
userId: input.userId,
|
|
@@ -1403,7 +1638,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1403
1638
|
// The hold-origin fact. Written here and nowhere else, in the same
|
|
1404
1639
|
// critical section that raises `reserved` below, so the row and the hold
|
|
1405
1640
|
// become visible together. `createReservation` cannot set it.
|
|
1406
|
-
holdPlacedAt: now
|
|
1641
|
+
holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
|
|
1407
1642
|
};
|
|
1408
1643
|
if (!store.reservations.has(input.userId)) {
|
|
1409
1644
|
store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
@@ -1421,6 +1656,8 @@ async function reserveCreditsV2(store, input) {
|
|
|
1421
1656
|
);
|
|
1422
1657
|
}
|
|
1423
1658
|
credits.reserved = nextReserved;
|
|
1659
|
+
writeRecordedShares(store, input.userId, backing);
|
|
1660
|
+
Object.assign(credits, expiredBackingPatch(backing, credits));
|
|
1424
1661
|
credits.updatedAt = now;
|
|
1425
1662
|
return { outcome: "created", reservation: copyRecord(reservation) };
|
|
1426
1663
|
});
|
|
@@ -1441,22 +1678,30 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1441
1678
|
const amount = reservation.amount;
|
|
1442
1679
|
await yieldPoint(store);
|
|
1443
1680
|
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1444
|
-
|
|
1681
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1682
|
+
const paid = planHoldCommit(
|
|
1683
|
+
credits.balance,
|
|
1684
|
+
credits.bonusCredits,
|
|
1685
|
+
backing.total,
|
|
1686
|
+
holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
|
|
1687
|
+
amount
|
|
1688
|
+
);
|
|
1689
|
+
if (!paid.covered) {
|
|
1445
1690
|
throw new CreditError(
|
|
1446
1691
|
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1447
1692
|
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1448
1693
|
{ userId, reservationId, required: amount }
|
|
1449
1694
|
);
|
|
1450
1695
|
}
|
|
1451
|
-
const
|
|
1452
|
-
const
|
|
1453
|
-
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1696
|
+
const nextBalance = paid.balance;
|
|
1697
|
+
const nextBonusCredits = paid.bonusCredits;
|
|
1454
1698
|
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1455
1699
|
const journal = {
|
|
1456
1700
|
userId,
|
|
1457
1701
|
operation: "commitReservation",
|
|
1458
1702
|
entryType: "debit",
|
|
1459
1703
|
amount,
|
|
1704
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1460
1705
|
balanceAfter,
|
|
1461
1706
|
source: "operation_commit",
|
|
1462
1707
|
reservationId,
|
|
@@ -1484,6 +1729,11 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1484
1729
|
credits.bonusCredits = nextBonusCredits;
|
|
1485
1730
|
credits.reserved = nextReserved;
|
|
1486
1731
|
credits.monthlyUsed = nextMonthlyUsed;
|
|
1732
|
+
writeRecordedShares(store, userId, backing);
|
|
1733
|
+
Object.assign(
|
|
1734
|
+
credits,
|
|
1735
|
+
expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
|
|
1736
|
+
);
|
|
1487
1737
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1488
1738
|
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1489
1739
|
return {
|
|
@@ -1563,19 +1813,28 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1563
1813
|
reservation.id,
|
|
1564
1814
|
reservation.amount
|
|
1565
1815
|
);
|
|
1816
|
+
const inForce = expiredBackingInForce(store, credits);
|
|
1817
|
+
const backing = settleExpiredHoldBacking(
|
|
1818
|
+
credits.balance,
|
|
1819
|
+
inForce.total,
|
|
1820
|
+
holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
|
|
1821
|
+
);
|
|
1822
|
+
const nextBalance = sumAmounts(credits.balance, -backing.used);
|
|
1566
1823
|
const journal = {
|
|
1567
1824
|
userId: reservation.userId,
|
|
1568
1825
|
operation: `${transition.transition}Reservation`,
|
|
1569
1826
|
entryType: "credit",
|
|
1570
1827
|
amount: 0,
|
|
1571
|
-
|
|
1828
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1829
|
+
balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
|
|
1572
1830
|
source: transition.source,
|
|
1573
1831
|
reservationId: reservation.id,
|
|
1574
1832
|
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1575
1833
|
metadata: {
|
|
1576
1834
|
...transition.options?.metadata,
|
|
1577
1835
|
operationType: reservation.operationType,
|
|
1578
|
-
amount: reservation.amount
|
|
1836
|
+
amount: reservation.amount,
|
|
1837
|
+
...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
|
|
1579
1838
|
},
|
|
1580
1839
|
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1581
1840
|
};
|
|
@@ -1588,6 +1847,12 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1588
1847
|
reservation.status = transition.status;
|
|
1589
1848
|
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1590
1849
|
credits.reserved = nextReserved;
|
|
1850
|
+
credits.balance = nextBalance;
|
|
1851
|
+
writeRecordedShares(store, reservation.userId, inForce);
|
|
1852
|
+
Object.assign(
|
|
1853
|
+
credits,
|
|
1854
|
+
expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
|
|
1855
|
+
);
|
|
1591
1856
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1592
1857
|
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1593
1858
|
}
|
|
@@ -1598,6 +1863,10 @@ function isoOf(value) {
|
|
|
1598
1863
|
}
|
|
1599
1864
|
var InMemoryCreditRepository = class {
|
|
1600
1865
|
store = new MemoryStore();
|
|
1866
|
+
options;
|
|
1867
|
+
constructor(options = {}) {
|
|
1868
|
+
this.options = options;
|
|
1869
|
+
}
|
|
1601
1870
|
// ==================== User Credits ====================
|
|
1602
1871
|
/**
|
|
1603
1872
|
* A copy, never the live record.
|
|
@@ -1614,7 +1883,8 @@ var InMemoryCreditRepository = class {
|
|
|
1614
1883
|
return copyRecord(credits) ?? null;
|
|
1615
1884
|
}
|
|
1616
1885
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1617
|
-
|
|
1886
|
+
const existing = this.store.users.get(userId);
|
|
1887
|
+
return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
|
|
1618
1888
|
}
|
|
1619
1889
|
/**
|
|
1620
1890
|
* The synchronous body of `initializeUserCredits`.
|
|
@@ -1625,7 +1895,7 @@ var InMemoryCreditRepository = class {
|
|
|
1625
1895
|
* simultaneous first deliveries of the same reference would then both credit.
|
|
1626
1896
|
*/
|
|
1627
1897
|
seedUser(userId, tier, initialBalance) {
|
|
1628
|
-
|
|
1898
|
+
assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
|
|
1629
1899
|
const configured = getConfigMonthlyLimit(tier);
|
|
1630
1900
|
assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
|
|
1631
1901
|
const monthlyLimit = storedMonthlyLimit(configured);
|
|
@@ -1665,8 +1935,11 @@ var InMemoryCreditRepository = class {
|
|
|
1665
1935
|
if (!credits) {
|
|
1666
1936
|
throw new Error(`User ${userId} not found`);
|
|
1667
1937
|
}
|
|
1938
|
+
assertValidBalanceUpdate(credits, updates);
|
|
1668
1939
|
const next = { ...credits };
|
|
1669
|
-
if (updates.balance !== void 0)
|
|
1940
|
+
if (updates.balance !== void 0) {
|
|
1941
|
+
next.balance = updates.balance;
|
|
1942
|
+
}
|
|
1670
1943
|
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1671
1944
|
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1672
1945
|
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
@@ -1703,6 +1976,11 @@ var InMemoryCreditRepository = class {
|
|
|
1703
1976
|
userId,
|
|
1704
1977
|
operation: "updateUserCredits"
|
|
1705
1978
|
});
|
|
1979
|
+
writeExpiredHoldBacking(
|
|
1980
|
+
this.store,
|
|
1981
|
+
next,
|
|
1982
|
+
updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
|
|
1983
|
+
);
|
|
1706
1984
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1707
1985
|
this.store.users.set(userId, next);
|
|
1708
1986
|
}
|
|
@@ -1711,18 +1989,34 @@ var InMemoryCreditRepository = class {
|
|
|
1711
1989
|
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1712
1990
|
{ userId, operation: "updateUserTier" }
|
|
1713
1991
|
);
|
|
1992
|
+
assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
|
|
1714
1993
|
const credits = this.store.users.get(userId);
|
|
1715
1994
|
if (!credits) {
|
|
1716
1995
|
throw new Error(`User ${userId} not found`);
|
|
1717
1996
|
}
|
|
1997
|
+
if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
|
|
1998
|
+
const capped = planBalanceCap(
|
|
1999
|
+
credits.balance,
|
|
2000
|
+
input.balanceCap,
|
|
2001
|
+
credits.reserved,
|
|
2002
|
+
credits.bonusCredits,
|
|
2003
|
+
credits.expiredHoldBacking
|
|
2004
|
+
);
|
|
2005
|
+
credits.balance = capped.balance;
|
|
2006
|
+
writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
|
|
2007
|
+
}
|
|
1718
2008
|
credits.tier = input.tier;
|
|
1719
2009
|
credits.monthlyLimit = input.monthlyLimit;
|
|
1720
2010
|
if (input.balance !== void 0) {
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
);
|
|
2011
|
+
const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
2012
|
+
credits.balance = planned.balance;
|
|
2013
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1725
2014
|
}
|
|
2015
|
+
writeExpiredHoldBacking(
|
|
2016
|
+
this.store,
|
|
2017
|
+
credits,
|
|
2018
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
2019
|
+
);
|
|
1726
2020
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
1727
2021
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
1728
2022
|
if (input.subscriptionExpiresAt === null) {
|
|
@@ -1884,6 +2178,11 @@ var InMemoryCreditRepository = class {
|
|
|
1884
2178
|
{ userId, operation: "addCredits" }
|
|
1885
2179
|
);
|
|
1886
2180
|
credits.bonusCredits = nextBonusCredits;
|
|
2181
|
+
writeExpiredHoldBacking(
|
|
2182
|
+
this.store,
|
|
2183
|
+
credits,
|
|
2184
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
2185
|
+
);
|
|
1887
2186
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1888
2187
|
this.store.users.set(userId, credits);
|
|
1889
2188
|
const transaction = this.recordTransaction({
|
|
@@ -1903,6 +2202,7 @@ var InMemoryCreditRepository = class {
|
|
|
1903
2202
|
userId,
|
|
1904
2203
|
entryType: "credit",
|
|
1905
2204
|
amount,
|
|
2205
|
+
balanceBefore: previousTotal,
|
|
1906
2206
|
balanceAfter: newTotal,
|
|
1907
2207
|
source: payload.source,
|
|
1908
2208
|
referenceId: transaction.id ?? paymentRef ?? "unknown",
|
|
@@ -1936,19 +2236,23 @@ var InMemoryCreditRepository = class {
|
|
|
1936
2236
|
});
|
|
1937
2237
|
}
|
|
1938
2238
|
}
|
|
1939
|
-
async deductCreditsAtomic(userId, amount) {
|
|
2239
|
+
async deductCreditsAtomic(userId, amount, journal) {
|
|
1940
2240
|
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1941
2241
|
const credits = this.store.users.get(userId);
|
|
1942
2242
|
if (!credits) {
|
|
1943
2243
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
1944
2244
|
}
|
|
1945
|
-
const
|
|
2245
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
2246
|
+
const available = Math.min(
|
|
2247
|
+
sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
|
|
2248
|
+
sumAmounts(spendable, credits.bonusCredits)
|
|
2249
|
+
);
|
|
1946
2250
|
if (available < amount) {
|
|
1947
2251
|
throw new Error(
|
|
1948
2252
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
1949
2253
|
);
|
|
1950
2254
|
}
|
|
1951
|
-
const balanceDeduction = Math.min(
|
|
2255
|
+
const balanceDeduction = Math.min(spendable, amount);
|
|
1952
2256
|
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
1953
2257
|
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
1954
2258
|
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
@@ -1958,11 +2262,26 @@ var InMemoryCreditRepository = class {
|
|
|
1958
2262
|
{ previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
|
|
1959
2263
|
{ userId, operation: "deductCredits" }
|
|
1960
2264
|
);
|
|
2265
|
+
const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
1961
2266
|
credits.balance = nextBalance;
|
|
1962
2267
|
credits.bonusCredits = nextBonusCredits;
|
|
2268
|
+
writeExpiredHoldBacking(this.store, credits, held);
|
|
1963
2269
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1964
2270
|
this.store.users.set(userId, credits);
|
|
1965
|
-
return { previousBalance, newBalance };
|
|
2271
|
+
if (!journal) return { previousBalance, newBalance };
|
|
2272
|
+
const entry = this.recordJournalEntry({
|
|
2273
|
+
userId,
|
|
2274
|
+
entryType: "debit",
|
|
2275
|
+
amount,
|
|
2276
|
+
balanceBefore: previousBalance,
|
|
2277
|
+
balanceAfter: newBalance,
|
|
2278
|
+
source: journal.source ?? "operation_commit",
|
|
2279
|
+
referenceId: journal.referenceId,
|
|
2280
|
+
referenceType: journal.referenceType ?? "adjustment",
|
|
2281
|
+
description: journal.description,
|
|
2282
|
+
metadata: journal.metadata
|
|
2283
|
+
});
|
|
2284
|
+
return { previousBalance, newBalance, journalEntryId: entry.id };
|
|
1966
2285
|
}
|
|
1967
2286
|
// ==================== Transactions ====================
|
|
1968
2287
|
async createTransaction(input) {
|
|
@@ -2076,6 +2395,11 @@ var InMemoryCreditRepository = class {
|
|
|
2076
2395
|
assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
|
|
2077
2396
|
userId: input.userId
|
|
2078
2397
|
});
|
|
2398
|
+
if (input.balanceBefore !== void 0) {
|
|
2399
|
+
assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
|
|
2400
|
+
userId: input.userId
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2079
2403
|
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
2080
2404
|
assertPublicJournalKey(input.idempotencyKey, input.userId);
|
|
2081
2405
|
const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
|
|
@@ -2091,6 +2415,7 @@ var InMemoryCreditRepository = class {
|
|
|
2091
2415
|
userId: input.userId,
|
|
2092
2416
|
entryType: input.entryType,
|
|
2093
2417
|
amount: input.amount,
|
|
2418
|
+
...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
|
|
2094
2419
|
balanceAfter: input.balanceAfter,
|
|
2095
2420
|
source: input.source,
|
|
2096
2421
|
referenceId: input.referenceId,
|
|
@@ -2185,7 +2510,11 @@ var InMemoryCreditRepository = class {
|
|
|
2185
2510
|
return { expiredCount, creditsReleased, errors };
|
|
2186
2511
|
}
|
|
2187
2512
|
// ==================== Atomic Monthly Reset ====================
|
|
2188
|
-
|
|
2513
|
+
/**
|
|
2514
|
+
* @param _tier - Not trusted. The caller read it earlier and the account may
|
|
2515
|
+
* have changed tier since, so the reset uses the tier stored on the account.
|
|
2516
|
+
*/
|
|
2517
|
+
async atomicMonthlyReset(userId, _tier, expectedResetAt) {
|
|
2189
2518
|
const credits = this.store.users.get(userId);
|
|
2190
2519
|
if (!credits) {
|
|
2191
2520
|
throw new Error(`User ${userId} not found`);
|
|
@@ -2195,17 +2524,41 @@ var InMemoryCreditRepository = class {
|
|
|
2195
2524
|
if (currentResetAt !== expected) {
|
|
2196
2525
|
return { wasReset: false, credits: { ...credits } };
|
|
2197
2526
|
}
|
|
2198
|
-
|
|
2527
|
+
const tier = credits.tier;
|
|
2528
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
2529
|
+
const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
|
|
2530
|
+
if (decision === null) {
|
|
2531
|
+
return { wasReset: false, credits: { ...credits } };
|
|
2532
|
+
}
|
|
2533
|
+
if (decision) {
|
|
2534
|
+
assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
|
|
2535
|
+
if (decision.monthlyLimit !== void 0) {
|
|
2536
|
+
assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
|
|
2537
|
+
userId,
|
|
2538
|
+
tier
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
} else {
|
|
2542
|
+
assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
|
|
2543
|
+
}
|
|
2199
2544
|
const nextReset = getNextMonthlyReset();
|
|
2200
|
-
const target = monthlyResetBalance(tier);
|
|
2201
|
-
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
2202
2545
|
const previousBalance = credits.balance;
|
|
2203
|
-
|
|
2546
|
+
let target;
|
|
2547
|
+
if (decision) {
|
|
2548
|
+
target = decision.balance;
|
|
2549
|
+
} else {
|
|
2550
|
+
const configured = monthlyResetBalance(tier);
|
|
2551
|
+
target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
|
|
2552
|
+
}
|
|
2553
|
+
const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
|
|
2554
|
+
const newBalance = planned.balance;
|
|
2204
2555
|
const change = sumAmounts(newBalance, -previousBalance);
|
|
2205
2556
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2206
2557
|
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
2207
2558
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2208
2559
|
credits.balance = newBalance;
|
|
2560
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2561
|
+
if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
|
|
2209
2562
|
credits.monthlyUsed = 0;
|
|
2210
2563
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
2211
2564
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2215,6 +2568,7 @@ var InMemoryCreditRepository = class {
|
|
|
2215
2568
|
userId,
|
|
2216
2569
|
entryType: change > 0 ? "credit" : "debit",
|
|
2217
2570
|
amount: Math.abs(change),
|
|
2571
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
2218
2572
|
balanceAfter,
|
|
2219
2573
|
source: "monthly_reset",
|
|
2220
2574
|
referenceId: `reset-${Date.now()}`,
|
|
@@ -2259,23 +2613,34 @@ var InMemoryCreditRepository = class {
|
|
|
2259
2613
|
};
|
|
2260
2614
|
}
|
|
2261
2615
|
const defaultTier = getDefaultTier();
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
tier: defaultTier
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2616
|
+
let limit;
|
|
2617
|
+
if (this.options.getDowngradeBalanceCap) {
|
|
2618
|
+
limit = this.options.getDowngradeBalanceCap();
|
|
2619
|
+
assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
|
|
2620
|
+
} else {
|
|
2621
|
+
const configured = getConfigMonthlyLimit(defaultTier);
|
|
2622
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", {
|
|
2623
|
+
userId,
|
|
2624
|
+
tier: defaultTier
|
|
2625
|
+
});
|
|
2626
|
+
limit = storedMonthlyLimit(configured);
|
|
2627
|
+
}
|
|
2268
2628
|
const previousTier = credits.tier;
|
|
2269
2629
|
const previousBalance = credits.balance;
|
|
2270
|
-
const
|
|
2271
|
-
|
|
2272
|
-
|
|
2630
|
+
const planned = planBalanceCap(
|
|
2631
|
+
credits.balance,
|
|
2632
|
+
limit,
|
|
2633
|
+
credits.reserved,
|
|
2634
|
+
credits.bonusCredits,
|
|
2635
|
+
credits.expiredHoldBacking
|
|
2273
2636
|
);
|
|
2637
|
+
const newBalance = planned.balance;
|
|
2274
2638
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2275
2639
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2276
2640
|
credits.tier = defaultTier;
|
|
2277
2641
|
credits.monthlyLimit = limit;
|
|
2278
2642
|
credits.balance = newBalance;
|
|
2643
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2279
2644
|
credits.subscriptionExpiresAt = null;
|
|
2280
2645
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2281
2646
|
this.store.users.set(userId, credits);
|
|
@@ -2284,6 +2649,7 @@ var InMemoryCreditRepository = class {
|
|
|
2284
2649
|
entryType: "debit",
|
|
2285
2650
|
amount: 0,
|
|
2286
2651
|
// No credits deducted, just tier change
|
|
2652
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
2287
2653
|
balanceAfter,
|
|
2288
2654
|
source: "subscription_downgrade",
|
|
2289
2655
|
referenceId: `downgrade-${Date.now()}`,
|
|
@@ -2333,8 +2699,8 @@ var InMemoryCreditRepository = class {
|
|
|
2333
2699
|
return copyRecords(Array.from(userReservations.values()));
|
|
2334
2700
|
}
|
|
2335
2701
|
};
|
|
2336
|
-
function createInMemoryCreditRepository() {
|
|
2337
|
-
return new InMemoryCreditRepository();
|
|
2702
|
+
function createInMemoryCreditRepository(options = {}) {
|
|
2703
|
+
return new InMemoryCreditRepository(options);
|
|
2338
2704
|
}
|
|
2339
2705
|
|
|
2340
2706
|
// src/auth/api-key.ts
|
|
@@ -2447,6 +2813,7 @@ var CreditsService = class {
|
|
|
2447
2813
|
entryType: "debit",
|
|
2448
2814
|
amount: 0,
|
|
2449
2815
|
// No credits deducted, just tier change
|
|
2816
|
+
balanceBefore: sumAmounts(data.balance, data.bonusCredits),
|
|
2450
2817
|
// The ledger balance, matching every other journal writer: the V2
|
|
2451
2818
|
// transitions and add/deduct all record `balance + bonusCredits`, so
|
|
2452
2819
|
// recording `balance` alone made the audit trail disagree with itself
|
|
@@ -2490,6 +2857,7 @@ var CreditsService = class {
|
|
|
2490
2857
|
userId,
|
|
2491
2858
|
entryType: balanceChange > 0 ? "credit" : "debit",
|
|
2492
2859
|
amount: Math.abs(balanceChange),
|
|
2860
|
+
balanceBefore: sumAmounts(data.balance, data.bonusCredits),
|
|
2493
2861
|
balanceAfter: sumAmounts(
|
|
2494
2862
|
resetResult.credits.balance,
|
|
2495
2863
|
resetResult.credits.bonusCredits
|
|
@@ -2497,9 +2865,11 @@ var CreditsService = class {
|
|
|
2497
2865
|
source: "monthly_reset",
|
|
2498
2866
|
referenceId: `reset-${Date.now()}`,
|
|
2499
2867
|
referenceType: "reset",
|
|
2500
|
-
|
|
2868
|
+
// The tier the reset ran on, which a concurrent downgrade may have
|
|
2869
|
+
// changed since `data` was read.
|
|
2870
|
+
description: `Monthly credit reset for ${resetResult.credits.tier} tier.`,
|
|
2501
2871
|
metadata: {
|
|
2502
|
-
tier:
|
|
2872
|
+
tier: resetResult.credits.tier,
|
|
2503
2873
|
previousBalance: data.balance,
|
|
2504
2874
|
newBalance: resetResult.credits.balance
|
|
2505
2875
|
}
|
|
@@ -2722,9 +3092,18 @@ var CreditsService = class {
|
|
|
2722
3092
|
*/
|
|
2723
3093
|
async deductCredits(userId, amount, options) {
|
|
2724
3094
|
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
3095
|
+
const operationType = options?.operationType;
|
|
3096
|
+
const journal = {
|
|
3097
|
+
referenceId: options?.requestId ?? `deduct-${Date.now()}`,
|
|
3098
|
+
description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
|
|
3099
|
+
metadata: {
|
|
3100
|
+
...options?.metadata,
|
|
3101
|
+
...operationType && { operationType }
|
|
3102
|
+
}
|
|
3103
|
+
};
|
|
2725
3104
|
let deduction;
|
|
2726
3105
|
try {
|
|
2727
|
-
deduction = await this.repository.deductCreditsAtomic(userId, amount);
|
|
3106
|
+
deduction = await this.repository.deductCreditsAtomic(userId, amount, journal);
|
|
2728
3107
|
} catch (error) {
|
|
2729
3108
|
if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
|
|
2730
3109
|
const credits = await this.repository.getUserCredits(userId);
|
|
@@ -2740,21 +3119,18 @@ var CreditsService = class {
|
|
|
2740
3119
|
shortfall: sumAmounts(amount, -available)
|
|
2741
3120
|
};
|
|
2742
3121
|
}
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
...operationType && { operationType }
|
|
2756
|
-
}
|
|
2757
|
-
});
|
|
3122
|
+
if (deduction.journalEntryId === void 0) {
|
|
3123
|
+
await this.repository.createJournalEntry({
|
|
3124
|
+
userId,
|
|
3125
|
+
entryType: "debit",
|
|
3126
|
+
amount,
|
|
3127
|
+
balanceBefore: deduction.previousBalance,
|
|
3128
|
+
balanceAfter: deduction.newBalance,
|
|
3129
|
+
source: "operation_commit",
|
|
3130
|
+
referenceType: "adjustment",
|
|
3131
|
+
...journal
|
|
3132
|
+
});
|
|
3133
|
+
}
|
|
2758
3134
|
if (operationType) {
|
|
2759
3135
|
await this.logUsage({
|
|
2760
3136
|
userId,
|
|
@@ -3764,6 +4140,7 @@ export {
|
|
|
3764
4140
|
CreditsSDKError,
|
|
3765
4141
|
CreditsService,
|
|
3766
4142
|
InMemoryCreditRepository,
|
|
4143
|
+
NO_EXPIRED_BACKING_PREFIX,
|
|
3767
4144
|
NetworkError,
|
|
3768
4145
|
RESERVATION_STATUSES,
|
|
3769
4146
|
RESERVED_JOURNAL_KEY_PREFIX,
|
|
@@ -3776,6 +4153,7 @@ export {
|
|
|
3776
4153
|
UNLIMITED_BALANCE_SENTINEL,
|
|
3777
4154
|
ValidationError,
|
|
3778
4155
|
addCreditsThroughRepository,
|
|
4156
|
+
allocateExpiredBacking,
|
|
3779
4157
|
assertDirectStatusWriteAllowed,
|
|
3780
4158
|
assertHoldPlaced,
|
|
3781
4159
|
assertKnownReservationStatus,
|
|
@@ -3785,12 +4163,17 @@ export {
|
|
|
3785
4163
|
assertRepresentableTierAmount,
|
|
3786
4164
|
assertUnkeyedDirectReservation,
|
|
3787
4165
|
assertUnreferencedDirectTransaction,
|
|
4166
|
+
assertValidBalanceTarget,
|
|
4167
|
+
assertValidBalanceUpdate,
|
|
3788
4168
|
assertValidCreditAmount,
|
|
3789
4169
|
assertValidIdempotencyKey,
|
|
3790
4170
|
assertValidStoredAmount,
|
|
3791
4171
|
assertValidStoredAmountRaw,
|
|
4172
|
+
assertValidTierBalanceInput,
|
|
3792
4173
|
backedBalanceFloor,
|
|
3793
4174
|
calculateAvailableCredits,
|
|
4175
|
+
canonicalAmount,
|
|
4176
|
+
cappedBalance,
|
|
3794
4177
|
checkAndNotifyLowBalance,
|
|
3795
4178
|
classifyDatabaseError,
|
|
3796
4179
|
clearConfigProvider,
|
|
@@ -3816,6 +4199,7 @@ export {
|
|
|
3816
4199
|
createUnsupportedOperationError,
|
|
3817
4200
|
createUserNotFoundError,
|
|
3818
4201
|
describePaymentMismatch,
|
|
4202
|
+
expiredBackingPatch,
|
|
3819
4203
|
generateId,
|
|
3820
4204
|
generateRequestId2 as generateRequestId,
|
|
3821
4205
|
genericDeferred,
|
|
@@ -3841,6 +4225,9 @@ export {
|
|
|
3841
4225
|
getValidOperationTypes,
|
|
3842
4226
|
getValidTiers,
|
|
3843
4227
|
hasPlacedHold,
|
|
4228
|
+
heldExpiredBacking,
|
|
4229
|
+
holdExpiredBacking,
|
|
4230
|
+
holdPlacedAtAfter,
|
|
3844
4231
|
initializeConfig,
|
|
3845
4232
|
isCreditError,
|
|
3846
4233
|
isCreditErrorMessage,
|
|
@@ -3866,6 +4253,7 @@ export {
|
|
|
3866
4253
|
isWinningOutcome,
|
|
3867
4254
|
loadConfigFromEnv,
|
|
3868
4255
|
monthlyResetBalance,
|
|
4256
|
+
multiplyAmount,
|
|
3869
4257
|
noopDeferred,
|
|
3870
4258
|
normalizePaymentRef,
|
|
3871
4259
|
notifySubscriptionExpired,
|
|
@@ -3874,6 +4262,10 @@ export {
|
|
|
3874
4262
|
parseApiError,
|
|
3875
4263
|
parseCreditError,
|
|
3876
4264
|
parseTier,
|
|
4265
|
+
planBalanceCap,
|
|
4266
|
+
planBalanceTarget,
|
|
4267
|
+
planHoldCommit,
|
|
4268
|
+
recordedExpiredBacking,
|
|
3877
4269
|
registerConfigProvider,
|
|
3878
4270
|
registerNotificationHandler,
|
|
3879
4271
|
releaseReservationWithJournal,
|
|
@@ -3883,6 +4275,10 @@ export {
|
|
|
3883
4275
|
reserveThroughRepository,
|
|
3884
4276
|
resetConfig,
|
|
3885
4277
|
sameAmount,
|
|
4278
|
+
settleExpiredHoldBacking,
|
|
4279
|
+
spendableBalance,
|
|
4280
|
+
storedAmountCeil,
|
|
4281
|
+
storedAmountFloor,
|
|
3886
4282
|
storedMonthlyLimit,
|
|
3887
4283
|
sumAmounts,
|
|
3888
4284
|
supportsCreditsV2,
|
|
@@ -3894,6 +4290,7 @@ export {
|
|
|
3894
4290
|
toClientUserCredits,
|
|
3895
4291
|
toDate,
|
|
3896
4292
|
toPortableTimestamp,
|
|
4293
|
+
validateBalanceUpdate,
|
|
3897
4294
|
verifyAdminApiKey,
|
|
3898
4295
|
verifyBearerToken
|
|
3899
4296
|
};
|