@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
|
@@ -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
|
};
|
|
@@ -92,6 +96,23 @@ function floatSum(values) {
|
|
|
92
96
|
for (const value of values) total += value;
|
|
93
97
|
return total;
|
|
94
98
|
}
|
|
99
|
+
var FLOAT_NOISE_FLOOR = 1e-6;
|
|
100
|
+
var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
|
|
101
|
+
function floatNoise(value) {
|
|
102
|
+
return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
|
|
103
|
+
}
|
|
104
|
+
function canonicalAmount(value) {
|
|
105
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return value;
|
|
106
|
+
const cents = Math.round(value * 100);
|
|
107
|
+
if (!Number.isSafeInteger(cents)) return value;
|
|
108
|
+
const onGrid = cents / 100 + 0;
|
|
109
|
+
return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
|
|
110
|
+
}
|
|
111
|
+
function storedAmountCeil(value) {
|
|
112
|
+
const canonical = canonicalAmount(value);
|
|
113
|
+
if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
|
|
114
|
+
return Math.ceil(canonical * 100) / 100;
|
|
115
|
+
}
|
|
95
116
|
function isValidCreditAmount(value) {
|
|
96
117
|
if (typeof value !== "number") return false;
|
|
97
118
|
const cents = toCents(value);
|
|
@@ -144,6 +165,147 @@ function backedBalanceFloor(reserved, bonusCredits) {
|
|
|
144
165
|
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
145
166
|
return floor;
|
|
146
167
|
}
|
|
168
|
+
function heldExpiredBacking(balance, stored) {
|
|
169
|
+
if (!(stored !== void 0 && stored > 0)) return 0;
|
|
170
|
+
return Math.max(0, Math.min(stored, balance));
|
|
171
|
+
}
|
|
172
|
+
function spendableBalance(balance, expiredHoldBacking) {
|
|
173
|
+
return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
|
|
174
|
+
}
|
|
175
|
+
function planBalanceTarget(target, reserved, bonusCredits) {
|
|
176
|
+
assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
|
|
177
|
+
const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
|
|
178
|
+
return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
|
|
179
|
+
}
|
|
180
|
+
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
|
|
181
|
+
const spendable = spendableBalance(balance, expiredHoldBacking);
|
|
182
|
+
const target = Math.min(spendable, cap);
|
|
183
|
+
const kept = Math.max(
|
|
184
|
+
sumAmounts(balance, -spendable),
|
|
185
|
+
sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
|
|
186
|
+
);
|
|
187
|
+
const next = Math.min(balance, sumAmounts(target, kept));
|
|
188
|
+
return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
|
|
189
|
+
}
|
|
190
|
+
function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
|
|
191
|
+
const held = heldExpiredBacking(balance, expiredHoldBacking);
|
|
192
|
+
const used = Math.max(0, Math.min(held, holdBacking));
|
|
193
|
+
return { used, remaining: sumAmounts(held, -used) };
|
|
194
|
+
}
|
|
195
|
+
function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
|
|
196
|
+
const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
|
|
197
|
+
const rest = sumAmounts(amount, -backing.used);
|
|
198
|
+
const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
|
|
199
|
+
const fromBonus = sumAmounts(rest, -fromSpendable);
|
|
200
|
+
return {
|
|
201
|
+
covered: fromBonus <= bonusCredits,
|
|
202
|
+
balance: sumAmounts(balance, -backing.used, -fromSpendable),
|
|
203
|
+
bonusCredits: sumAmounts(bonusCredits, -fromBonus),
|
|
204
|
+
expiredHoldBacking: backing.remaining
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
var NO_EXPIRED_BACKING_PREFIX = {
|
|
208
|
+
holdPlacedAt: "",
|
|
209
|
+
id: "",
|
|
210
|
+
share: 0
|
|
211
|
+
};
|
|
212
|
+
function compareHolds(a, b) {
|
|
213
|
+
if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
|
|
214
|
+
if (a.id === b.id) return 0;
|
|
215
|
+
return a.id < b.id ? -1 : 1;
|
|
216
|
+
}
|
|
217
|
+
function recordedExpiredBacking(hold) {
|
|
218
|
+
const stored = hold.expiredBacking;
|
|
219
|
+
if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
|
|
220
|
+
if (!isValidCreditAmount(hold.amount)) return 0;
|
|
221
|
+
return Math.min(storedAmountCeil(stored), hold.amount);
|
|
222
|
+
}
|
|
223
|
+
function allocateExpiredBacking(holds, total) {
|
|
224
|
+
const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
|
|
225
|
+
const wanted = Math.max(0, total);
|
|
226
|
+
if (wanted === 0) return { total: 0, frontier: null };
|
|
227
|
+
const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
|
|
228
|
+
const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
|
|
229
|
+
let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
|
|
230
|
+
for (const hold of [...recorded].reverse()) {
|
|
231
|
+
if (left >= 0) break;
|
|
232
|
+
const share = shares.get(hold.id);
|
|
233
|
+
const cut = Math.min(share, -left);
|
|
234
|
+
shares.set(hold.id, sumAmounts(share, -cut));
|
|
235
|
+
left = sumAmounts(left, cut);
|
|
236
|
+
}
|
|
237
|
+
let frontier = NO_EXPIRED_BACKING_PREFIX;
|
|
238
|
+
for (const hold of placed) {
|
|
239
|
+
if (left <= 0) break;
|
|
240
|
+
if (shares.has(hold.id)) continue;
|
|
241
|
+
const share = Math.min(hold.amount, left);
|
|
242
|
+
frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
|
|
243
|
+
left = sumAmounts(left, -share);
|
|
244
|
+
}
|
|
245
|
+
for (const hold of recorded) {
|
|
246
|
+
if (left <= 0) break;
|
|
247
|
+
const share = shares.get(hold.id);
|
|
248
|
+
const added = Math.min(sumAmounts(hold.amount, -share), left);
|
|
249
|
+
shares.set(hold.id, sumAmounts(share, added));
|
|
250
|
+
left = sumAmounts(left, -added);
|
|
251
|
+
}
|
|
252
|
+
const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
|
|
253
|
+
return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
|
|
254
|
+
}
|
|
255
|
+
function holdExpiredBacking(hold, frontier, rewrites) {
|
|
256
|
+
const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
|
|
257
|
+
const recorded = recordedExpiredBacking(
|
|
258
|
+
rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
|
|
259
|
+
);
|
|
260
|
+
if (recorded > 0) return recorded;
|
|
261
|
+
if (!frontier || !hold.holdPlacedAt) return 0;
|
|
262
|
+
const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
|
|
263
|
+
if (order > 0) return 0;
|
|
264
|
+
return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
|
|
265
|
+
}
|
|
266
|
+
function holdPlacedAtAfter(now, frontier) {
|
|
267
|
+
if (!frontier?.holdPlacedAt) return now;
|
|
268
|
+
const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
|
|
269
|
+
return now > earliest ? now : earliest;
|
|
270
|
+
}
|
|
271
|
+
function expiredBackingPatch(next, stored) {
|
|
272
|
+
const total = Math.max(0, next.total);
|
|
273
|
+
const patch = {};
|
|
274
|
+
const storedTotal = stored.expiredHoldBacking;
|
|
275
|
+
if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
|
|
276
|
+
patch.expiredHoldBacking = total;
|
|
277
|
+
}
|
|
278
|
+
if (total <= 0) {
|
|
279
|
+
if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
|
|
280
|
+
} else if (next.frontier) {
|
|
281
|
+
patch.expiredBackingFrontier = next.frontier;
|
|
282
|
+
}
|
|
283
|
+
return patch;
|
|
284
|
+
}
|
|
285
|
+
function assertValidBalanceTarget(value, field, context) {
|
|
286
|
+
assertRepresentableAmount(value, field, context);
|
|
287
|
+
if (value < 0) {
|
|
288
|
+
throw new CreditError(
|
|
289
|
+
`${field} cannot be negative (got ${value})`,
|
|
290
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
291
|
+
{ field, value, ...context }
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function assertValidTierBalanceInput(input, context) {
|
|
296
|
+
if (input.balanceCap === void 0) {
|
|
297
|
+
if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (input.balance !== void 0) {
|
|
301
|
+
throw new CreditError(
|
|
302
|
+
"A tier update cannot set both balance and balanceCap",
|
|
303
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
304
|
+
{ balance: input.balance, balanceCap: input.balanceCap, ...context }
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
|
|
308
|
+
}
|
|
147
309
|
function assertRepresentableTierAmount(value, field, context) {
|
|
148
310
|
if (value === Number.POSITIVE_INFINITY) return;
|
|
149
311
|
assertRepresentableAmount(value, field, context);
|
|
@@ -361,9 +523,9 @@ var creditSystemConfigSchema = import_zod.z.object({
|
|
|
361
523
|
*/
|
|
362
524
|
reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
|
|
363
525
|
/**
|
|
364
|
-
* Default credits for new users (free tier)
|
|
526
|
+
* Default credits for new users (free tier). 0 means no signup grant.
|
|
365
527
|
*/
|
|
366
|
-
defaultFreeCredits: import_zod.z.number().
|
|
528
|
+
defaultFreeCredits: import_zod.z.number().min(0).default(25),
|
|
367
529
|
/**
|
|
368
530
|
* Grace period in days after subscription expires before downgrade
|
|
369
531
|
*/
|
|
@@ -793,11 +955,36 @@ function toDate(value) {
|
|
|
793
955
|
return /* @__PURE__ */ new Date();
|
|
794
956
|
}
|
|
795
957
|
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
next
|
|
800
|
-
|
|
958
|
+
return new Date(from.getFullYear(), from.getMonth() + 1, 1);
|
|
959
|
+
}
|
|
960
|
+
function validateBalanceUpdate(current, updates) {
|
|
961
|
+
const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
|
|
962
|
+
const balance = next(current.balance, updates.balance, updates.balanceIncrement);
|
|
963
|
+
const bonusCredits = next(
|
|
964
|
+
current.bonusCredits ?? 0,
|
|
965
|
+
updates.bonusCredits,
|
|
966
|
+
updates.bonusCreditsIncrement
|
|
967
|
+
);
|
|
968
|
+
const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
|
|
969
|
+
const errors = [];
|
|
970
|
+
if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
|
|
971
|
+
const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
|
|
972
|
+
if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
|
|
973
|
+
errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
|
|
974
|
+
}
|
|
975
|
+
if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
|
|
976
|
+
if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
|
|
977
|
+
const covering = sumAmounts(balance, bonusCredits);
|
|
978
|
+
if (reserved > covering) {
|
|
979
|
+
errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
|
|
980
|
+
}
|
|
981
|
+
return { valid: errors.length === 0, errors };
|
|
982
|
+
}
|
|
983
|
+
function assertValidBalanceUpdate(current, updates) {
|
|
984
|
+
const result = validateBalanceUpdate(current, updates);
|
|
985
|
+
if (!result.valid) {
|
|
986
|
+
throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
|
|
987
|
+
}
|
|
801
988
|
}
|
|
802
989
|
|
|
803
990
|
// src/repository/memory/snapshot.ts
|
|
@@ -1034,6 +1221,7 @@ function commitTransitionJournal(store, input, existingId) {
|
|
|
1034
1221
|
userId: input.userId,
|
|
1035
1222
|
entryType: input.entryType,
|
|
1036
1223
|
amount: input.amount,
|
|
1224
|
+
balanceBefore: input.balanceBefore,
|
|
1037
1225
|
balanceAfter: input.balanceAfter,
|
|
1038
1226
|
source: input.source,
|
|
1039
1227
|
referenceId: input.reservationId,
|
|
@@ -1078,6 +1266,34 @@ function assertTrustworthy(reservation, transition) {
|
|
|
1078
1266
|
assertLockedAmount(reservation, transition);
|
|
1079
1267
|
assertHoldPlaced(reservation, transition);
|
|
1080
1268
|
}
|
|
1269
|
+
function openHolds(store, userId) {
|
|
1270
|
+
return [...store.reservations.get(userId)?.values() ?? []].filter(
|
|
1271
|
+
(reservation) => reservation.status === "reserved"
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
function expiredBackingInForce(store, credits) {
|
|
1275
|
+
const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
1276
|
+
if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
|
|
1277
|
+
return allocateExpiredBacking(openHolds(store, credits.userId), total);
|
|
1278
|
+
}
|
|
1279
|
+
return { total, frontier: credits.expiredBackingFrontier ?? null };
|
|
1280
|
+
}
|
|
1281
|
+
function writeRecordedShares(store, userId, allocation) {
|
|
1282
|
+
for (const { id, expiredBacking } of allocation.rewrites ?? []) {
|
|
1283
|
+
const hold = getReservation(store, userId, id);
|
|
1284
|
+
if (hold) hold.expiredBacking = expiredBacking;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
function writeExpiredHoldBacking(store, credits, next) {
|
|
1288
|
+
if (next <= 0) {
|
|
1289
|
+
Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (next === credits.expiredHoldBacking) return;
|
|
1293
|
+
const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
|
|
1294
|
+
writeRecordedShares(store, credits.userId, allocation);
|
|
1295
|
+
Object.assign(credits, expiredBackingPatch(allocation, credits));
|
|
1296
|
+
}
|
|
1081
1297
|
function getReservation(store, userId, reservationId) {
|
|
1082
1298
|
return store.reservations.get(userId)?.get(reservationId);
|
|
1083
1299
|
}
|
|
@@ -1125,6 +1341,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1125
1341
|
};
|
|
1126
1342
|
}
|
|
1127
1343
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1344
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1128
1345
|
const reservation = {
|
|
1129
1346
|
id: generateId(),
|
|
1130
1347
|
userId: input.userId,
|
|
@@ -1137,7 +1354,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1137
1354
|
// The hold-origin fact. Written here and nowhere else, in the same
|
|
1138
1355
|
// critical section that raises `reserved` below, so the row and the hold
|
|
1139
1356
|
// become visible together. `createReservation` cannot set it.
|
|
1140
|
-
holdPlacedAt: now
|
|
1357
|
+
holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
|
|
1141
1358
|
};
|
|
1142
1359
|
if (!store.reservations.has(input.userId)) {
|
|
1143
1360
|
store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
@@ -1155,6 +1372,8 @@ async function reserveCreditsV2(store, input) {
|
|
|
1155
1372
|
);
|
|
1156
1373
|
}
|
|
1157
1374
|
credits.reserved = nextReserved;
|
|
1375
|
+
writeRecordedShares(store, input.userId, backing);
|
|
1376
|
+
Object.assign(credits, expiredBackingPatch(backing, credits));
|
|
1158
1377
|
credits.updatedAt = now;
|
|
1159
1378
|
return { outcome: "created", reservation: copyRecord(reservation) };
|
|
1160
1379
|
});
|
|
@@ -1175,22 +1394,30 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1175
1394
|
const amount = reservation.amount;
|
|
1176
1395
|
await yieldPoint(store);
|
|
1177
1396
|
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1178
|
-
|
|
1397
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1398
|
+
const paid = planHoldCommit(
|
|
1399
|
+
credits.balance,
|
|
1400
|
+
credits.bonusCredits,
|
|
1401
|
+
backing.total,
|
|
1402
|
+
holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
|
|
1403
|
+
amount
|
|
1404
|
+
);
|
|
1405
|
+
if (!paid.covered) {
|
|
1179
1406
|
throw new CreditError(
|
|
1180
1407
|
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1181
1408
|
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1182
1409
|
{ userId, reservationId, required: amount }
|
|
1183
1410
|
);
|
|
1184
1411
|
}
|
|
1185
|
-
const
|
|
1186
|
-
const
|
|
1187
|
-
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1412
|
+
const nextBalance = paid.balance;
|
|
1413
|
+
const nextBonusCredits = paid.bonusCredits;
|
|
1188
1414
|
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1189
1415
|
const journal = {
|
|
1190
1416
|
userId,
|
|
1191
1417
|
operation: "commitReservation",
|
|
1192
1418
|
entryType: "debit",
|
|
1193
1419
|
amount,
|
|
1420
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1194
1421
|
balanceAfter,
|
|
1195
1422
|
source: "operation_commit",
|
|
1196
1423
|
reservationId,
|
|
@@ -1218,6 +1445,11 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1218
1445
|
credits.bonusCredits = nextBonusCredits;
|
|
1219
1446
|
credits.reserved = nextReserved;
|
|
1220
1447
|
credits.monthlyUsed = nextMonthlyUsed;
|
|
1448
|
+
writeRecordedShares(store, userId, backing);
|
|
1449
|
+
Object.assign(
|
|
1450
|
+
credits,
|
|
1451
|
+
expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
|
|
1452
|
+
);
|
|
1221
1453
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1222
1454
|
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1223
1455
|
return {
|
|
@@ -1297,19 +1529,28 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1297
1529
|
reservation.id,
|
|
1298
1530
|
reservation.amount
|
|
1299
1531
|
);
|
|
1532
|
+
const inForce = expiredBackingInForce(store, credits);
|
|
1533
|
+
const backing = settleExpiredHoldBacking(
|
|
1534
|
+
credits.balance,
|
|
1535
|
+
inForce.total,
|
|
1536
|
+
holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
|
|
1537
|
+
);
|
|
1538
|
+
const nextBalance = sumAmounts(credits.balance, -backing.used);
|
|
1300
1539
|
const journal = {
|
|
1301
1540
|
userId: reservation.userId,
|
|
1302
1541
|
operation: `${transition.transition}Reservation`,
|
|
1303
1542
|
entryType: "credit",
|
|
1304
1543
|
amount: 0,
|
|
1305
|
-
|
|
1544
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1545
|
+
balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
|
|
1306
1546
|
source: transition.source,
|
|
1307
1547
|
reservationId: reservation.id,
|
|
1308
1548
|
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1309
1549
|
metadata: {
|
|
1310
1550
|
...transition.options?.metadata,
|
|
1311
1551
|
operationType: reservation.operationType,
|
|
1312
|
-
amount: reservation.amount
|
|
1552
|
+
amount: reservation.amount,
|
|
1553
|
+
...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
|
|
1313
1554
|
},
|
|
1314
1555
|
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1315
1556
|
};
|
|
@@ -1322,6 +1563,12 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1322
1563
|
reservation.status = transition.status;
|
|
1323
1564
|
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1324
1565
|
credits.reserved = nextReserved;
|
|
1566
|
+
credits.balance = nextBalance;
|
|
1567
|
+
writeRecordedShares(store, reservation.userId, inForce);
|
|
1568
|
+
Object.assign(
|
|
1569
|
+
credits,
|
|
1570
|
+
expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
|
|
1571
|
+
);
|
|
1325
1572
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1326
1573
|
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1327
1574
|
}
|
|
@@ -1332,6 +1579,10 @@ function isoOf(value) {
|
|
|
1332
1579
|
}
|
|
1333
1580
|
var InMemoryCreditRepository = class {
|
|
1334
1581
|
store = new MemoryStore();
|
|
1582
|
+
options;
|
|
1583
|
+
constructor(options = {}) {
|
|
1584
|
+
this.options = options;
|
|
1585
|
+
}
|
|
1335
1586
|
// ==================== User Credits ====================
|
|
1336
1587
|
/**
|
|
1337
1588
|
* A copy, never the live record.
|
|
@@ -1348,7 +1599,8 @@ var InMemoryCreditRepository = class {
|
|
|
1348
1599
|
return copyRecord(credits) ?? null;
|
|
1349
1600
|
}
|
|
1350
1601
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1351
|
-
|
|
1602
|
+
const existing = this.store.users.get(userId);
|
|
1603
|
+
return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
|
|
1352
1604
|
}
|
|
1353
1605
|
/**
|
|
1354
1606
|
* The synchronous body of `initializeUserCredits`.
|
|
@@ -1359,7 +1611,7 @@ var InMemoryCreditRepository = class {
|
|
|
1359
1611
|
* simultaneous first deliveries of the same reference would then both credit.
|
|
1360
1612
|
*/
|
|
1361
1613
|
seedUser(userId, tier, initialBalance) {
|
|
1362
|
-
|
|
1614
|
+
assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
|
|
1363
1615
|
const configured = getConfigMonthlyLimit(tier);
|
|
1364
1616
|
assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
|
|
1365
1617
|
const monthlyLimit = storedMonthlyLimit(configured);
|
|
@@ -1399,8 +1651,11 @@ var InMemoryCreditRepository = class {
|
|
|
1399
1651
|
if (!credits) {
|
|
1400
1652
|
throw new Error(`User ${userId} not found`);
|
|
1401
1653
|
}
|
|
1654
|
+
assertValidBalanceUpdate(credits, updates);
|
|
1402
1655
|
const next = { ...credits };
|
|
1403
|
-
if (updates.balance !== void 0)
|
|
1656
|
+
if (updates.balance !== void 0) {
|
|
1657
|
+
next.balance = updates.balance;
|
|
1658
|
+
}
|
|
1404
1659
|
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1405
1660
|
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1406
1661
|
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
@@ -1437,6 +1692,11 @@ var InMemoryCreditRepository = class {
|
|
|
1437
1692
|
userId,
|
|
1438
1693
|
operation: "updateUserCredits"
|
|
1439
1694
|
});
|
|
1695
|
+
writeExpiredHoldBacking(
|
|
1696
|
+
this.store,
|
|
1697
|
+
next,
|
|
1698
|
+
updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
|
|
1699
|
+
);
|
|
1440
1700
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1441
1701
|
this.store.users.set(userId, next);
|
|
1442
1702
|
}
|
|
@@ -1445,18 +1705,34 @@ var InMemoryCreditRepository = class {
|
|
|
1445
1705
|
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1446
1706
|
{ userId, operation: "updateUserTier" }
|
|
1447
1707
|
);
|
|
1708
|
+
assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
|
|
1448
1709
|
const credits = this.store.users.get(userId);
|
|
1449
1710
|
if (!credits) {
|
|
1450
1711
|
throw new Error(`User ${userId} not found`);
|
|
1451
1712
|
}
|
|
1713
|
+
if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
|
|
1714
|
+
const capped = planBalanceCap(
|
|
1715
|
+
credits.balance,
|
|
1716
|
+
input.balanceCap,
|
|
1717
|
+
credits.reserved,
|
|
1718
|
+
credits.bonusCredits,
|
|
1719
|
+
credits.expiredHoldBacking
|
|
1720
|
+
);
|
|
1721
|
+
credits.balance = capped.balance;
|
|
1722
|
+
writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
|
|
1723
|
+
}
|
|
1452
1724
|
credits.tier = input.tier;
|
|
1453
1725
|
credits.monthlyLimit = input.monthlyLimit;
|
|
1454
1726
|
if (input.balance !== void 0) {
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1727
|
+
const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
1728
|
+
credits.balance = planned.balance;
|
|
1729
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1730
|
+
}
|
|
1731
|
+
writeExpiredHoldBacking(
|
|
1732
|
+
this.store,
|
|
1733
|
+
credits,
|
|
1734
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
1735
|
+
);
|
|
1460
1736
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
1461
1737
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
1462
1738
|
if (input.subscriptionExpiresAt === null) {
|
|
@@ -1618,6 +1894,11 @@ var InMemoryCreditRepository = class {
|
|
|
1618
1894
|
{ userId, operation: "addCredits" }
|
|
1619
1895
|
);
|
|
1620
1896
|
credits.bonusCredits = nextBonusCredits;
|
|
1897
|
+
writeExpiredHoldBacking(
|
|
1898
|
+
this.store,
|
|
1899
|
+
credits,
|
|
1900
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
1901
|
+
);
|
|
1621
1902
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1622
1903
|
this.store.users.set(userId, credits);
|
|
1623
1904
|
const transaction = this.recordTransaction({
|
|
@@ -1637,6 +1918,7 @@ var InMemoryCreditRepository = class {
|
|
|
1637
1918
|
userId,
|
|
1638
1919
|
entryType: "credit",
|
|
1639
1920
|
amount,
|
|
1921
|
+
balanceBefore: previousTotal,
|
|
1640
1922
|
balanceAfter: newTotal,
|
|
1641
1923
|
source: payload.source,
|
|
1642
1924
|
referenceId: transaction.id ?? paymentRef ?? "unknown",
|
|
@@ -1670,19 +1952,23 @@ var InMemoryCreditRepository = class {
|
|
|
1670
1952
|
});
|
|
1671
1953
|
}
|
|
1672
1954
|
}
|
|
1673
|
-
async deductCreditsAtomic(userId, amount) {
|
|
1955
|
+
async deductCreditsAtomic(userId, amount, journal) {
|
|
1674
1956
|
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1675
1957
|
const credits = this.store.users.get(userId);
|
|
1676
1958
|
if (!credits) {
|
|
1677
1959
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
1678
1960
|
}
|
|
1679
|
-
const
|
|
1961
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
1962
|
+
const available = Math.min(
|
|
1963
|
+
sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
|
|
1964
|
+
sumAmounts(spendable, credits.bonusCredits)
|
|
1965
|
+
);
|
|
1680
1966
|
if (available < amount) {
|
|
1681
1967
|
throw new Error(
|
|
1682
1968
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
1683
1969
|
);
|
|
1684
1970
|
}
|
|
1685
|
-
const balanceDeduction = Math.min(
|
|
1971
|
+
const balanceDeduction = Math.min(spendable, amount);
|
|
1686
1972
|
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
1687
1973
|
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
1688
1974
|
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
@@ -1692,11 +1978,26 @@ var InMemoryCreditRepository = class {
|
|
|
1692
1978
|
{ previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
|
|
1693
1979
|
{ userId, operation: "deductCredits" }
|
|
1694
1980
|
);
|
|
1981
|
+
const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
1695
1982
|
credits.balance = nextBalance;
|
|
1696
1983
|
credits.bonusCredits = nextBonusCredits;
|
|
1984
|
+
writeExpiredHoldBacking(this.store, credits, held);
|
|
1697
1985
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1698
1986
|
this.store.users.set(userId, credits);
|
|
1699
|
-
return { previousBalance, newBalance };
|
|
1987
|
+
if (!journal) return { previousBalance, newBalance };
|
|
1988
|
+
const entry = this.recordJournalEntry({
|
|
1989
|
+
userId,
|
|
1990
|
+
entryType: "debit",
|
|
1991
|
+
amount,
|
|
1992
|
+
balanceBefore: previousBalance,
|
|
1993
|
+
balanceAfter: newBalance,
|
|
1994
|
+
source: journal.source ?? "operation_commit",
|
|
1995
|
+
referenceId: journal.referenceId,
|
|
1996
|
+
referenceType: journal.referenceType ?? "adjustment",
|
|
1997
|
+
description: journal.description,
|
|
1998
|
+
metadata: journal.metadata
|
|
1999
|
+
});
|
|
2000
|
+
return { previousBalance, newBalance, journalEntryId: entry.id };
|
|
1700
2001
|
}
|
|
1701
2002
|
// ==================== Transactions ====================
|
|
1702
2003
|
async createTransaction(input) {
|
|
@@ -1810,6 +2111,11 @@ var InMemoryCreditRepository = class {
|
|
|
1810
2111
|
assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
|
|
1811
2112
|
userId: input.userId
|
|
1812
2113
|
});
|
|
2114
|
+
if (input.balanceBefore !== void 0) {
|
|
2115
|
+
assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
|
|
2116
|
+
userId: input.userId
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
1813
2119
|
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
1814
2120
|
assertPublicJournalKey(input.idempotencyKey, input.userId);
|
|
1815
2121
|
const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
|
|
@@ -1825,6 +2131,7 @@ var InMemoryCreditRepository = class {
|
|
|
1825
2131
|
userId: input.userId,
|
|
1826
2132
|
entryType: input.entryType,
|
|
1827
2133
|
amount: input.amount,
|
|
2134
|
+
...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
|
|
1828
2135
|
balanceAfter: input.balanceAfter,
|
|
1829
2136
|
source: input.source,
|
|
1830
2137
|
referenceId: input.referenceId,
|
|
@@ -1919,7 +2226,11 @@ var InMemoryCreditRepository = class {
|
|
|
1919
2226
|
return { expiredCount, creditsReleased, errors };
|
|
1920
2227
|
}
|
|
1921
2228
|
// ==================== Atomic Monthly Reset ====================
|
|
1922
|
-
|
|
2229
|
+
/**
|
|
2230
|
+
* @param _tier - Not trusted. The caller read it earlier and the account may
|
|
2231
|
+
* have changed tier since, so the reset uses the tier stored on the account.
|
|
2232
|
+
*/
|
|
2233
|
+
async atomicMonthlyReset(userId, _tier, expectedResetAt) {
|
|
1923
2234
|
const credits = this.store.users.get(userId);
|
|
1924
2235
|
if (!credits) {
|
|
1925
2236
|
throw new Error(`User ${userId} not found`);
|
|
@@ -1929,17 +2240,41 @@ var InMemoryCreditRepository = class {
|
|
|
1929
2240
|
if (currentResetAt !== expected) {
|
|
1930
2241
|
return { wasReset: false, credits: { ...credits } };
|
|
1931
2242
|
}
|
|
1932
|
-
|
|
2243
|
+
const tier = credits.tier;
|
|
2244
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
2245
|
+
const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
|
|
2246
|
+
if (decision === null) {
|
|
2247
|
+
return { wasReset: false, credits: { ...credits } };
|
|
2248
|
+
}
|
|
2249
|
+
if (decision) {
|
|
2250
|
+
assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
|
|
2251
|
+
if (decision.monthlyLimit !== void 0) {
|
|
2252
|
+
assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
|
|
2253
|
+
userId,
|
|
2254
|
+
tier
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
} else {
|
|
2258
|
+
assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
|
|
2259
|
+
}
|
|
1933
2260
|
const nextReset = getNextMonthlyReset();
|
|
1934
|
-
const target = monthlyResetBalance(tier);
|
|
1935
|
-
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
1936
2261
|
const previousBalance = credits.balance;
|
|
1937
|
-
|
|
2262
|
+
let target;
|
|
2263
|
+
if (decision) {
|
|
2264
|
+
target = decision.balance;
|
|
2265
|
+
} else {
|
|
2266
|
+
const configured = monthlyResetBalance(tier);
|
|
2267
|
+
target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
|
|
2268
|
+
}
|
|
2269
|
+
const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
|
|
2270
|
+
const newBalance = planned.balance;
|
|
1938
2271
|
const change = sumAmounts(newBalance, -previousBalance);
|
|
1939
2272
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
1940
2273
|
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
1941
2274
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
1942
2275
|
credits.balance = newBalance;
|
|
2276
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2277
|
+
if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
|
|
1943
2278
|
credits.monthlyUsed = 0;
|
|
1944
2279
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
1945
2280
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1949,6 +2284,7 @@ var InMemoryCreditRepository = class {
|
|
|
1949
2284
|
userId,
|
|
1950
2285
|
entryType: change > 0 ? "credit" : "debit",
|
|
1951
2286
|
amount: Math.abs(change),
|
|
2287
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
1952
2288
|
balanceAfter,
|
|
1953
2289
|
source: "monthly_reset",
|
|
1954
2290
|
referenceId: `reset-${Date.now()}`,
|
|
@@ -1993,23 +2329,34 @@ var InMemoryCreditRepository = class {
|
|
|
1993
2329
|
};
|
|
1994
2330
|
}
|
|
1995
2331
|
const defaultTier = getDefaultTier();
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
tier: defaultTier
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2332
|
+
let limit;
|
|
2333
|
+
if (this.options.getDowngradeBalanceCap) {
|
|
2334
|
+
limit = this.options.getDowngradeBalanceCap();
|
|
2335
|
+
assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
|
|
2336
|
+
} else {
|
|
2337
|
+
const configured = getConfigMonthlyLimit(defaultTier);
|
|
2338
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", {
|
|
2339
|
+
userId,
|
|
2340
|
+
tier: defaultTier
|
|
2341
|
+
});
|
|
2342
|
+
limit = storedMonthlyLimit(configured);
|
|
2343
|
+
}
|
|
2002
2344
|
const previousTier = credits.tier;
|
|
2003
2345
|
const previousBalance = credits.balance;
|
|
2004
|
-
const
|
|
2005
|
-
|
|
2006
|
-
|
|
2346
|
+
const planned = planBalanceCap(
|
|
2347
|
+
credits.balance,
|
|
2348
|
+
limit,
|
|
2349
|
+
credits.reserved,
|
|
2350
|
+
credits.bonusCredits,
|
|
2351
|
+
credits.expiredHoldBacking
|
|
2007
2352
|
);
|
|
2353
|
+
const newBalance = planned.balance;
|
|
2008
2354
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2009
2355
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2010
2356
|
credits.tier = defaultTier;
|
|
2011
2357
|
credits.monthlyLimit = limit;
|
|
2012
2358
|
credits.balance = newBalance;
|
|
2359
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2013
2360
|
credits.subscriptionExpiresAt = null;
|
|
2014
2361
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2015
2362
|
this.store.users.set(userId, credits);
|
|
@@ -2018,6 +2365,7 @@ var InMemoryCreditRepository = class {
|
|
|
2018
2365
|
entryType: "debit",
|
|
2019
2366
|
amount: 0,
|
|
2020
2367
|
// No credits deducted, just tier change
|
|
2368
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
2021
2369
|
balanceAfter,
|
|
2022
2370
|
source: "subscription_downgrade",
|
|
2023
2371
|
referenceId: `downgrade-${Date.now()}`,
|
|
@@ -2067,13 +2415,14 @@ var InMemoryCreditRepository = class {
|
|
|
2067
2415
|
return copyRecords(Array.from(userReservations.values()));
|
|
2068
2416
|
}
|
|
2069
2417
|
};
|
|
2070
|
-
function createInMemoryCreditRepository() {
|
|
2071
|
-
return new InMemoryCreditRepository();
|
|
2418
|
+
function createInMemoryCreditRepository(options = {}) {
|
|
2419
|
+
return new InMemoryCreditRepository(options);
|
|
2072
2420
|
}
|
|
2073
2421
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2074
2422
|
0 && (module.exports = {
|
|
2075
2423
|
InMemoryCreditRepository,
|
|
2076
2424
|
addCreditsThroughRepository,
|
|
2425
|
+
assertValidBalanceUpdate,
|
|
2077
2426
|
commitThroughRepository,
|
|
2078
2427
|
createInMemoryCreditRepository,
|
|
2079
2428
|
generateId,
|
|
@@ -2084,6 +2433,7 @@ function createInMemoryCreditRepository() {
|
|
|
2084
2433
|
supportsCreditsV2,
|
|
2085
2434
|
supportsIdempotentCredit,
|
|
2086
2435
|
toClientUserCredits,
|
|
2087
|
-
toDate
|
|
2436
|
+
toDate,
|
|
2437
|
+
validateBalanceUpdate
|
|
2088
2438
|
});
|
|
2089
2439
|
//# sourceMappingURL=index.cjs.map
|