@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
package/dist/repository/index.js
CHANGED
|
@@ -56,6 +56,23 @@ function floatSum(values) {
|
|
|
56
56
|
for (const value of values) total += value;
|
|
57
57
|
return total;
|
|
58
58
|
}
|
|
59
|
+
var FLOAT_NOISE_FLOOR = 1e-6;
|
|
60
|
+
var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
|
|
61
|
+
function floatNoise(value) {
|
|
62
|
+
return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
|
|
63
|
+
}
|
|
64
|
+
function canonicalAmount(value) {
|
|
65
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return value;
|
|
66
|
+
const cents = Math.round(value * 100);
|
|
67
|
+
if (!Number.isSafeInteger(cents)) return value;
|
|
68
|
+
const onGrid = cents / 100 + 0;
|
|
69
|
+
return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
|
|
70
|
+
}
|
|
71
|
+
function storedAmountCeil(value) {
|
|
72
|
+
const canonical = canonicalAmount(value);
|
|
73
|
+
if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
|
|
74
|
+
return Math.ceil(canonical * 100) / 100;
|
|
75
|
+
}
|
|
59
76
|
function isValidCreditAmount(value) {
|
|
60
77
|
if (typeof value !== "number") return false;
|
|
61
78
|
const cents = toCents(value);
|
|
@@ -108,14 +125,19 @@ function backedBalanceFloor(reserved, bonusCredits) {
|
|
|
108
125
|
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
109
126
|
return floor;
|
|
110
127
|
}
|
|
111
|
-
function
|
|
112
|
-
|
|
128
|
+
function heldExpiredBacking(balance, stored) {
|
|
129
|
+
if (!(stored !== void 0 && stored > 0)) return 0;
|
|
130
|
+
return Math.max(0, Math.min(stored, balance));
|
|
131
|
+
}
|
|
132
|
+
function spendableBalance(balance, expiredHoldBacking) {
|
|
133
|
+
return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
|
|
113
134
|
}
|
|
114
135
|
function planBalanceTarget(target, reserved, bonusCredits) {
|
|
136
|
+
assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
|
|
115
137
|
const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
|
|
116
138
|
return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
|
|
117
139
|
}
|
|
118
|
-
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
|
|
140
|
+
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
|
|
119
141
|
const spendable = spendableBalance(balance, expiredHoldBacking);
|
|
120
142
|
const target = Math.min(spendable, cap);
|
|
121
143
|
const kept = Math.max(
|
|
@@ -126,8 +148,8 @@ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
|
|
|
126
148
|
return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
|
|
127
149
|
}
|
|
128
150
|
function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
|
|
129
|
-
const held =
|
|
130
|
-
const used = Math.min(held, holdBacking);
|
|
151
|
+
const held = heldExpiredBacking(balance, expiredHoldBacking);
|
|
152
|
+
const used = Math.max(0, Math.min(held, holdBacking));
|
|
131
153
|
return { used, remaining: sumAmounts(held, -used) };
|
|
132
154
|
}
|
|
133
155
|
function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
|
|
@@ -142,27 +164,83 @@ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking,
|
|
|
142
164
|
expiredHoldBacking: backing.remaining
|
|
143
165
|
};
|
|
144
166
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
167
|
+
var NO_EXPIRED_BACKING_PREFIX = {
|
|
168
|
+
holdPlacedAt: "",
|
|
169
|
+
id: "",
|
|
170
|
+
share: 0
|
|
171
|
+
};
|
|
172
|
+
function compareHolds(a, b) {
|
|
173
|
+
if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
|
|
174
|
+
if (a.id === b.id) return 0;
|
|
175
|
+
return a.id < b.id ? -1 : 1;
|
|
176
|
+
}
|
|
177
|
+
function recordedExpiredBacking(hold) {
|
|
178
|
+
const stored = hold.expiredBacking;
|
|
179
|
+
if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
|
|
180
|
+
if (!isValidCreditAmount(hold.amount)) return 0;
|
|
181
|
+
return Math.min(storedAmountCeil(stored), hold.amount);
|
|
182
|
+
}
|
|
183
|
+
function allocateExpiredBacking(holds, total) {
|
|
184
|
+
const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
|
|
185
|
+
const wanted = Math.max(0, total);
|
|
186
|
+
if (wanted === 0) return { total: 0, frontier: null };
|
|
187
|
+
const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
|
|
188
|
+
const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
|
|
189
|
+
let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
|
|
190
|
+
for (const hold of [...recorded].reverse()) {
|
|
191
|
+
if (left >= 0) break;
|
|
192
|
+
const share = shares.get(hold.id);
|
|
193
|
+
const cut = Math.min(share, -left);
|
|
194
|
+
shares.set(hold.id, sumAmounts(share, -cut));
|
|
195
|
+
left = sumAmounts(left, cut);
|
|
155
196
|
}
|
|
197
|
+
let frontier = NO_EXPIRED_BACKING_PREFIX;
|
|
156
198
|
for (const hold of placed) {
|
|
199
|
+
if (left <= 0) break;
|
|
200
|
+
if (shares.has(hold.id)) continue;
|
|
201
|
+
const share = Math.min(hold.amount, left);
|
|
202
|
+
frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
|
|
203
|
+
left = sumAmounts(left, -share);
|
|
204
|
+
}
|
|
205
|
+
for (const hold of recorded) {
|
|
206
|
+
if (left <= 0) break;
|
|
157
207
|
const share = shares.get(hold.id);
|
|
158
208
|
const added = Math.min(sumAmounts(hold.amount, -share), left);
|
|
159
209
|
shares.set(hold.id, sumAmounts(share, added));
|
|
160
210
|
left = sumAmounts(left, -added);
|
|
161
211
|
}
|
|
162
|
-
|
|
212
|
+
const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
|
|
213
|
+
return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
|
|
163
214
|
}
|
|
164
|
-
function
|
|
165
|
-
|
|
215
|
+
function holdExpiredBacking(hold, frontier, rewrites) {
|
|
216
|
+
const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
|
|
217
|
+
const recorded = recordedExpiredBacking(
|
|
218
|
+
rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
|
|
219
|
+
);
|
|
220
|
+
if (recorded > 0) return recorded;
|
|
221
|
+
if (!frontier || !hold.holdPlacedAt) return 0;
|
|
222
|
+
const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
|
|
223
|
+
if (order > 0) return 0;
|
|
224
|
+
return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
|
|
225
|
+
}
|
|
226
|
+
function holdPlacedAtAfter(now, frontier) {
|
|
227
|
+
if (!frontier?.holdPlacedAt) return now;
|
|
228
|
+
const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
|
|
229
|
+
return now > earliest ? now : earliest;
|
|
230
|
+
}
|
|
231
|
+
function expiredBackingPatch(next, stored) {
|
|
232
|
+
const total = Math.max(0, next.total);
|
|
233
|
+
const patch = {};
|
|
234
|
+
const storedTotal = stored.expiredHoldBacking;
|
|
235
|
+
if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
|
|
236
|
+
patch.expiredHoldBacking = total;
|
|
237
|
+
}
|
|
238
|
+
if (total <= 0) {
|
|
239
|
+
if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
|
|
240
|
+
} else if (next.frontier) {
|
|
241
|
+
patch.expiredBackingFrontier = next.frontier;
|
|
242
|
+
}
|
|
243
|
+
return patch;
|
|
166
244
|
}
|
|
167
245
|
function assertValidBalanceTarget(value, field, context) {
|
|
168
246
|
assertRepresentableAmount(value, field, context);
|
|
@@ -175,7 +253,10 @@ function assertValidBalanceTarget(value, field, context) {
|
|
|
175
253
|
}
|
|
176
254
|
}
|
|
177
255
|
function assertValidTierBalanceInput(input, context) {
|
|
178
|
-
if (input.balanceCap === void 0)
|
|
256
|
+
if (input.balanceCap === void 0) {
|
|
257
|
+
if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
179
260
|
if (input.balance !== void 0) {
|
|
180
261
|
throw new CreditError(
|
|
181
262
|
"A tier update cannot set both balance and balanceCap",
|
|
@@ -510,17 +591,25 @@ var DEFAULT_CONFIG = {
|
|
|
510
591
|
}
|
|
511
592
|
};
|
|
512
593
|
var currentConfig = DEFAULT_CONFIG;
|
|
594
|
+
function isOperationCostMap(value) {
|
|
595
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
596
|
+
return Object.values(value).every((cost) => isRepresentableAmount(cost) && cost >= 0);
|
|
597
|
+
}
|
|
513
598
|
function loadConfigFromEnv() {
|
|
514
599
|
const envConfig = {};
|
|
515
600
|
const operationCostsEnv = process.env.CREDITS_OPERATION_COSTS;
|
|
516
601
|
if (operationCostsEnv) {
|
|
517
602
|
try {
|
|
518
603
|
const parsed = JSON.parse(operationCostsEnv);
|
|
519
|
-
if (
|
|
604
|
+
if (isOperationCostMap(parsed)) {
|
|
520
605
|
envConfig.operationCosts = {
|
|
521
606
|
...DEFAULT_CONFIG.operationCosts,
|
|
522
607
|
...parsed
|
|
523
608
|
};
|
|
609
|
+
} else {
|
|
610
|
+
console.warn(
|
|
611
|
+
"[Credits Config] Ignoring CREDITS_OPERATION_COSTS: it must be a JSON object whose costs are numbers >= 0, exact to the cent"
|
|
612
|
+
);
|
|
524
613
|
}
|
|
525
614
|
} catch {
|
|
526
615
|
console.warn("[Credits Config] Failed to parse CREDITS_OPERATION_COSTS env var as JSON");
|
|
@@ -847,7 +936,7 @@ function validateBalanceUpdate(current, updates) {
|
|
|
847
936
|
const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
|
|
848
937
|
const errors = [];
|
|
849
938
|
if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
|
|
850
|
-
const marked = updates.balance === void 0 ? current.expiredHoldBacking
|
|
939
|
+
const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
|
|
851
940
|
if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
|
|
852
941
|
errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
|
|
853
942
|
}
|
|
@@ -865,6 +954,50 @@ function assertValidBalanceUpdate(current, updates) {
|
|
|
865
954
|
throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
|
|
866
955
|
}
|
|
867
956
|
}
|
|
957
|
+
function signupGrantJournalInput(userId, tier, grant) {
|
|
958
|
+
if (grant <= 0) return null;
|
|
959
|
+
return {
|
|
960
|
+
userId,
|
|
961
|
+
entryType: "credit",
|
|
962
|
+
amount: grant,
|
|
963
|
+
balanceBefore: 0,
|
|
964
|
+
balanceAfter: grant,
|
|
965
|
+
source: "bonus",
|
|
966
|
+
referenceId: `signup-${userId}`,
|
|
967
|
+
referenceType: "adjustment",
|
|
968
|
+
description: `Signup grant for ${tier} tier.`,
|
|
969
|
+
metadata: { tier }
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
function tierChangeJournalInput(input) {
|
|
973
|
+
const { userId, previousTier, newTier, previousBalance, newBalance, bonusCredits } = input;
|
|
974
|
+
const change = sumAmounts(newBalance, -previousBalance);
|
|
975
|
+
if (change === 0) return null;
|
|
976
|
+
const raised = change > 0;
|
|
977
|
+
const amount = raised ? newBalance : 0;
|
|
978
|
+
const balanceBefore = sumAmounts(previousBalance, bonusCredits);
|
|
979
|
+
const balanceAfter = sumAmounts(newBalance, bonusCredits);
|
|
980
|
+
assertRepresentableFields(
|
|
981
|
+
{
|
|
982
|
+
"journal amount": amount,
|
|
983
|
+
"journal balanceBefore": balanceBefore,
|
|
984
|
+
"journal balanceAfter": balanceAfter
|
|
985
|
+
},
|
|
986
|
+
{ userId, operation: "updateUserTier" }
|
|
987
|
+
);
|
|
988
|
+
return {
|
|
989
|
+
userId,
|
|
990
|
+
entryType: raised ? "credit" : "debit",
|
|
991
|
+
amount,
|
|
992
|
+
balanceBefore,
|
|
993
|
+
balanceAfter,
|
|
994
|
+
source: raised ? "subscription_upgrade" : "subscription_downgrade",
|
|
995
|
+
referenceId: `tier-${Date.now()}`,
|
|
996
|
+
referenceType: "subscription",
|
|
997
|
+
description: `Tier changed from ${previousTier} to ${newTier}.`,
|
|
998
|
+
metadata: { previousTier, newTier, previousBalance, newBalance }
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
868
1001
|
|
|
869
1002
|
// src/repository/memory/snapshot.ts
|
|
870
1003
|
function copyRecord(record) {
|
|
@@ -1100,6 +1233,7 @@ function commitTransitionJournal(store, input, existingId) {
|
|
|
1100
1233
|
userId: input.userId,
|
|
1101
1234
|
entryType: input.entryType,
|
|
1102
1235
|
amount: input.amount,
|
|
1236
|
+
balanceBefore: input.balanceBefore,
|
|
1103
1237
|
balanceAfter: input.balanceAfter,
|
|
1104
1238
|
source: input.source,
|
|
1105
1239
|
referenceId: input.reservationId,
|
|
@@ -1149,16 +1283,28 @@ function openHolds(store, userId) {
|
|
|
1149
1283
|
(reservation) => reservation.status === "reserved"
|
|
1150
1284
|
);
|
|
1151
1285
|
}
|
|
1286
|
+
function expiredBackingInForce(store, credits) {
|
|
1287
|
+
const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
1288
|
+
if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
|
|
1289
|
+
return allocateExpiredBacking(openHolds(store, credits.userId), total);
|
|
1290
|
+
}
|
|
1291
|
+
return { total, frontier: credits.expiredBackingFrontier ?? null };
|
|
1292
|
+
}
|
|
1293
|
+
function writeRecordedShares(store, userId, allocation) {
|
|
1294
|
+
for (const { id, expiredBacking } of allocation.rewrites ?? []) {
|
|
1295
|
+
const hold = getReservation(store, userId, id);
|
|
1296
|
+
if (hold) hold.expiredBacking = expiredBacking;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1152
1299
|
function writeExpiredHoldBacking(store, credits, next) {
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
const shares = attributeExpiredBacking(holds, next);
|
|
1157
|
-
for (const hold of holds) {
|
|
1158
|
-
const share = shares.get(hold.id) ?? 0;
|
|
1159
|
-
if (share !== (hold.expiredBacking ?? 0)) hold.expiredBacking = share;
|
|
1300
|
+
if (next <= 0) {
|
|
1301
|
+
Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
|
|
1302
|
+
return;
|
|
1160
1303
|
}
|
|
1161
|
-
|
|
1304
|
+
if (next === credits.expiredHoldBacking) return;
|
|
1305
|
+
const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
|
|
1306
|
+
writeRecordedShares(store, credits.userId, allocation);
|
|
1307
|
+
Object.assign(credits, expiredBackingPatch(allocation, credits));
|
|
1162
1308
|
}
|
|
1163
1309
|
function getReservation(store, userId, reservationId) {
|
|
1164
1310
|
return store.reservations.get(userId)?.get(reservationId);
|
|
@@ -1207,6 +1353,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1207
1353
|
};
|
|
1208
1354
|
}
|
|
1209
1355
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1356
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1210
1357
|
const reservation = {
|
|
1211
1358
|
id: generateId(),
|
|
1212
1359
|
userId: input.userId,
|
|
@@ -1219,7 +1366,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1219
1366
|
// The hold-origin fact. Written here and nowhere else, in the same
|
|
1220
1367
|
// critical section that raises `reserved` below, so the row and the hold
|
|
1221
1368
|
// become visible together. `createReservation` cannot set it.
|
|
1222
|
-
holdPlacedAt: now
|
|
1369
|
+
holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
|
|
1223
1370
|
};
|
|
1224
1371
|
if (!store.reservations.has(input.userId)) {
|
|
1225
1372
|
store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
@@ -1237,6 +1384,8 @@ async function reserveCreditsV2(store, input) {
|
|
|
1237
1384
|
);
|
|
1238
1385
|
}
|
|
1239
1386
|
credits.reserved = nextReserved;
|
|
1387
|
+
writeRecordedShares(store, input.userId, backing);
|
|
1388
|
+
Object.assign(credits, expiredBackingPatch(backing, credits));
|
|
1240
1389
|
credits.updatedAt = now;
|
|
1241
1390
|
return { outcome: "created", reservation: copyRecord(reservation) };
|
|
1242
1391
|
});
|
|
@@ -1257,11 +1406,12 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1257
1406
|
const amount = reservation.amount;
|
|
1258
1407
|
await yieldPoint(store);
|
|
1259
1408
|
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1409
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1260
1410
|
const paid = planHoldCommit(
|
|
1261
1411
|
credits.balance,
|
|
1262
1412
|
credits.bonusCredits,
|
|
1263
|
-
|
|
1264
|
-
reservation.
|
|
1413
|
+
backing.total,
|
|
1414
|
+
holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
|
|
1265
1415
|
amount
|
|
1266
1416
|
);
|
|
1267
1417
|
if (!paid.covered) {
|
|
@@ -1279,6 +1429,7 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1279
1429
|
operation: "commitReservation",
|
|
1280
1430
|
entryType: "debit",
|
|
1281
1431
|
amount,
|
|
1432
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1282
1433
|
balanceAfter,
|
|
1283
1434
|
source: "operation_commit",
|
|
1284
1435
|
reservationId,
|
|
@@ -1306,9 +1457,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1306
1457
|
credits.bonusCredits = nextBonusCredits;
|
|
1307
1458
|
credits.reserved = nextReserved;
|
|
1308
1459
|
credits.monthlyUsed = nextMonthlyUsed;
|
|
1460
|
+
writeRecordedShares(store, userId, backing);
|
|
1309
1461
|
Object.assign(
|
|
1310
1462
|
credits,
|
|
1311
|
-
|
|
1463
|
+
expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
|
|
1312
1464
|
);
|
|
1313
1465
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1314
1466
|
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
@@ -1389,10 +1541,11 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1389
1541
|
reservation.id,
|
|
1390
1542
|
reservation.amount
|
|
1391
1543
|
);
|
|
1544
|
+
const inForce = expiredBackingInForce(store, credits);
|
|
1392
1545
|
const backing = settleExpiredHoldBacking(
|
|
1393
1546
|
credits.balance,
|
|
1394
|
-
|
|
1395
|
-
reservation.
|
|
1547
|
+
inForce.total,
|
|
1548
|
+
holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
|
|
1396
1549
|
);
|
|
1397
1550
|
const nextBalance = sumAmounts(credits.balance, -backing.used);
|
|
1398
1551
|
const journal = {
|
|
@@ -1400,6 +1553,7 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1400
1553
|
operation: `${transition.transition}Reservation`,
|
|
1401
1554
|
entryType: "credit",
|
|
1402
1555
|
amount: 0,
|
|
1556
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1403
1557
|
balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
|
|
1404
1558
|
source: transition.source,
|
|
1405
1559
|
reservationId: reservation.id,
|
|
@@ -1422,7 +1576,11 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1422
1576
|
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1423
1577
|
credits.reserved = nextReserved;
|
|
1424
1578
|
credits.balance = nextBalance;
|
|
1425
|
-
|
|
1579
|
+
writeRecordedShares(store, reservation.userId, inForce);
|
|
1580
|
+
Object.assign(
|
|
1581
|
+
credits,
|
|
1582
|
+
expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
|
|
1583
|
+
);
|
|
1426
1584
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1427
1585
|
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1428
1586
|
}
|
|
@@ -1465,7 +1623,7 @@ var InMemoryCreditRepository = class {
|
|
|
1465
1623
|
* simultaneous first deliveries of the same reference would then both credit.
|
|
1466
1624
|
*/
|
|
1467
1625
|
seedUser(userId, tier, initialBalance) {
|
|
1468
|
-
|
|
1626
|
+
assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
|
|
1469
1627
|
const configured = getConfigMonthlyLimit(tier);
|
|
1470
1628
|
assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
|
|
1471
1629
|
const monthlyLimit = storedMonthlyLimit(configured);
|
|
@@ -1484,6 +1642,8 @@ var InMemoryCreditRepository = class {
|
|
|
1484
1642
|
updatedAt: now
|
|
1485
1643
|
};
|
|
1486
1644
|
this.store.users.set(userId, credits);
|
|
1645
|
+
const grantLine = signupGrantJournalInput(userId, tier, initialBalance);
|
|
1646
|
+
if (grantLine) this.recordJournalEntry(grantLine);
|
|
1487
1647
|
return credits;
|
|
1488
1648
|
}
|
|
1489
1649
|
async updateUserCredits(userId, updates) {
|
|
@@ -1546,9 +1706,11 @@ var InMemoryCreditRepository = class {
|
|
|
1546
1706
|
userId,
|
|
1547
1707
|
operation: "updateUserCredits"
|
|
1548
1708
|
});
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1709
|
+
writeExpiredHoldBacking(
|
|
1710
|
+
this.store,
|
|
1711
|
+
next,
|
|
1712
|
+
updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
|
|
1713
|
+
);
|
|
1552
1714
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1553
1715
|
this.store.users.set(userId, next);
|
|
1554
1716
|
}
|
|
@@ -1562,24 +1724,37 @@ var InMemoryCreditRepository = class {
|
|
|
1562
1724
|
if (!credits) {
|
|
1563
1725
|
throw new Error(`User ${userId} not found`);
|
|
1564
1726
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
1727
|
+
let planned;
|
|
1728
|
+
if (input.balance !== void 0) {
|
|
1729
|
+
planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
1730
|
+
} else if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
|
|
1731
|
+
planned = planBalanceCap(
|
|
1567
1732
|
credits.balance,
|
|
1568
1733
|
input.balanceCap,
|
|
1569
1734
|
credits.reserved,
|
|
1570
1735
|
credits.bonusCredits,
|
|
1571
1736
|
credits.expiredHoldBacking
|
|
1572
1737
|
);
|
|
1573
|
-
credits.balance = capped.balance;
|
|
1574
|
-
writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
|
|
1575
1738
|
}
|
|
1739
|
+
const journalLine = planned ? tierChangeJournalInput({
|
|
1740
|
+
userId,
|
|
1741
|
+
previousTier: credits.tier,
|
|
1742
|
+
newTier: input.tier,
|
|
1743
|
+
previousBalance: credits.balance,
|
|
1744
|
+
newBalance: planned.balance,
|
|
1745
|
+
bonusCredits: credits.bonusCredits
|
|
1746
|
+
}) : null;
|
|
1576
1747
|
credits.tier = input.tier;
|
|
1577
1748
|
credits.monthlyLimit = input.monthlyLimit;
|
|
1578
|
-
if (
|
|
1579
|
-
const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
1749
|
+
if (planned) {
|
|
1580
1750
|
credits.balance = planned.balance;
|
|
1581
1751
|
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1582
1752
|
}
|
|
1753
|
+
writeExpiredHoldBacking(
|
|
1754
|
+
this.store,
|
|
1755
|
+
credits,
|
|
1756
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
1757
|
+
);
|
|
1583
1758
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
1584
1759
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
1585
1760
|
if (input.subscriptionExpiresAt === null) {
|
|
@@ -1590,6 +1765,7 @@ var InMemoryCreditRepository = class {
|
|
|
1590
1765
|
}
|
|
1591
1766
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1592
1767
|
this.store.users.set(userId, credits);
|
|
1768
|
+
if (journalLine) this.recordJournalEntry(journalLine);
|
|
1593
1769
|
}
|
|
1594
1770
|
// ==================== Reservations ====================
|
|
1595
1771
|
async createReservation(input) {
|
|
@@ -1741,6 +1917,11 @@ var InMemoryCreditRepository = class {
|
|
|
1741
1917
|
{ userId, operation: "addCredits" }
|
|
1742
1918
|
);
|
|
1743
1919
|
credits.bonusCredits = nextBonusCredits;
|
|
1920
|
+
writeExpiredHoldBacking(
|
|
1921
|
+
this.store,
|
|
1922
|
+
credits,
|
|
1923
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
1924
|
+
);
|
|
1744
1925
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1745
1926
|
this.store.users.set(userId, credits);
|
|
1746
1927
|
const transaction = this.recordTransaction({
|
|
@@ -1760,6 +1941,7 @@ var InMemoryCreditRepository = class {
|
|
|
1760
1941
|
userId,
|
|
1761
1942
|
entryType: "credit",
|
|
1762
1943
|
amount,
|
|
1944
|
+
balanceBefore: previousTotal,
|
|
1763
1945
|
balanceAfter: newTotal,
|
|
1764
1946
|
source: payload.source,
|
|
1765
1947
|
referenceId: transaction.id ?? paymentRef ?? "unknown",
|
|
@@ -1793,7 +1975,7 @@ var InMemoryCreditRepository = class {
|
|
|
1793
1975
|
});
|
|
1794
1976
|
}
|
|
1795
1977
|
}
|
|
1796
|
-
async deductCreditsAtomic(userId, amount) {
|
|
1978
|
+
async deductCreditsAtomic(userId, amount, journal) {
|
|
1797
1979
|
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1798
1980
|
const credits = this.store.users.get(userId);
|
|
1799
1981
|
if (!credits) {
|
|
@@ -1819,11 +2001,26 @@ var InMemoryCreditRepository = class {
|
|
|
1819
2001
|
{ previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
|
|
1820
2002
|
{ userId, operation: "deductCredits" }
|
|
1821
2003
|
);
|
|
2004
|
+
const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
1822
2005
|
credits.balance = nextBalance;
|
|
1823
2006
|
credits.bonusCredits = nextBonusCredits;
|
|
2007
|
+
writeExpiredHoldBacking(this.store, credits, held);
|
|
1824
2008
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1825
2009
|
this.store.users.set(userId, credits);
|
|
1826
|
-
return { previousBalance, newBalance };
|
|
2010
|
+
if (!journal) return { previousBalance, newBalance };
|
|
2011
|
+
const entry = this.recordJournalEntry({
|
|
2012
|
+
userId,
|
|
2013
|
+
entryType: "debit",
|
|
2014
|
+
amount,
|
|
2015
|
+
balanceBefore: previousBalance,
|
|
2016
|
+
balanceAfter: newBalance,
|
|
2017
|
+
source: journal.source ?? "operation_commit",
|
|
2018
|
+
referenceId: journal.referenceId,
|
|
2019
|
+
referenceType: journal.referenceType ?? "adjustment",
|
|
2020
|
+
description: journal.description,
|
|
2021
|
+
metadata: journal.metadata
|
|
2022
|
+
});
|
|
2023
|
+
return { previousBalance, newBalance, journalEntryId: entry.id };
|
|
1827
2024
|
}
|
|
1828
2025
|
// ==================== Transactions ====================
|
|
1829
2026
|
async createTransaction(input) {
|
|
@@ -1937,6 +2134,11 @@ var InMemoryCreditRepository = class {
|
|
|
1937
2134
|
assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
|
|
1938
2135
|
userId: input.userId
|
|
1939
2136
|
});
|
|
2137
|
+
if (input.balanceBefore !== void 0) {
|
|
2138
|
+
assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
|
|
2139
|
+
userId: input.userId
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
1940
2142
|
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
1941
2143
|
assertPublicJournalKey(input.idempotencyKey, input.userId);
|
|
1942
2144
|
const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
|
|
@@ -1952,6 +2154,7 @@ var InMemoryCreditRepository = class {
|
|
|
1952
2154
|
userId: input.userId,
|
|
1953
2155
|
entryType: input.entryType,
|
|
1954
2156
|
amount: input.amount,
|
|
2157
|
+
...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
|
|
1955
2158
|
balanceAfter: input.balanceAfter,
|
|
1956
2159
|
source: input.source,
|
|
1957
2160
|
referenceId: input.referenceId,
|
|
@@ -2104,6 +2307,7 @@ var InMemoryCreditRepository = class {
|
|
|
2104
2307
|
userId,
|
|
2105
2308
|
entryType: change > 0 ? "credit" : "debit",
|
|
2106
2309
|
amount: Math.abs(change),
|
|
2310
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
2107
2311
|
balanceAfter,
|
|
2108
2312
|
source: "monthly_reset",
|
|
2109
2313
|
referenceId: `reset-${Date.now()}`,
|
|
@@ -2183,7 +2387,8 @@ var InMemoryCreditRepository = class {
|
|
|
2183
2387
|
userId,
|
|
2184
2388
|
entryType: "debit",
|
|
2185
2389
|
amount: 0,
|
|
2186
|
-
//
|
|
2390
|
+
// 0, however far the cap lowered the balance: the totals record the move
|
|
2391
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
2187
2392
|
balanceAfter,
|
|
2188
2393
|
source: "subscription_downgrade",
|
|
2189
2394
|
referenceId: `downgrade-${Date.now()}`,
|