@nehorai/credits 2.2.0 → 3.1.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/dist/adapters/index.cjs +169 -155
- 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 +169 -155
- package/dist/adapters/index.js.map +1 -1
- package/dist/core/index.cjs +148 -24
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +113 -22
- package/dist/core/index.d.ts +113 -22
- package/dist/core/index.js +137 -22
- package/dist/core/index.js.map +1 -1
- package/dist/index-BrogG0Gc.d.cts +277 -0
- package/dist/index-DOvJES2m.d.ts +277 -0
- package/dist/index.cjs +324 -71
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +311 -69
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +254 -49
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +3 -254
- package/dist/repository/index.d.ts +3 -254
- package/dist/repository/index.js +254 -49
- 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 +42 -19
- 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 +42 -19
- package/dist/service/index.js.map +1 -1
- package/dist/{types-3c-IAwZI.d.ts → types-BL3YqxUD.d.ts} +28 -6
- package/dist/{types-MaViK5tD.d.cts → types-C3sxDvNb.d.cts} +29 -6
- package/dist/{types-MaViK5tD.d.ts → types-C3sxDvNb.d.ts} +29 -6
- package/dist/{types-XSjNXO5l.d.cts → types-YLAB8bFk.d.cts} +28 -6
- package/package.json +1 -1
|
@@ -96,6 +96,23 @@ function floatSum(values) {
|
|
|
96
96
|
for (const value of values) total += value;
|
|
97
97
|
return total;
|
|
98
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
|
+
}
|
|
99
116
|
function isValidCreditAmount(value) {
|
|
100
117
|
if (typeof value !== "number") return false;
|
|
101
118
|
const cents = toCents(value);
|
|
@@ -148,14 +165,19 @@ function backedBalanceFloor(reserved, bonusCredits) {
|
|
|
148
165
|
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
149
166
|
return floor;
|
|
150
167
|
}
|
|
151
|
-
function
|
|
152
|
-
|
|
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));
|
|
153
174
|
}
|
|
154
175
|
function planBalanceTarget(target, reserved, bonusCredits) {
|
|
176
|
+
assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
|
|
155
177
|
const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
|
|
156
178
|
return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
|
|
157
179
|
}
|
|
158
|
-
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
|
|
180
|
+
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
|
|
159
181
|
const spendable = spendableBalance(balance, expiredHoldBacking);
|
|
160
182
|
const target = Math.min(spendable, cap);
|
|
161
183
|
const kept = Math.max(
|
|
@@ -166,8 +188,8 @@ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
|
|
|
166
188
|
return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
|
|
167
189
|
}
|
|
168
190
|
function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
|
|
169
|
-
const held =
|
|
170
|
-
const used = Math.min(held, holdBacking);
|
|
191
|
+
const held = heldExpiredBacking(balance, expiredHoldBacking);
|
|
192
|
+
const used = Math.max(0, Math.min(held, holdBacking));
|
|
171
193
|
return { used, remaining: sumAmounts(held, -used) };
|
|
172
194
|
}
|
|
173
195
|
function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
|
|
@@ -182,27 +204,83 @@ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking,
|
|
|
182
204
|
expiredHoldBacking: backing.remaining
|
|
183
205
|
};
|
|
184
206
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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);
|
|
195
236
|
}
|
|
237
|
+
let frontier = NO_EXPIRED_BACKING_PREFIX;
|
|
196
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;
|
|
197
247
|
const share = shares.get(hold.id);
|
|
198
248
|
const added = Math.min(sumAmounts(hold.amount, -share), left);
|
|
199
249
|
shares.set(hold.id, sumAmounts(share, added));
|
|
200
250
|
left = sumAmounts(left, -added);
|
|
201
251
|
}
|
|
202
|
-
|
|
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 } : {} };
|
|
203
254
|
}
|
|
204
|
-
function
|
|
205
|
-
|
|
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;
|
|
206
284
|
}
|
|
207
285
|
function assertValidBalanceTarget(value, field, context) {
|
|
208
286
|
assertRepresentableAmount(value, field, context);
|
|
@@ -215,7 +293,10 @@ function assertValidBalanceTarget(value, field, context) {
|
|
|
215
293
|
}
|
|
216
294
|
}
|
|
217
295
|
function assertValidTierBalanceInput(input, context) {
|
|
218
|
-
if (input.balanceCap === void 0)
|
|
296
|
+
if (input.balanceCap === void 0) {
|
|
297
|
+
if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
219
300
|
if (input.balance !== void 0) {
|
|
220
301
|
throw new CreditError(
|
|
221
302
|
"A tier update cannot set both balance and balanceCap",
|
|
@@ -550,17 +631,25 @@ var DEFAULT_CONFIG = {
|
|
|
550
631
|
}
|
|
551
632
|
};
|
|
552
633
|
var currentConfig = DEFAULT_CONFIG;
|
|
634
|
+
function isOperationCostMap(value) {
|
|
635
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
636
|
+
return Object.values(value).every((cost) => isRepresentableAmount(cost) && cost >= 0);
|
|
637
|
+
}
|
|
553
638
|
function loadConfigFromEnv() {
|
|
554
639
|
const envConfig = {};
|
|
555
640
|
const operationCostsEnv = process.env.CREDITS_OPERATION_COSTS;
|
|
556
641
|
if (operationCostsEnv) {
|
|
557
642
|
try {
|
|
558
643
|
const parsed = JSON.parse(operationCostsEnv);
|
|
559
|
-
if (
|
|
644
|
+
if (isOperationCostMap(parsed)) {
|
|
560
645
|
envConfig.operationCosts = {
|
|
561
646
|
...DEFAULT_CONFIG.operationCosts,
|
|
562
647
|
...parsed
|
|
563
648
|
};
|
|
649
|
+
} else {
|
|
650
|
+
console.warn(
|
|
651
|
+
"[Credits Config] Ignoring CREDITS_OPERATION_COSTS: it must be a JSON object whose costs are numbers >= 0, exact to the cent"
|
|
652
|
+
);
|
|
564
653
|
}
|
|
565
654
|
} catch {
|
|
566
655
|
console.warn("[Credits Config] Failed to parse CREDITS_OPERATION_COSTS env var as JSON");
|
|
@@ -887,7 +976,7 @@ function validateBalanceUpdate(current, updates) {
|
|
|
887
976
|
const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
|
|
888
977
|
const errors = [];
|
|
889
978
|
if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
|
|
890
|
-
const marked = updates.balance === void 0 ? current.expiredHoldBacking
|
|
979
|
+
const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
|
|
891
980
|
if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
|
|
892
981
|
errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
|
|
893
982
|
}
|
|
@@ -905,6 +994,50 @@ function assertValidBalanceUpdate(current, updates) {
|
|
|
905
994
|
throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
|
|
906
995
|
}
|
|
907
996
|
}
|
|
997
|
+
function signupGrantJournalInput(userId, tier, grant) {
|
|
998
|
+
if (grant <= 0) return null;
|
|
999
|
+
return {
|
|
1000
|
+
userId,
|
|
1001
|
+
entryType: "credit",
|
|
1002
|
+
amount: grant,
|
|
1003
|
+
balanceBefore: 0,
|
|
1004
|
+
balanceAfter: grant,
|
|
1005
|
+
source: "bonus",
|
|
1006
|
+
referenceId: `signup-${userId}`,
|
|
1007
|
+
referenceType: "adjustment",
|
|
1008
|
+
description: `Signup grant for ${tier} tier.`,
|
|
1009
|
+
metadata: { tier }
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
function tierChangeJournalInput(input) {
|
|
1013
|
+
const { userId, previousTier, newTier, previousBalance, newBalance, bonusCredits } = input;
|
|
1014
|
+
const change = sumAmounts(newBalance, -previousBalance);
|
|
1015
|
+
if (change === 0) return null;
|
|
1016
|
+
const raised = change > 0;
|
|
1017
|
+
const amount = raised ? newBalance : 0;
|
|
1018
|
+
const balanceBefore = sumAmounts(previousBalance, bonusCredits);
|
|
1019
|
+
const balanceAfter = sumAmounts(newBalance, bonusCredits);
|
|
1020
|
+
assertRepresentableFields(
|
|
1021
|
+
{
|
|
1022
|
+
"journal amount": amount,
|
|
1023
|
+
"journal balanceBefore": balanceBefore,
|
|
1024
|
+
"journal balanceAfter": balanceAfter
|
|
1025
|
+
},
|
|
1026
|
+
{ userId, operation: "updateUserTier" }
|
|
1027
|
+
);
|
|
1028
|
+
return {
|
|
1029
|
+
userId,
|
|
1030
|
+
entryType: raised ? "credit" : "debit",
|
|
1031
|
+
amount,
|
|
1032
|
+
balanceBefore,
|
|
1033
|
+
balanceAfter,
|
|
1034
|
+
source: raised ? "subscription_upgrade" : "subscription_downgrade",
|
|
1035
|
+
referenceId: `tier-${Date.now()}`,
|
|
1036
|
+
referenceType: "subscription",
|
|
1037
|
+
description: `Tier changed from ${previousTier} to ${newTier}.`,
|
|
1038
|
+
metadata: { previousTier, newTier, previousBalance, newBalance }
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
908
1041
|
|
|
909
1042
|
// src/repository/memory/snapshot.ts
|
|
910
1043
|
function copyRecord(record) {
|
|
@@ -1140,6 +1273,7 @@ function commitTransitionJournal(store, input, existingId) {
|
|
|
1140
1273
|
userId: input.userId,
|
|
1141
1274
|
entryType: input.entryType,
|
|
1142
1275
|
amount: input.amount,
|
|
1276
|
+
balanceBefore: input.balanceBefore,
|
|
1143
1277
|
balanceAfter: input.balanceAfter,
|
|
1144
1278
|
source: input.source,
|
|
1145
1279
|
referenceId: input.reservationId,
|
|
@@ -1189,16 +1323,28 @@ function openHolds(store, userId) {
|
|
|
1189
1323
|
(reservation) => reservation.status === "reserved"
|
|
1190
1324
|
);
|
|
1191
1325
|
}
|
|
1326
|
+
function expiredBackingInForce(store, credits) {
|
|
1327
|
+
const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
1328
|
+
if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
|
|
1329
|
+
return allocateExpiredBacking(openHolds(store, credits.userId), total);
|
|
1330
|
+
}
|
|
1331
|
+
return { total, frontier: credits.expiredBackingFrontier ?? null };
|
|
1332
|
+
}
|
|
1333
|
+
function writeRecordedShares(store, userId, allocation) {
|
|
1334
|
+
for (const { id, expiredBacking } of allocation.rewrites ?? []) {
|
|
1335
|
+
const hold = getReservation(store, userId, id);
|
|
1336
|
+
if (hold) hold.expiredBacking = expiredBacking;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1192
1339
|
function writeExpiredHoldBacking(store, credits, next) {
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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;
|
|
1340
|
+
if (next <= 0) {
|
|
1341
|
+
Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
|
|
1342
|
+
return;
|
|
1200
1343
|
}
|
|
1201
|
-
|
|
1344
|
+
if (next === credits.expiredHoldBacking) return;
|
|
1345
|
+
const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
|
|
1346
|
+
writeRecordedShares(store, credits.userId, allocation);
|
|
1347
|
+
Object.assign(credits, expiredBackingPatch(allocation, credits));
|
|
1202
1348
|
}
|
|
1203
1349
|
function getReservation(store, userId, reservationId) {
|
|
1204
1350
|
return store.reservations.get(userId)?.get(reservationId);
|
|
@@ -1247,6 +1393,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1247
1393
|
};
|
|
1248
1394
|
}
|
|
1249
1395
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1396
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1250
1397
|
const reservation = {
|
|
1251
1398
|
id: generateId(),
|
|
1252
1399
|
userId: input.userId,
|
|
@@ -1259,7 +1406,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1259
1406
|
// The hold-origin fact. Written here and nowhere else, in the same
|
|
1260
1407
|
// critical section that raises `reserved` below, so the row and the hold
|
|
1261
1408
|
// become visible together. `createReservation` cannot set it.
|
|
1262
|
-
holdPlacedAt: now
|
|
1409
|
+
holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
|
|
1263
1410
|
};
|
|
1264
1411
|
if (!store.reservations.has(input.userId)) {
|
|
1265
1412
|
store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
@@ -1277,6 +1424,8 @@ async function reserveCreditsV2(store, input) {
|
|
|
1277
1424
|
);
|
|
1278
1425
|
}
|
|
1279
1426
|
credits.reserved = nextReserved;
|
|
1427
|
+
writeRecordedShares(store, input.userId, backing);
|
|
1428
|
+
Object.assign(credits, expiredBackingPatch(backing, credits));
|
|
1280
1429
|
credits.updatedAt = now;
|
|
1281
1430
|
return { outcome: "created", reservation: copyRecord(reservation) };
|
|
1282
1431
|
});
|
|
@@ -1297,11 +1446,12 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1297
1446
|
const amount = reservation.amount;
|
|
1298
1447
|
await yieldPoint(store);
|
|
1299
1448
|
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1449
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1300
1450
|
const paid = planHoldCommit(
|
|
1301
1451
|
credits.balance,
|
|
1302
1452
|
credits.bonusCredits,
|
|
1303
|
-
|
|
1304
|
-
reservation.
|
|
1453
|
+
backing.total,
|
|
1454
|
+
holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
|
|
1305
1455
|
amount
|
|
1306
1456
|
);
|
|
1307
1457
|
if (!paid.covered) {
|
|
@@ -1319,6 +1469,7 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1319
1469
|
operation: "commitReservation",
|
|
1320
1470
|
entryType: "debit",
|
|
1321
1471
|
amount,
|
|
1472
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1322
1473
|
balanceAfter,
|
|
1323
1474
|
source: "operation_commit",
|
|
1324
1475
|
reservationId,
|
|
@@ -1346,9 +1497,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1346
1497
|
credits.bonusCredits = nextBonusCredits;
|
|
1347
1498
|
credits.reserved = nextReserved;
|
|
1348
1499
|
credits.monthlyUsed = nextMonthlyUsed;
|
|
1500
|
+
writeRecordedShares(store, userId, backing);
|
|
1349
1501
|
Object.assign(
|
|
1350
1502
|
credits,
|
|
1351
|
-
|
|
1503
|
+
expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
|
|
1352
1504
|
);
|
|
1353
1505
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1354
1506
|
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
@@ -1429,10 +1581,11 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1429
1581
|
reservation.id,
|
|
1430
1582
|
reservation.amount
|
|
1431
1583
|
);
|
|
1584
|
+
const inForce = expiredBackingInForce(store, credits);
|
|
1432
1585
|
const backing = settleExpiredHoldBacking(
|
|
1433
1586
|
credits.balance,
|
|
1434
|
-
|
|
1435
|
-
reservation.
|
|
1587
|
+
inForce.total,
|
|
1588
|
+
holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
|
|
1436
1589
|
);
|
|
1437
1590
|
const nextBalance = sumAmounts(credits.balance, -backing.used);
|
|
1438
1591
|
const journal = {
|
|
@@ -1440,6 +1593,7 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1440
1593
|
operation: `${transition.transition}Reservation`,
|
|
1441
1594
|
entryType: "credit",
|
|
1442
1595
|
amount: 0,
|
|
1596
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1443
1597
|
balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
|
|
1444
1598
|
source: transition.source,
|
|
1445
1599
|
reservationId: reservation.id,
|
|
@@ -1462,7 +1616,11 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1462
1616
|
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1463
1617
|
credits.reserved = nextReserved;
|
|
1464
1618
|
credits.balance = nextBalance;
|
|
1465
|
-
|
|
1619
|
+
writeRecordedShares(store, reservation.userId, inForce);
|
|
1620
|
+
Object.assign(
|
|
1621
|
+
credits,
|
|
1622
|
+
expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
|
|
1623
|
+
);
|
|
1466
1624
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1467
1625
|
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1468
1626
|
}
|
|
@@ -1505,7 +1663,7 @@ var InMemoryCreditRepository = class {
|
|
|
1505
1663
|
* simultaneous first deliveries of the same reference would then both credit.
|
|
1506
1664
|
*/
|
|
1507
1665
|
seedUser(userId, tier, initialBalance) {
|
|
1508
|
-
|
|
1666
|
+
assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
|
|
1509
1667
|
const configured = getConfigMonthlyLimit(tier);
|
|
1510
1668
|
assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
|
|
1511
1669
|
const monthlyLimit = storedMonthlyLimit(configured);
|
|
@@ -1524,6 +1682,8 @@ var InMemoryCreditRepository = class {
|
|
|
1524
1682
|
updatedAt: now
|
|
1525
1683
|
};
|
|
1526
1684
|
this.store.users.set(userId, credits);
|
|
1685
|
+
const grantLine = signupGrantJournalInput(userId, tier, initialBalance);
|
|
1686
|
+
if (grantLine) this.recordJournalEntry(grantLine);
|
|
1527
1687
|
return credits;
|
|
1528
1688
|
}
|
|
1529
1689
|
async updateUserCredits(userId, updates) {
|
|
@@ -1586,9 +1746,11 @@ var InMemoryCreditRepository = class {
|
|
|
1586
1746
|
userId,
|
|
1587
1747
|
operation: "updateUserCredits"
|
|
1588
1748
|
});
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1749
|
+
writeExpiredHoldBacking(
|
|
1750
|
+
this.store,
|
|
1751
|
+
next,
|
|
1752
|
+
updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
|
|
1753
|
+
);
|
|
1592
1754
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1593
1755
|
this.store.users.set(userId, next);
|
|
1594
1756
|
}
|
|
@@ -1602,24 +1764,37 @@ var InMemoryCreditRepository = class {
|
|
|
1602
1764
|
if (!credits) {
|
|
1603
1765
|
throw new Error(`User ${userId} not found`);
|
|
1604
1766
|
}
|
|
1605
|
-
|
|
1606
|
-
|
|
1767
|
+
let planned;
|
|
1768
|
+
if (input.balance !== void 0) {
|
|
1769
|
+
planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
1770
|
+
} else if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
|
|
1771
|
+
planned = planBalanceCap(
|
|
1607
1772
|
credits.balance,
|
|
1608
1773
|
input.balanceCap,
|
|
1609
1774
|
credits.reserved,
|
|
1610
1775
|
credits.bonusCredits,
|
|
1611
1776
|
credits.expiredHoldBacking
|
|
1612
1777
|
);
|
|
1613
|
-
credits.balance = capped.balance;
|
|
1614
|
-
writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
|
|
1615
1778
|
}
|
|
1779
|
+
const journalLine = planned ? tierChangeJournalInput({
|
|
1780
|
+
userId,
|
|
1781
|
+
previousTier: credits.tier,
|
|
1782
|
+
newTier: input.tier,
|
|
1783
|
+
previousBalance: credits.balance,
|
|
1784
|
+
newBalance: planned.balance,
|
|
1785
|
+
bonusCredits: credits.bonusCredits
|
|
1786
|
+
}) : null;
|
|
1616
1787
|
credits.tier = input.tier;
|
|
1617
1788
|
credits.monthlyLimit = input.monthlyLimit;
|
|
1618
|
-
if (
|
|
1619
|
-
const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
1789
|
+
if (planned) {
|
|
1620
1790
|
credits.balance = planned.balance;
|
|
1621
1791
|
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1622
1792
|
}
|
|
1793
|
+
writeExpiredHoldBacking(
|
|
1794
|
+
this.store,
|
|
1795
|
+
credits,
|
|
1796
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
1797
|
+
);
|
|
1623
1798
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
1624
1799
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
1625
1800
|
if (input.subscriptionExpiresAt === null) {
|
|
@@ -1630,6 +1805,7 @@ var InMemoryCreditRepository = class {
|
|
|
1630
1805
|
}
|
|
1631
1806
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1632
1807
|
this.store.users.set(userId, credits);
|
|
1808
|
+
if (journalLine) this.recordJournalEntry(journalLine);
|
|
1633
1809
|
}
|
|
1634
1810
|
// ==================== Reservations ====================
|
|
1635
1811
|
async createReservation(input) {
|
|
@@ -1781,6 +1957,11 @@ var InMemoryCreditRepository = class {
|
|
|
1781
1957
|
{ userId, operation: "addCredits" }
|
|
1782
1958
|
);
|
|
1783
1959
|
credits.bonusCredits = nextBonusCredits;
|
|
1960
|
+
writeExpiredHoldBacking(
|
|
1961
|
+
this.store,
|
|
1962
|
+
credits,
|
|
1963
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
1964
|
+
);
|
|
1784
1965
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1785
1966
|
this.store.users.set(userId, credits);
|
|
1786
1967
|
const transaction = this.recordTransaction({
|
|
@@ -1800,6 +1981,7 @@ var InMemoryCreditRepository = class {
|
|
|
1800
1981
|
userId,
|
|
1801
1982
|
entryType: "credit",
|
|
1802
1983
|
amount,
|
|
1984
|
+
balanceBefore: previousTotal,
|
|
1803
1985
|
balanceAfter: newTotal,
|
|
1804
1986
|
source: payload.source,
|
|
1805
1987
|
referenceId: transaction.id ?? paymentRef ?? "unknown",
|
|
@@ -1833,7 +2015,7 @@ var InMemoryCreditRepository = class {
|
|
|
1833
2015
|
});
|
|
1834
2016
|
}
|
|
1835
2017
|
}
|
|
1836
|
-
async deductCreditsAtomic(userId, amount) {
|
|
2018
|
+
async deductCreditsAtomic(userId, amount, journal) {
|
|
1837
2019
|
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1838
2020
|
const credits = this.store.users.get(userId);
|
|
1839
2021
|
if (!credits) {
|
|
@@ -1859,11 +2041,26 @@ var InMemoryCreditRepository = class {
|
|
|
1859
2041
|
{ previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
|
|
1860
2042
|
{ userId, operation: "deductCredits" }
|
|
1861
2043
|
);
|
|
2044
|
+
const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
1862
2045
|
credits.balance = nextBalance;
|
|
1863
2046
|
credits.bonusCredits = nextBonusCredits;
|
|
2047
|
+
writeExpiredHoldBacking(this.store, credits, held);
|
|
1864
2048
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1865
2049
|
this.store.users.set(userId, credits);
|
|
1866
|
-
return { previousBalance, newBalance };
|
|
2050
|
+
if (!journal) return { previousBalance, newBalance };
|
|
2051
|
+
const entry = this.recordJournalEntry({
|
|
2052
|
+
userId,
|
|
2053
|
+
entryType: "debit",
|
|
2054
|
+
amount,
|
|
2055
|
+
balanceBefore: previousBalance,
|
|
2056
|
+
balanceAfter: newBalance,
|
|
2057
|
+
source: journal.source ?? "operation_commit",
|
|
2058
|
+
referenceId: journal.referenceId,
|
|
2059
|
+
referenceType: journal.referenceType ?? "adjustment",
|
|
2060
|
+
description: journal.description,
|
|
2061
|
+
metadata: journal.metadata
|
|
2062
|
+
});
|
|
2063
|
+
return { previousBalance, newBalance, journalEntryId: entry.id };
|
|
1867
2064
|
}
|
|
1868
2065
|
// ==================== Transactions ====================
|
|
1869
2066
|
async createTransaction(input) {
|
|
@@ -1977,6 +2174,11 @@ var InMemoryCreditRepository = class {
|
|
|
1977
2174
|
assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
|
|
1978
2175
|
userId: input.userId
|
|
1979
2176
|
});
|
|
2177
|
+
if (input.balanceBefore !== void 0) {
|
|
2178
|
+
assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
|
|
2179
|
+
userId: input.userId
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
1980
2182
|
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
1981
2183
|
assertPublicJournalKey(input.idempotencyKey, input.userId);
|
|
1982
2184
|
const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
|
|
@@ -1992,6 +2194,7 @@ var InMemoryCreditRepository = class {
|
|
|
1992
2194
|
userId: input.userId,
|
|
1993
2195
|
entryType: input.entryType,
|
|
1994
2196
|
amount: input.amount,
|
|
2197
|
+
...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
|
|
1995
2198
|
balanceAfter: input.balanceAfter,
|
|
1996
2199
|
source: input.source,
|
|
1997
2200
|
referenceId: input.referenceId,
|
|
@@ -2144,6 +2347,7 @@ var InMemoryCreditRepository = class {
|
|
|
2144
2347
|
userId,
|
|
2145
2348
|
entryType: change > 0 ? "credit" : "debit",
|
|
2146
2349
|
amount: Math.abs(change),
|
|
2350
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
2147
2351
|
balanceAfter,
|
|
2148
2352
|
source: "monthly_reset",
|
|
2149
2353
|
referenceId: `reset-${Date.now()}`,
|
|
@@ -2223,7 +2427,8 @@ var InMemoryCreditRepository = class {
|
|
|
2223
2427
|
userId,
|
|
2224
2428
|
entryType: "debit",
|
|
2225
2429
|
amount: 0,
|
|
2226
|
-
//
|
|
2430
|
+
// 0, however far the cap lowered the balance: the totals record the move
|
|
2431
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
2227
2432
|
balanceAfter,
|
|
2228
2433
|
source: "subscription_downgrade",
|
|
2229
2434
|
referenceId: `downgrade-${Date.now()}`,
|