@nehorai/credits 1.7.0 → 2.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/README.md +384 -153
- package/dist/adapters/index.cjs +308 -46
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.d.cts +3 -2
- package/dist/adapters/index.d.ts +3 -2
- package/dist/adapters/index.js +308 -46
- package/dist/adapters/index.js.map +1 -1
- package/dist/auth/index.cjs.map +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.cjs +700 -76
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +583 -5
- package/dist/core/index.d.ts +583 -5
- package/dist/core/index.js +652 -76
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +1772 -371
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -7
- package/dist/index.d.ts +44 -7
- package/dist/index.js +1716 -371
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +1403 -185
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +141 -15
- package/dist/repository/index.d.ts +141 -15
- package/dist/repository/index.js +1396 -185
- package/dist/repository/index.js.map +1 -1
- package/dist/sdk/index.cjs +23 -1
- package/dist/sdk/index.cjs.map +1 -1
- package/dist/sdk/index.d.cts +2 -2
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/index.js +23 -1
- package/dist/sdk/index.js.map +1 -1
- package/dist/service/index.cjs +484 -106
- package/dist/service/index.cjs.map +1 -1
- package/dist/service/index.d.cts +68 -10
- package/dist/service/index.d.ts +68 -10
- package/dist/service/index.js +484 -106
- package/dist/service/index.js.map +1 -1
- package/dist/types-BhCdep_t.d.ts +646 -0
- package/dist/{types-CjLj3GW5.d.cts → types-Dzc443vy.d.cts} +44 -2
- package/dist/{types-CjLj3GW5.d.ts → types-Dzc443vy.d.ts} +44 -2
- package/dist/types-wtksMa4n.d.cts +646 -0
- package/package.json +71 -70
- package/LICENSE +0 -21
- package/dist/types-DFe5BoJY.d.cts +0 -329
- package/dist/types-_bSybQnb.d.ts +0 -329
|
@@ -21,15 +21,28 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var repository_exports = {};
|
|
22
22
|
__export(repository_exports, {
|
|
23
23
|
InMemoryCreditRepository: () => InMemoryCreditRepository,
|
|
24
|
+
addCreditsThroughRepository: () => addCreditsThroughRepository,
|
|
25
|
+
commitThroughRepository: () => commitThroughRepository,
|
|
24
26
|
createInMemoryCreditRepository: () => createInMemoryCreditRepository,
|
|
25
27
|
generateId: () => generateId,
|
|
26
28
|
getNextMonthlyReset: () => getNextMonthlyReset,
|
|
29
|
+
releaseThroughRepository: () => releaseThroughRepository,
|
|
30
|
+
reservationJournalKey: () => reservationJournalKey,
|
|
31
|
+
reserveThroughRepository: () => reserveThroughRepository,
|
|
32
|
+
supportsCreditsV2: () => supportsCreditsV2,
|
|
33
|
+
supportsIdempotentCredit: () => supportsIdempotentCredit,
|
|
27
34
|
toClientUserCredits: () => toClientUserCredits,
|
|
28
35
|
toDate: () => toDate
|
|
29
36
|
});
|
|
30
37
|
module.exports = __toCommonJS(repository_exports);
|
|
31
38
|
|
|
32
39
|
// src/repository/types.ts
|
|
40
|
+
function supportsCreditsV2(repository) {
|
|
41
|
+
return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
|
|
42
|
+
}
|
|
43
|
+
function supportsIdempotentCredit(repository) {
|
|
44
|
+
return typeof repository.addCreditsV2 === "function";
|
|
45
|
+
}
|
|
33
46
|
function toClientUserCredits(credits) {
|
|
34
47
|
return {
|
|
35
48
|
userId: credits.userId,
|
|
@@ -55,24 +68,246 @@ function toISOString(value) {
|
|
|
55
68
|
return (/* @__PURE__ */ new Date()).toISOString();
|
|
56
69
|
}
|
|
57
70
|
|
|
58
|
-
// src/
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
// src/core/amount.ts
|
|
72
|
+
var CREDIT_AMOUNT_SCALE = 2;
|
|
73
|
+
var CREDIT_AMOUNT_MAX = 999999999999e-2;
|
|
74
|
+
var MAX_CENTS = 999999999999;
|
|
75
|
+
function toCents(value) {
|
|
76
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
77
|
+
const cents = Math.round(value * 100);
|
|
78
|
+
if (!Number.isSafeInteger(cents)) return null;
|
|
79
|
+
return value === cents / 100 ? cents : null;
|
|
61
80
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return
|
|
81
|
+
function sumAmounts(...values) {
|
|
82
|
+
let cents = 0;
|
|
83
|
+
for (const value of values) {
|
|
84
|
+
const valueCents = toCents(value);
|
|
85
|
+
if (valueCents === null) return floatSum(values);
|
|
86
|
+
cents += valueCents;
|
|
67
87
|
}
|
|
68
|
-
return
|
|
88
|
+
return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);
|
|
69
89
|
}
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
90
|
+
function floatSum(values) {
|
|
91
|
+
let total = 0;
|
|
92
|
+
for (const value of values) total += value;
|
|
93
|
+
return total;
|
|
94
|
+
}
|
|
95
|
+
function isValidCreditAmount(value) {
|
|
96
|
+
if (typeof value !== "number") return false;
|
|
97
|
+
const cents = toCents(value);
|
|
98
|
+
return cents !== null && cents > 0 && cents <= MAX_CENTS;
|
|
99
|
+
}
|
|
100
|
+
function assertValidCreditAmount(value, context) {
|
|
101
|
+
if (isValidCreditAmount(value)) return;
|
|
102
|
+
throw new CreditError(
|
|
103
|
+
creditAmountReason(value),
|
|
104
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
105
|
+
{ amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
function creditAmountReason(value) {
|
|
109
|
+
if (typeof value !== "number") {
|
|
110
|
+
return `Credit amount must be a number (got ${typeof value})`;
|
|
111
|
+
}
|
|
112
|
+
if (!Number.isFinite(value)) {
|
|
113
|
+
return `Credit amount must be finite (got ${String(value)})`;
|
|
114
|
+
}
|
|
115
|
+
if (value <= 0) {
|
|
116
|
+
return `Credit amount must be greater than zero (got ${value})`;
|
|
117
|
+
}
|
|
118
|
+
if (value > CREDIT_AMOUNT_MAX) {
|
|
119
|
+
return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;
|
|
120
|
+
}
|
|
121
|
+
return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;
|
|
122
|
+
}
|
|
123
|
+
function isRepresentableAmount(value) {
|
|
124
|
+
if (typeof value !== "number") return false;
|
|
125
|
+
const cents = toCents(value);
|
|
126
|
+
return cents !== null && Math.abs(cents) <= MAX_CENTS;
|
|
127
|
+
}
|
|
128
|
+
function assertRepresentableAmount(value, field, context) {
|
|
129
|
+
if (isRepresentableAmount(value)) return;
|
|
130
|
+
throw new CreditError(
|
|
131
|
+
`${field} must be a finite number exact to ${CREDIT_AMOUNT_SCALE} decimal places within \xB1${CREDIT_AMOUNT_MAX} (got ${String(value)})`,
|
|
132
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
133
|
+
{ field, value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
function assertRepresentableFields(fields, context) {
|
|
137
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
138
|
+
if (value === void 0) continue;
|
|
139
|
+
assertRepresentableAmount(value, field, context);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function backedBalanceFloor(reserved, bonusCredits) {
|
|
143
|
+
const floor = Math.max(0, sumAmounts(reserved, -bonusCredits));
|
|
144
|
+
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
145
|
+
return floor;
|
|
146
|
+
}
|
|
147
|
+
function assertRepresentableTierAmount(value, field, context) {
|
|
148
|
+
if (value === Number.POSITIVE_INFINITY) return;
|
|
149
|
+
assertRepresentableAmount(value, field, context);
|
|
150
|
+
}
|
|
151
|
+
function storedMonthlyLimit(limit) {
|
|
152
|
+
return Number.isFinite(limit) ? limit : CREDIT_AMOUNT_MAX;
|
|
153
|
+
}
|
|
154
|
+
function assertValidStoredAmount(value, context) {
|
|
155
|
+
if (isValidCreditAmount(value)) return;
|
|
156
|
+
throw new CreditError(
|
|
157
|
+
`Stored credit amount ${String(value)} is not a valid amount, so this transition was refused before any state changed. The row must be repaired before it can be committed, released or expired.`,
|
|
158
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
159
|
+
{ amount: value, reason: "corrupt_stored_amount", max: CREDIT_AMOUNT_MAX, ...context }
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
function numericToCents(value) {
|
|
163
|
+
if (typeof value === "number") {
|
|
164
|
+
const cents2 = toCents(value);
|
|
165
|
+
return cents2 === null ? null : BigInt(cents2);
|
|
166
|
+
}
|
|
167
|
+
if (typeof value !== "string") return null;
|
|
168
|
+
const match = /^([+-]?)(\d+)(?:\.(\d*))?$/.exec(value.trim());
|
|
169
|
+
if (!match) return null;
|
|
170
|
+
const [, sign, whole, fraction = ""] = match;
|
|
171
|
+
if (/[1-9]/.test(fraction.slice(CREDIT_AMOUNT_SCALE))) return null;
|
|
172
|
+
const cents = BigInt(whole) * 100n + BigInt(fraction.slice(0, CREDIT_AMOUNT_SCALE).padEnd(CREDIT_AMOUNT_SCALE, "0"));
|
|
173
|
+
return sign === "-" ? -cents : cents;
|
|
174
|
+
}
|
|
175
|
+
function sameAmount(a, b) {
|
|
176
|
+
const left = numericToCents(a);
|
|
177
|
+
const right = numericToCents(b);
|
|
178
|
+
return left !== null && right !== null && left === right;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/core/errors.ts
|
|
182
|
+
var CreditErrorCode = {
|
|
183
|
+
INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
|
|
184
|
+
RESERVATION_NOT_FOUND: "RESERVATION_NOT_FOUND",
|
|
185
|
+
RESERVATION_EXPIRED: "RESERVATION_EXPIRED",
|
|
186
|
+
RESERVATION_ALREADY_PROCESSED: "RESERVATION_ALREADY_PROCESSED",
|
|
187
|
+
USER_NOT_FOUND: "USER_NOT_FOUND",
|
|
188
|
+
INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
|
|
189
|
+
CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
|
|
190
|
+
DATABASE_ERROR: "DATABASE_ERROR",
|
|
191
|
+
/** Caller reused an idempotency key with a different immutable payload. */
|
|
192
|
+
IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT",
|
|
193
|
+
/**
|
|
194
|
+
* Retryable infrastructure failure — serialization/deadlock, lock timeout,
|
|
195
|
+
* connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
|
|
196
|
+
* a deterministic failure that will fail again identically on retry.
|
|
197
|
+
*/
|
|
198
|
+
TRANSIENT_ERROR: "TRANSIENT_ERROR",
|
|
199
|
+
/** The configured repository adapter does not implement the requested V2 method. */
|
|
200
|
+
UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
|
|
201
|
+
/** Amount was not a finite positive number. */
|
|
202
|
+
INVALID_AMOUNT: "INVALID_AMOUNT",
|
|
203
|
+
/** Idempotency key was supplied but empty or whitespace-only. */
|
|
204
|
+
INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY",
|
|
205
|
+
/**
|
|
206
|
+
* A persisted reservation holds a status this library does not define, so no
|
|
207
|
+
* transition can reason about it. Quarantined rather than cast; nothing moved.
|
|
208
|
+
*/
|
|
209
|
+
CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS",
|
|
210
|
+
/**
|
|
211
|
+
* A persisted reservation does not record that its hold was placed
|
|
212
|
+
* atomically, so the credits it claims are not known to be reserved. Moving
|
|
213
|
+
* it would spend another hold's coverage; nothing moved.
|
|
214
|
+
*/
|
|
215
|
+
UNBACKED_RESERVATION: "UNBACKED_RESERVATION"
|
|
216
|
+
};
|
|
217
|
+
var CreditError = class _CreditError extends Error {
|
|
218
|
+
code;
|
|
219
|
+
details;
|
|
220
|
+
constructor(message, code, details) {
|
|
221
|
+
super(message);
|
|
222
|
+
this.name = "CreditError";
|
|
223
|
+
this.code = code;
|
|
224
|
+
this.details = details;
|
|
225
|
+
if (Error.captureStackTrace) {
|
|
226
|
+
Error.captureStackTrace(this, _CreditError);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Convert to JSON-serializable object
|
|
231
|
+
*/
|
|
232
|
+
toJSON() {
|
|
233
|
+
return {
|
|
234
|
+
name: this.name,
|
|
235
|
+
code: this.code,
|
|
236
|
+
message: this.message,
|
|
237
|
+
...this.details && { details: this.details }
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
function isCreditError(error) {
|
|
242
|
+
return error instanceof CreditError;
|
|
243
|
+
}
|
|
244
|
+
function isInsufficientCreditsError(error) {
|
|
245
|
+
if (isCreditError(error)) {
|
|
246
|
+
return error.code === CreditErrorCode.INSUFFICIENT_CREDITS;
|
|
247
|
+
}
|
|
248
|
+
if (error instanceof Error) {
|
|
249
|
+
return error.message.toLowerCase().includes("insufficient credits");
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
function createInsufficientCreditsError(required, available) {
|
|
254
|
+
return new CreditError(
|
|
255
|
+
`Insufficient credits: available ${available}, required ${required}`,
|
|
256
|
+
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
257
|
+
{ required, available, shortfall: sumAmounts(required, -available) }
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
function createReservationNotFoundError(reservationId) {
|
|
261
|
+
return new CreditError(
|
|
262
|
+
`Reservation ${reservationId} not found`,
|
|
263
|
+
CreditErrorCode.RESERVATION_NOT_FOUND,
|
|
264
|
+
{ reservationId }
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
function createReservationAlreadyProcessedError(reservationId, status) {
|
|
268
|
+
return new CreditError(
|
|
269
|
+
`Reservation ${reservationId} has already been ${status}`,
|
|
270
|
+
CreditErrorCode.RESERVATION_ALREADY_PROCESSED,
|
|
271
|
+
{ reservationId, status }
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
function createUnsupportedOperationError(operation, hint) {
|
|
275
|
+
return new CreditError(
|
|
276
|
+
hint ? `Repository does not support ${operation}. ${hint}` : `Repository does not support ${operation}`,
|
|
277
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
278
|
+
{ operation, ...hint ? { hint } : {} }
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/core/idempotency.ts
|
|
283
|
+
function isValidIdempotencyKey(value) {
|
|
284
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
285
|
+
}
|
|
286
|
+
function assertValidIdempotencyKey(value, context) {
|
|
287
|
+
if (value === void 0 || value === null) return;
|
|
288
|
+
if (isValidIdempotencyKey(value)) return;
|
|
289
|
+
throw new CreditError(
|
|
290
|
+
typeof value === "string" ? "An idempotency key was supplied but is empty or whitespace-only. It cannot deduplicate anything, so it was rejected rather than stored as a key that never matches. Omit the key for at-least-once behaviour, or send a meaningful one." : `Idempotency key must be a string (got ${typeof value})`,
|
|
291
|
+
CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
|
|
292
|
+
{ idempotencyKey: value, ...context }
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
var RESERVED_JOURNAL_KEY_PREFIX = "reservation:";
|
|
296
|
+
function isReservedJournalKey(key) {
|
|
297
|
+
return typeof key === "string" && key.startsWith(RESERVED_JOURNAL_KEY_PREFIX);
|
|
298
|
+
}
|
|
299
|
+
function assertPublicJournalKey(key, userId) {
|
|
300
|
+
if (!isReservedJournalKey(key)) return;
|
|
301
|
+
throw new CreditError(
|
|
302
|
+
`Idempotency keys starting with "${RESERVED_JOURNAL_KEY_PREFIX}" are reserved for the V2 reservation transitions and cannot be written directly. Use a key of your own, or commit/release/expire the reservation through the V2 methods.`,
|
|
303
|
+
CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
|
|
304
|
+
{ userId, idempotencyKey: key, reason: "reserved_namespace" }
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/repository/v2-types.ts
|
|
309
|
+
function reservationJournalKey(reservationId, transition) {
|
|
310
|
+
return `${RESERVED_JOURNAL_KEY_PREFIX}${reservationId}:${transition}`;
|
|
76
311
|
}
|
|
77
312
|
|
|
78
313
|
// src/config/index.ts
|
|
@@ -98,11 +333,13 @@ var creditSystemConfigSchema = import_zod.z.object({
|
|
|
98
333
|
/**
|
|
99
334
|
* Operation costs in credits
|
|
100
335
|
* Keys are operation type names (any non-empty string)
|
|
101
|
-
* Values are
|
|
336
|
+
* Values are non-negative numbers representing credit cost. 0 marks a free
|
|
337
|
+
* operation: `withCredits` runs it without touching the ledger. Amounts that
|
|
338
|
+
* move credits stay strictly positive, so `reserveCredits` refuses a 0.
|
|
102
339
|
*/
|
|
103
340
|
operationCosts: import_zod.z.record(
|
|
104
341
|
import_zod.z.string().min(1),
|
|
105
|
-
import_zod.z.number().
|
|
342
|
+
import_zod.z.number().nonnegative()
|
|
106
343
|
),
|
|
107
344
|
/**
|
|
108
345
|
* Display labels for operation types (English canonical labels).
|
|
@@ -341,6 +578,13 @@ function getDefaultTier() {
|
|
|
341
578
|
const def = entries.find(([, c]) => c.isDefault)?.[0] ?? entries.find(([, c]) => c.isFree ?? c.priceUsd === 0)?.[0];
|
|
342
579
|
return def ?? "free";
|
|
343
580
|
}
|
|
581
|
+
var UNLIMITED_BALANCE_SENTINEL = 999999;
|
|
582
|
+
function monthlyResetBalance(tier) {
|
|
583
|
+
if (isUnlimitedTier(tier)) {
|
|
584
|
+
return { kind: "atLeast", value: UNLIMITED_BALANCE_SENTINEL };
|
|
585
|
+
}
|
|
586
|
+
return { kind: "absolute", value: getConfigMonthlyLimit(tier) };
|
|
587
|
+
}
|
|
344
588
|
function getConfigTierConfig(tier) {
|
|
345
589
|
const cfg = getConfig().tierConfigs[tier];
|
|
346
590
|
if (cfg) return cfg;
|
|
@@ -354,20 +598,771 @@ function getConfigMonthlyLimit(tier) {
|
|
|
354
598
|
return isUnlimitedTier(tier) ? Infinity : getConfigTierConfig(tier).monthlyCredits;
|
|
355
599
|
}
|
|
356
600
|
var tierSchema = import_zod.z.string().refine(isValidTier, { message: "Unknown subscription tier" });
|
|
601
|
+
function snakeCaseToTitleCase(str) {
|
|
602
|
+
return str.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
603
|
+
}
|
|
604
|
+
function getOperationLabel(operationType) {
|
|
605
|
+
const labels = getConfig().operationLabels;
|
|
606
|
+
if (labels && operationType in labels) {
|
|
607
|
+
return labels[operationType];
|
|
608
|
+
}
|
|
609
|
+
return snakeCaseToTitleCase(operationType);
|
|
610
|
+
}
|
|
357
611
|
initializeConfig();
|
|
358
612
|
|
|
359
|
-
// src/
|
|
360
|
-
|
|
613
|
+
// src/core/payment-ref.ts
|
|
614
|
+
function normalizePaymentRef(ref) {
|
|
615
|
+
if (typeof ref !== "string") return void 0;
|
|
616
|
+
const trimmed = ref.trim();
|
|
617
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
618
|
+
}
|
|
619
|
+
function describePaymentMismatch(stored, expected) {
|
|
620
|
+
if (stored.userId !== expected.userId) return "userId";
|
|
621
|
+
if (!sameAmount(stored.amount, expected.amount)) return "amount";
|
|
622
|
+
if (stored.type !== expected.type) return "type";
|
|
623
|
+
if (stored.source !== void 0 && stored.source !== expected.source) return "source";
|
|
624
|
+
if (stored.referenceType !== void 0 && stored.referenceType !== expected.referenceType) {
|
|
625
|
+
return "referenceType";
|
|
626
|
+
}
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
function assertUnreferencedDirectTransaction(input) {
|
|
630
|
+
if (normalizePaymentRef(input.paymentRef) === void 0) return;
|
|
631
|
+
throw new CreditError(
|
|
632
|
+
"createTransaction cannot accept a paymentRef: it records a transaction without crediting the account, and the reference would then be treated as an already-delivered payment \u2014 a later addCredits call with the same reference reports `replayed` and credits nothing. Record unreferenced transactions here, or deliver referenced payments through addCredits (addCreditsAtomic / addCreditsV2), which claims the reference and moves the balance atomically.",
|
|
633
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
634
|
+
{
|
|
635
|
+
userId: input.userId,
|
|
636
|
+
paymentRef: input.paymentRef,
|
|
637
|
+
operation: "createTransaction",
|
|
638
|
+
reason: "referenced_direct_transaction"
|
|
639
|
+
}
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
function createPaymentRefConflictError(paymentRef, details) {
|
|
643
|
+
return new CreditError(
|
|
644
|
+
`Payment reference ${paymentRef} has already been used for a different credit event (${details.mismatch} differs). Nothing was credited. A payment reference identifies one credit event globally; reusing it for another user, amount or source is refused rather than silently ignored.`,
|
|
645
|
+
CreditErrorCode.IDEMPOTENCY_CONFLICT,
|
|
646
|
+
{ paymentRef, reason: "payment_ref_conflict", ...details }
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/repository/flow.ts
|
|
651
|
+
async function reserveThroughRepository(repository, input) {
|
|
652
|
+
const v2 = supportsCreditsV2(repository);
|
|
653
|
+
if (!v2 && input.idempotencyKey !== void 0) {
|
|
654
|
+
throw createUnsupportedOperationError(
|
|
655
|
+
"reserveCredits with an idempotencyKey",
|
|
656
|
+
"This repository does not implement the V2 boundary, so it cannot enforce idempotency keys. Use a V2 repository (check with supportsCreditsV2) or omit the key and accept at-least-once holds."
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
assertValidCreditAmount(input.amount, { userId: input.userId });
|
|
660
|
+
if (v2) {
|
|
661
|
+
return repository.reserveCreditsV2(input);
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
const reservation = await repository.reserveCreditsAtomic(
|
|
665
|
+
input.userId,
|
|
666
|
+
input.amount,
|
|
667
|
+
input.operationType,
|
|
668
|
+
input.expiresAt
|
|
669
|
+
);
|
|
670
|
+
return { outcome: "created", reservation };
|
|
671
|
+
} catch (error) {
|
|
672
|
+
if (!isInsufficientCreditsError(error)) throw error;
|
|
673
|
+
const credits = await repository.getUserCredits(input.userId);
|
|
674
|
+
const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
|
|
675
|
+
return {
|
|
676
|
+
outcome: "insufficient",
|
|
677
|
+
available,
|
|
678
|
+
required: input.amount,
|
|
679
|
+
shortfall: Math.max(sumAmounts(input.amount, -available), 0)
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
async function commitThroughRepository(repository, userId, reservationId, options) {
|
|
684
|
+
if (supportsCreditsV2(repository)) {
|
|
685
|
+
return repository.commitReservationV2(userId, reservationId, options);
|
|
686
|
+
}
|
|
687
|
+
const reservation = await repository.getReservation(userId, reservationId);
|
|
688
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
689
|
+
if (reservation.status !== "reserved") {
|
|
690
|
+
return {
|
|
691
|
+
outcome: "already_terminal",
|
|
692
|
+
reservation,
|
|
693
|
+
terminalStatus: reservation.status
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
await repository.commitReservationAtomic(userId, reservationId);
|
|
697
|
+
const credits = await repository.getUserCredits(userId);
|
|
698
|
+
const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
|
|
699
|
+
const entry = await repository.createJournalEntry({
|
|
700
|
+
userId,
|
|
701
|
+
entryType: "debit",
|
|
702
|
+
amount: reservation.amount,
|
|
703
|
+
balanceAfter,
|
|
704
|
+
source: "operation_commit",
|
|
705
|
+
referenceId: reservationId,
|
|
706
|
+
referenceType: "reservation",
|
|
707
|
+
description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
708
|
+
// Deterministic fields last: they carry the identity of the event, and a
|
|
709
|
+
// caller who happens to send an `operationType` or an `amount` must not
|
|
710
|
+
// relabel what actually moved.
|
|
711
|
+
metadata: {
|
|
712
|
+
...options?.metadata,
|
|
713
|
+
operationType: reservation.operationType,
|
|
714
|
+
amount: reservation.amount
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
return {
|
|
718
|
+
outcome: "committed",
|
|
719
|
+
reservation: { ...reservation, status: "committed" },
|
|
720
|
+
amount: reservation.amount,
|
|
721
|
+
balanceAfter,
|
|
722
|
+
journalEntryId: entry.id
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
async function releaseThroughRepository(repository, userId, reservationId, options) {
|
|
726
|
+
if (supportsCreditsV2(repository)) {
|
|
727
|
+
return repository.releaseReservationV2(userId, reservationId, options);
|
|
728
|
+
}
|
|
729
|
+
const reservation = await repository.getReservation(userId, reservationId);
|
|
730
|
+
if (!reservation) {
|
|
731
|
+
await repository.releaseReservationAtomic(userId, reservationId);
|
|
732
|
+
return { outcome: "not_found", reservationId };
|
|
733
|
+
}
|
|
734
|
+
if (reservation.status !== "reserved") {
|
|
735
|
+
await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
|
|
736
|
+
return {
|
|
737
|
+
outcome: "already_terminal",
|
|
738
|
+
reservation,
|
|
739
|
+
terminalStatus: reservation.status
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
await repository.releaseReservationAtomic(userId, reservationId);
|
|
743
|
+
const credits = await repository.getUserCredits(userId);
|
|
744
|
+
const entry = await repository.createJournalEntry({
|
|
745
|
+
userId,
|
|
746
|
+
entryType: "credit",
|
|
747
|
+
// No credits changed hands — the hold just moved back into "available".
|
|
748
|
+
amount: 0,
|
|
749
|
+
balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
|
|
750
|
+
source: "operation_release",
|
|
751
|
+
referenceId: reservationId,
|
|
752
|
+
referenceType: "reservation",
|
|
753
|
+
description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
754
|
+
metadata: {
|
|
755
|
+
...options?.metadata,
|
|
756
|
+
operationType: reservation.operationType,
|
|
757
|
+
amount: reservation.amount
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
return {
|
|
761
|
+
outcome: "released",
|
|
762
|
+
reservation: { ...reservation, status: "released" },
|
|
763
|
+
amount: reservation.amount,
|
|
764
|
+
journalEntryId: entry.id
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
async function addCreditsThroughRepository(repository, input) {
|
|
768
|
+
assertValidCreditAmount(input.amount, { userId: input.userId, operation: "addCredits" });
|
|
769
|
+
const paymentRef = normalizePaymentRef(input.paymentRef);
|
|
770
|
+
if (supportsIdempotentCredit(repository)) {
|
|
771
|
+
return repository.addCreditsV2({ ...input, paymentRef });
|
|
772
|
+
}
|
|
773
|
+
await repository.addCreditsAtomic(
|
|
774
|
+
input.userId,
|
|
775
|
+
input.amount,
|
|
776
|
+
input.description,
|
|
777
|
+
paymentRef,
|
|
778
|
+
input.options
|
|
779
|
+
);
|
|
780
|
+
return paymentRef ? { outcome: "created", paymentRef } : { outcome: "created" };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/repository/utils.ts
|
|
784
|
+
function generateId() {
|
|
785
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
786
|
+
}
|
|
787
|
+
function toDate(value) {
|
|
788
|
+
if (value instanceof Date) return value;
|
|
789
|
+
if (typeof value === "string") return new Date(value);
|
|
790
|
+
if (typeof value === "object" && value !== null && "toDate" in value) {
|
|
791
|
+
return value.toDate();
|
|
792
|
+
}
|
|
793
|
+
return /* @__PURE__ */ new Date();
|
|
794
|
+
}
|
|
795
|
+
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
796
|
+
const next = new Date(from);
|
|
797
|
+
next.setMonth(next.getMonth() + 1);
|
|
798
|
+
next.setDate(1);
|
|
799
|
+
next.setHours(0, 0, 0, 0);
|
|
800
|
+
return next;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// src/repository/memory/snapshot.ts
|
|
804
|
+
function copyRecord(record) {
|
|
805
|
+
if (!record) return void 0;
|
|
806
|
+
const copy = { ...record };
|
|
807
|
+
const metadata = record.metadata;
|
|
808
|
+
if (metadata && typeof metadata === "object") {
|
|
809
|
+
copy.metadata = { ...metadata };
|
|
810
|
+
}
|
|
811
|
+
return copy;
|
|
812
|
+
}
|
|
813
|
+
function copyRecords(records) {
|
|
814
|
+
return records.map((record) => copyRecord(record));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// src/core/reservation-integrity.ts
|
|
818
|
+
var RESERVATION_STATUSES = [
|
|
819
|
+
"reserved",
|
|
820
|
+
"committed",
|
|
821
|
+
"released",
|
|
822
|
+
"expired"
|
|
823
|
+
];
|
|
824
|
+
var TERMINAL_RESERVATION_STATUSES = [
|
|
825
|
+
"committed",
|
|
826
|
+
"released",
|
|
827
|
+
"expired"
|
|
828
|
+
];
|
|
829
|
+
function isReservationStatus(value) {
|
|
830
|
+
return RESERVATION_STATUSES.includes(value);
|
|
831
|
+
}
|
|
832
|
+
function isTerminalReservationStatus(value) {
|
|
833
|
+
return TERMINAL_RESERVATION_STATUSES.includes(value);
|
|
834
|
+
}
|
|
835
|
+
function assertKnownReservationStatus(reservation, transition) {
|
|
836
|
+
if (isReservationStatus(reservation.status)) return;
|
|
837
|
+
throw new CreditError(
|
|
838
|
+
`Reservation ${reservation.id} holds the status ${JSON.stringify(reservation.status)}, which is not one of ${RESERVATION_STATUSES.join(", ")}. The ${transition} was refused before any state changed; the row must be repaired by an operator.`,
|
|
839
|
+
CreditErrorCode.CORRUPT_RESERVATION_STATUS,
|
|
840
|
+
{
|
|
841
|
+
userId: reservation.userId,
|
|
842
|
+
reservationId: reservation.id,
|
|
843
|
+
transition,
|
|
844
|
+
status: typeof reservation.status === "string" ? reservation.status : String(reservation.status),
|
|
845
|
+
allowed: [...RESERVATION_STATUSES],
|
|
846
|
+
reason: "corrupt_reservation_status"
|
|
847
|
+
}
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
function terminalStatusOf(reservation, transition) {
|
|
851
|
+
assertKnownReservationStatus(reservation, transition);
|
|
852
|
+
if (isTerminalReservationStatus(reservation.status)) return reservation.status;
|
|
853
|
+
throw new CreditError(
|
|
854
|
+
`Reservation ${reservation.id} is ${reservation.status}, which is not a terminal status, but the ${transition} tried to report it as one.`,
|
|
855
|
+
CreditErrorCode.CORRUPT_RESERVATION_STATUS,
|
|
856
|
+
{
|
|
857
|
+
userId: reservation.userId,
|
|
858
|
+
reservationId: reservation.id,
|
|
859
|
+
transition,
|
|
860
|
+
status: reservation.status,
|
|
861
|
+
reason: "not_terminal"
|
|
862
|
+
}
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
function assertHoldPlaced(reservation, transition) {
|
|
866
|
+
if (reservation.holdPlacedAt) return;
|
|
867
|
+
throw new CreditError(
|
|
868
|
+
`Reservation ${reservation.id} does not record that its hold was placed atomically (holdPlacedAt is absent), so the credits it claims to hold may never have been reserved. The ${transition} was refused before any state changed: honouring it would spend coverage belonging to another reservation. Rows written by createReservation are not V2 reservations; place holds with reserveCredits.`,
|
|
869
|
+
CreditErrorCode.UNBACKED_RESERVATION,
|
|
870
|
+
{
|
|
871
|
+
userId: reservation.userId,
|
|
872
|
+
reservationId: reservation.id,
|
|
873
|
+
transition,
|
|
874
|
+
idempotencyKey: reservation.idempotencyKey ?? void 0,
|
|
875
|
+
reason: "hold_not_placed"
|
|
876
|
+
}
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
function assertUnkeyedDirectReservation(input) {
|
|
880
|
+
if (input.idempotencyKey === void 0 || input.idempotencyKey === null) return;
|
|
881
|
+
throw new CreditError(
|
|
882
|
+
"createReservation cannot accept an idempotencyKey: it writes the reservation row without placing the hold, so the key would name credits that were never reserved, and a later reserveCredits with the same key would adopt it as a replay. Call reserveCredits (or reserveCreditsV2) with the key instead \u2014 it claims the key and places the hold in one transaction.",
|
|
883
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
884
|
+
{
|
|
885
|
+
userId: input.userId,
|
|
886
|
+
idempotencyKey: input.idempotencyKey,
|
|
887
|
+
operation: "createReservation",
|
|
888
|
+
reason: "keyed_direct_reservation"
|
|
889
|
+
}
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
function assertDirectStatusWriteAllowed(reservation, status) {
|
|
893
|
+
if (status === "reserved") {
|
|
894
|
+
throw new CreditError(
|
|
895
|
+
`Reservation ${reservation.id} cannot have its status set back to "reserved": updateReservationStatus does not place a hold, so the reopened row would claim credits nothing reserved. Create a new reservation with reserveCredits instead.`,
|
|
896
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
897
|
+
{
|
|
898
|
+
userId: reservation.userId,
|
|
899
|
+
reservationId: reservation.id,
|
|
900
|
+
status,
|
|
901
|
+
reason: "reopen_reservation"
|
|
902
|
+
}
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
if (!reservation.holdPlacedAt) return;
|
|
906
|
+
throw new CreditError(
|
|
907
|
+
`Reservation ${reservation.id} is backed by an atomically placed hold, so its status may only be assigned by the transition that settles it. Writing "${status}" directly would change the status without moving the credits it stands for, stranding the hold. Use commitReservation / releaseReservation / expireReservation (or their V2 forms) instead.`,
|
|
908
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
909
|
+
{
|
|
910
|
+
userId: reservation.userId,
|
|
911
|
+
reservationId: reservation.id,
|
|
912
|
+
status,
|
|
913
|
+
reason: "direct_status_write_on_backed_hold"
|
|
914
|
+
}
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// src/repository/memory/mutex.ts
|
|
919
|
+
var KeyedMutex = class {
|
|
920
|
+
tails = /* @__PURE__ */ new Map();
|
|
921
|
+
/**
|
|
922
|
+
* Run `fn` with exclusive access to `key`.
|
|
923
|
+
*
|
|
924
|
+
* Callers queue in arrival order. The stored tail settles either way, so one
|
|
925
|
+
* caller throwing does not wedge the queue behind it.
|
|
926
|
+
*/
|
|
927
|
+
async run(key, fn) {
|
|
928
|
+
const previous = this.tails.get(key) ?? Promise.resolve();
|
|
929
|
+
const result = previous.then(fn, fn);
|
|
930
|
+
const tail = result.then(
|
|
931
|
+
() => void 0,
|
|
932
|
+
() => void 0
|
|
933
|
+
);
|
|
934
|
+
this.tails.set(key, tail);
|
|
935
|
+
try {
|
|
936
|
+
return await result;
|
|
937
|
+
} finally {
|
|
938
|
+
if (this.tails.get(key) === tail) {
|
|
939
|
+
this.tails.delete(key);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
// src/repository/memory/store.ts
|
|
946
|
+
var MemoryStore = class {
|
|
361
947
|
users = /* @__PURE__ */ new Map();
|
|
362
948
|
reservations = /* @__PURE__ */ new Map();
|
|
363
949
|
transactions = /* @__PURE__ */ new Map();
|
|
364
950
|
usageLogs = [];
|
|
365
951
|
journalEntries = /* @__PURE__ */ new Map();
|
|
952
|
+
/** Stands in for the reservations table's partial unique index. */
|
|
953
|
+
reservationKeys = /* @__PURE__ */ new Map();
|
|
954
|
+
/** Stands in for the journal table's partial unique index. */
|
|
955
|
+
journalKeys = /* @__PURE__ */ new Map();
|
|
956
|
+
/** Stands in for the database's per-user row locks. */
|
|
957
|
+
locks = new KeyedMutex();
|
|
958
|
+
/**
|
|
959
|
+
* Test-only yield point, awaited between reading state and mutating it.
|
|
960
|
+
*
|
|
961
|
+
* Unset in production, so the critical sections stay synchronous and this
|
|
962
|
+
* costs nothing. Tests set it to a real macrotask yield, which forces
|
|
963
|
+
* concurrent callers to interleave at exactly the spot a database would have
|
|
964
|
+
* them contend — turning the lock above from decoration into the thing the
|
|
965
|
+
* concurrency tests actually depend on.
|
|
966
|
+
*/
|
|
967
|
+
schedulingHook;
|
|
968
|
+
clear() {
|
|
969
|
+
this.users.clear();
|
|
970
|
+
this.reservations.clear();
|
|
971
|
+
this.transactions.clear();
|
|
972
|
+
this.usageLogs = [];
|
|
973
|
+
this.journalEntries.clear();
|
|
974
|
+
this.reservationKeys.clear();
|
|
975
|
+
this.journalKeys.clear();
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
function scopedKey(userId, key) {
|
|
979
|
+
return `${userId}
|
|
980
|
+
${key}`;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// src/repository/memory/transition-journal.ts
|
|
984
|
+
function describeJournalMismatch(row, input) {
|
|
985
|
+
if (row.userId !== input.userId) return "user_id";
|
|
986
|
+
if (row.entryType !== input.entryType) return "entry_type";
|
|
987
|
+
if (!sameAmount(row.amount, input.amount)) return "amount";
|
|
988
|
+
if (!sameAmount(row.balanceAfter, input.balanceAfter)) return "balance_after";
|
|
989
|
+
if (row.source !== input.source) return "source";
|
|
990
|
+
if (row.referenceId !== input.reservationId) return "reference_id";
|
|
991
|
+
if (row.referenceType !== "reservation") return "reference_type";
|
|
992
|
+
const existingMeta = row.metadata ?? {};
|
|
993
|
+
const expectedMeta = input.metadata ?? {};
|
|
994
|
+
if (existingMeta.operationType !== expectedMeta.operationType) {
|
|
995
|
+
return "metadata.operationType";
|
|
996
|
+
}
|
|
997
|
+
const hadAmount = existingMeta.amount !== void 0;
|
|
998
|
+
const wantsAmount = expectedMeta.amount !== void 0;
|
|
999
|
+
if (hadAmount !== wantsAmount) return "metadata.amount";
|
|
1000
|
+
if (wantsAmount && !sameAmount(existingMeta.amount, expectedMeta.amount)) {
|
|
1001
|
+
return "metadata.amount";
|
|
1002
|
+
}
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
function findEntry(store, userId, entryId) {
|
|
1006
|
+
return store.journalEntries.get(userId)?.find((entry) => entry.id === entryId);
|
|
1007
|
+
}
|
|
1008
|
+
function planTransitionJournal(store, input) {
|
|
1009
|
+
assertRepresentableFields(
|
|
1010
|
+
{ amount: input.amount, "journal balanceAfter": input.balanceAfter },
|
|
1011
|
+
{ userId: input.userId, reservationId: input.reservationId, operation: input.operation }
|
|
1012
|
+
);
|
|
1013
|
+
const existingId = store.journalKeys.get(scopedKey(input.userId, input.idempotencyKey));
|
|
1014
|
+
if (!existingId) return void 0;
|
|
1015
|
+
const row = findEntry(store, input.userId, existingId);
|
|
1016
|
+
const mismatch = row ? describeJournalMismatch(row, input) : "the key is registered but its entry is missing";
|
|
1017
|
+
if (mismatch) {
|
|
1018
|
+
throw new CreditError(
|
|
1019
|
+
`Journal key ${input.idempotencyKey} is already used by a different entry (${mismatch}). The transition was refused rather than reported as applied.`,
|
|
1020
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
1021
|
+
{
|
|
1022
|
+
idempotencyKey: input.idempotencyKey,
|
|
1023
|
+
reservationId: input.reservationId,
|
|
1024
|
+
mismatch
|
|
1025
|
+
}
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
return existingId;
|
|
1029
|
+
}
|
|
1030
|
+
function commitTransitionJournal(store, input, existingId) {
|
|
1031
|
+
if (existingId) return existingId;
|
|
1032
|
+
const entry = {
|
|
1033
|
+
id: generateId(),
|
|
1034
|
+
userId: input.userId,
|
|
1035
|
+
entryType: input.entryType,
|
|
1036
|
+
amount: input.amount,
|
|
1037
|
+
balanceAfter: input.balanceAfter,
|
|
1038
|
+
source: input.source,
|
|
1039
|
+
referenceId: input.reservationId,
|
|
1040
|
+
referenceType: "reservation",
|
|
1041
|
+
description: input.description,
|
|
1042
|
+
metadata: input.metadata,
|
|
1043
|
+
idempotencyKey: input.idempotencyKey,
|
|
1044
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1045
|
+
};
|
|
1046
|
+
if (!store.journalEntries.has(input.userId)) {
|
|
1047
|
+
store.journalEntries.set(input.userId, []);
|
|
1048
|
+
}
|
|
1049
|
+
store.journalEntries.get(input.userId).push(entry);
|
|
1050
|
+
store.journalKeys.set(scopedKey(input.userId, input.idempotencyKey), entry.id);
|
|
1051
|
+
return entry.id;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// src/repository/memory/v2.ts
|
|
1055
|
+
async function yieldPoint(store) {
|
|
1056
|
+
if (store.schedulingHook) await store.schedulingHook();
|
|
1057
|
+
}
|
|
1058
|
+
function assertReservedCoversHold(credits, userId, reservationId, amount) {
|
|
1059
|
+
if (credits.reserved >= amount) return;
|
|
1060
|
+
throw new CreditError(
|
|
1061
|
+
`Credit balance invariant violated for user ${userId} while processing reservation ${reservationId}: reserved (${credits.reserved}) is less than the hold (${amount}). The transition was rolled back.`,
|
|
1062
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
1063
|
+
{ userId, reservationId, amount, reserved: credits.reserved }
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
function assertLockedAmount(reservation, transition) {
|
|
1067
|
+
assertValidStoredAmount(reservation.amount, {
|
|
1068
|
+
userId: reservation.userId,
|
|
1069
|
+
reservationId: reservation.id,
|
|
1070
|
+
transition
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
function terminalStatusOf2(reservation, transition) {
|
|
1074
|
+
return terminalStatusOf(reservation, transition);
|
|
1075
|
+
}
|
|
1076
|
+
function assertTrustworthy(reservation, transition) {
|
|
1077
|
+
assertKnownReservationStatus(reservation, transition);
|
|
1078
|
+
assertLockedAmount(reservation, transition);
|
|
1079
|
+
assertHoldPlaced(reservation, transition);
|
|
1080
|
+
}
|
|
1081
|
+
function getReservation(store, userId, reservationId) {
|
|
1082
|
+
return store.reservations.get(userId)?.get(reservationId);
|
|
1083
|
+
}
|
|
1084
|
+
function requireUser(store, userId) {
|
|
1085
|
+
const credits = store.users.get(userId);
|
|
1086
|
+
if (!credits) {
|
|
1087
|
+
throw new CreditError(
|
|
1088
|
+
`User credits not found for user ${userId}`,
|
|
1089
|
+
CreditErrorCode.USER_NOT_FOUND,
|
|
1090
|
+
{ userId }
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
return credits;
|
|
1094
|
+
}
|
|
1095
|
+
async function reserveCreditsV2(store, input) {
|
|
1096
|
+
assertValidCreditAmount(input.amount, { userId: input.userId });
|
|
1097
|
+
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
1098
|
+
return store.locks.run(input.userId, async () => {
|
|
1099
|
+
if (input.idempotencyKey) {
|
|
1100
|
+
const existingId = store.reservationKeys.get(
|
|
1101
|
+
scopedKey(input.userId, input.idempotencyKey)
|
|
1102
|
+
);
|
|
1103
|
+
if (existingId) {
|
|
1104
|
+
const existing = getReservation(store, input.userId, existingId);
|
|
1105
|
+
if (existing) {
|
|
1106
|
+
assertHoldPlaced(existing, "reserve replay");
|
|
1107
|
+
const samePayload = existing.amount === input.amount && existing.operationType === input.operationType;
|
|
1108
|
+
return samePayload ? { outcome: "replayed", reservation: copyRecord(existing) } : {
|
|
1109
|
+
outcome: "idempotency_conflict",
|
|
1110
|
+
idempotencyKey: input.idempotencyKey,
|
|
1111
|
+
existing: copyRecord(existing)
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
const credits = requireUser(store, input.userId);
|
|
1117
|
+
const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
|
|
1118
|
+
await yieldPoint(store);
|
|
1119
|
+
if (available < input.amount) {
|
|
1120
|
+
return {
|
|
1121
|
+
outcome: "insufficient",
|
|
1122
|
+
available,
|
|
1123
|
+
required: input.amount,
|
|
1124
|
+
shortfall: sumAmounts(input.amount, -available)
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1128
|
+
const reservation = {
|
|
1129
|
+
id: generateId(),
|
|
1130
|
+
userId: input.userId,
|
|
1131
|
+
amount: input.amount,
|
|
1132
|
+
operationType: input.operationType,
|
|
1133
|
+
status: "reserved",
|
|
1134
|
+
createdAt: now,
|
|
1135
|
+
expiresAt: input.expiresAt.toISOString(),
|
|
1136
|
+
idempotencyKey: input.idempotencyKey,
|
|
1137
|
+
// The hold-origin fact. Written here and nowhere else, in the same
|
|
1138
|
+
// critical section that raises `reserved` below, so the row and the hold
|
|
1139
|
+
// become visible together. `createReservation` cannot set it.
|
|
1140
|
+
holdPlacedAt: now
|
|
1141
|
+
};
|
|
1142
|
+
if (!store.reservations.has(input.userId)) {
|
|
1143
|
+
store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
1144
|
+
}
|
|
1145
|
+
const nextReserved = sumAmounts(credits.reserved, input.amount);
|
|
1146
|
+
assertRepresentableFields(
|
|
1147
|
+
{ reserved: nextReserved },
|
|
1148
|
+
{ userId: input.userId, operation: "reserveCredits" }
|
|
1149
|
+
);
|
|
1150
|
+
store.reservations.get(input.userId).set(reservation.id, reservation);
|
|
1151
|
+
if (input.idempotencyKey) {
|
|
1152
|
+
store.reservationKeys.set(
|
|
1153
|
+
scopedKey(input.userId, input.idempotencyKey),
|
|
1154
|
+
reservation.id
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
credits.reserved = nextReserved;
|
|
1158
|
+
credits.updatedAt = now;
|
|
1159
|
+
return { outcome: "created", reservation: copyRecord(reservation) };
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
async function commitReservationV2(store, userId, reservationId, options) {
|
|
1163
|
+
return store.locks.run(userId, async () => {
|
|
1164
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1165
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1166
|
+
assertTrustworthy(reservation, "commit");
|
|
1167
|
+
if (reservation.status !== "reserved") {
|
|
1168
|
+
return {
|
|
1169
|
+
outcome: "already_terminal",
|
|
1170
|
+
reservation: copyRecord(reservation),
|
|
1171
|
+
terminalStatus: terminalStatusOf2(reservation, "commit")
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
const credits = requireUser(store, userId);
|
|
1175
|
+
const amount = reservation.amount;
|
|
1176
|
+
await yieldPoint(store);
|
|
1177
|
+
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1178
|
+
if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
|
|
1179
|
+
throw new CreditError(
|
|
1180
|
+
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1181
|
+
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1182
|
+
{ userId, reservationId, required: amount }
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
const fromBalance = Math.min(credits.balance, amount);
|
|
1186
|
+
const nextBalance = sumAmounts(credits.balance, -fromBalance);
|
|
1187
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1188
|
+
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1189
|
+
const journal = {
|
|
1190
|
+
userId,
|
|
1191
|
+
operation: "commitReservation",
|
|
1192
|
+
entryType: "debit",
|
|
1193
|
+
amount,
|
|
1194
|
+
balanceAfter,
|
|
1195
|
+
source: "operation_commit",
|
|
1196
|
+
reservationId,
|
|
1197
|
+
description: options?.description ?? `Committed ${amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1198
|
+
// Caller metadata first: the deterministic fields carry the identity of
|
|
1199
|
+
// the event that the collision check compares on.
|
|
1200
|
+
metadata: { ...options?.metadata, operationType: reservation.operationType, amount },
|
|
1201
|
+
idempotencyKey: reservationJournalKey(reservationId, "commit")
|
|
1202
|
+
};
|
|
1203
|
+
const nextReserved = sumAmounts(credits.reserved, -amount);
|
|
1204
|
+
const nextMonthlyUsed = sumAmounts(credits.monthlyUsed, amount);
|
|
1205
|
+
assertRepresentableFields(
|
|
1206
|
+
{
|
|
1207
|
+
balance: nextBalance,
|
|
1208
|
+
bonusCredits: nextBonusCredits,
|
|
1209
|
+
reserved: nextReserved,
|
|
1210
|
+
monthlyUsed: nextMonthlyUsed
|
|
1211
|
+
},
|
|
1212
|
+
{ userId, operation: "commitReservation" }
|
|
1213
|
+
);
|
|
1214
|
+
const existingEntryId = planTransitionJournal(store, journal);
|
|
1215
|
+
reservation.status = "committed";
|
|
1216
|
+
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1217
|
+
credits.balance = nextBalance;
|
|
1218
|
+
credits.bonusCredits = nextBonusCredits;
|
|
1219
|
+
credits.reserved = nextReserved;
|
|
1220
|
+
credits.monthlyUsed = nextMonthlyUsed;
|
|
1221
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1222
|
+
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1223
|
+
return {
|
|
1224
|
+
outcome: "committed",
|
|
1225
|
+
reservation: copyRecord(reservation),
|
|
1226
|
+
amount,
|
|
1227
|
+
balanceAfter,
|
|
1228
|
+
journalEntryId
|
|
1229
|
+
};
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
async function releaseReservationV2(store, userId, reservationId, options) {
|
|
1233
|
+
return store.locks.run(userId, async () => {
|
|
1234
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1235
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1236
|
+
assertTrustworthy(reservation, "release");
|
|
1237
|
+
if (reservation.status !== "reserved") {
|
|
1238
|
+
return {
|
|
1239
|
+
outcome: "already_terminal",
|
|
1240
|
+
reservation: copyRecord(reservation),
|
|
1241
|
+
terminalStatus: terminalStatusOf2(reservation, "release")
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
await yieldPoint(store);
|
|
1245
|
+
const journalEntryId = finishUnspent(store, reservation, {
|
|
1246
|
+
status: "released",
|
|
1247
|
+
source: "operation_release",
|
|
1248
|
+
transition: "release",
|
|
1249
|
+
verb: "Released",
|
|
1250
|
+
options
|
|
1251
|
+
});
|
|
1252
|
+
return {
|
|
1253
|
+
outcome: "released",
|
|
1254
|
+
reservation: copyRecord(reservation),
|
|
1255
|
+
amount: reservation.amount,
|
|
1256
|
+
journalEntryId
|
|
1257
|
+
};
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
async function expireReservationV2(store, userId, reservationId, options) {
|
|
1261
|
+
const asOf = options?.asOf ?? /* @__PURE__ */ new Date();
|
|
1262
|
+
return store.locks.run(userId, async () => {
|
|
1263
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1264
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1265
|
+
assertTrustworthy(reservation, "expire");
|
|
1266
|
+
if (reservation.status !== "reserved") {
|
|
1267
|
+
return {
|
|
1268
|
+
outcome: "already_terminal",
|
|
1269
|
+
reservation: copyRecord(reservation),
|
|
1270
|
+
terminalStatus: terminalStatusOf2(reservation, "expire")
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
if (new Date(reservation.expiresAt).getTime() > asOf.getTime()) {
|
|
1274
|
+
return { outcome: "not_due", reservation: copyRecord(reservation) };
|
|
1275
|
+
}
|
|
1276
|
+
await yieldPoint(store);
|
|
1277
|
+
const journalEntryId = finishUnspent(store, reservation, {
|
|
1278
|
+
status: "expired",
|
|
1279
|
+
source: "reservation_expired",
|
|
1280
|
+
transition: "expire",
|
|
1281
|
+
verb: "Expired",
|
|
1282
|
+
options
|
|
1283
|
+
});
|
|
1284
|
+
return {
|
|
1285
|
+
outcome: "expired",
|
|
1286
|
+
reservation: copyRecord(reservation),
|
|
1287
|
+
amount: reservation.amount,
|
|
1288
|
+
journalEntryId
|
|
1289
|
+
};
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
function finishUnspent(store, reservation, transition) {
|
|
1293
|
+
const credits = requireUser(store, reservation.userId);
|
|
1294
|
+
assertReservedCoversHold(
|
|
1295
|
+
credits,
|
|
1296
|
+
reservation.userId,
|
|
1297
|
+
reservation.id,
|
|
1298
|
+
reservation.amount
|
|
1299
|
+
);
|
|
1300
|
+
const journal = {
|
|
1301
|
+
userId: reservation.userId,
|
|
1302
|
+
operation: `${transition.transition}Reservation`,
|
|
1303
|
+
entryType: "credit",
|
|
1304
|
+
amount: 0,
|
|
1305
|
+
balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1306
|
+
source: transition.source,
|
|
1307
|
+
reservationId: reservation.id,
|
|
1308
|
+
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1309
|
+
metadata: {
|
|
1310
|
+
...transition.options?.metadata,
|
|
1311
|
+
operationType: reservation.operationType,
|
|
1312
|
+
amount: reservation.amount
|
|
1313
|
+
},
|
|
1314
|
+
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1315
|
+
};
|
|
1316
|
+
const nextReserved = sumAmounts(credits.reserved, -reservation.amount);
|
|
1317
|
+
assertRepresentableFields(
|
|
1318
|
+
{ reserved: nextReserved },
|
|
1319
|
+
{ userId: reservation.userId, operation: `${transition.transition}Reservation` }
|
|
1320
|
+
);
|
|
1321
|
+
const existingEntryId = planTransitionJournal(store, journal);
|
|
1322
|
+
reservation.status = transition.status;
|
|
1323
|
+
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1324
|
+
credits.reserved = nextReserved;
|
|
1325
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1326
|
+
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// src/repository/memory/index.ts
|
|
1330
|
+
function isoOf(value) {
|
|
1331
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
1332
|
+
}
|
|
1333
|
+
var InMemoryCreditRepository = class {
|
|
1334
|
+
store = new MemoryStore();
|
|
366
1335
|
// ==================== User Credits ====================
|
|
1336
|
+
/**
|
|
1337
|
+
* A copy, never the live record.
|
|
1338
|
+
*
|
|
1339
|
+
* Handing out the stored object made this adapter behave unlike any real
|
|
1340
|
+
* one: a caller holding a "before" snapshot saw it mutate under them. The
|
|
1341
|
+
* service's monthly reset compared `resetResult.credits.balance` against the
|
|
1342
|
+
* balance it had read moments earlier, got zero because both were the same
|
|
1343
|
+
* object, and silently skipped the reset's journal entry — while the SQL
|
|
1344
|
+
* adapter, which maps each row into a fresh object, wrote it.
|
|
1345
|
+
*/
|
|
367
1346
|
async getUserCredits(userId) {
|
|
368
|
-
|
|
1347
|
+
const credits = this.store.users.get(userId);
|
|
1348
|
+
return copyRecord(credits) ?? null;
|
|
369
1349
|
}
|
|
370
1350
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1351
|
+
return copyRecord(this.seedUser(userId, tier, initialBalance));
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* The synchronous body of `initializeUserCredits`.
|
|
1355
|
+
*
|
|
1356
|
+
* Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
|
|
1357
|
+
* its critical section, and an `await` between the payment-reference check
|
|
1358
|
+
* and the transaction write opens a gap another caller can run through — two
|
|
1359
|
+
* simultaneous first deliveries of the same reference would then both credit.
|
|
1360
|
+
*/
|
|
1361
|
+
seedUser(userId, tier, initialBalance) {
|
|
1362
|
+
assertRepresentableAmount(initialBalance, "initialBalance", { userId });
|
|
1363
|
+
const configured = getConfigMonthlyLimit(tier);
|
|
1364
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
|
|
1365
|
+
const monthlyLimit = storedMonthlyLimit(configured);
|
|
371
1366
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
372
1367
|
const credits = {
|
|
373
1368
|
userId,
|
|
@@ -375,61 +1370,93 @@ var InMemoryCreditRepository = class {
|
|
|
375
1370
|
bonusCredits: 0,
|
|
376
1371
|
reserved: 0,
|
|
377
1372
|
tier,
|
|
378
|
-
monthlyLimit
|
|
1373
|
+
monthlyLimit,
|
|
379
1374
|
monthlyUsed: 0,
|
|
380
1375
|
monthlyResetAt: getNextMonthlyReset().toISOString(),
|
|
381
1376
|
subscriptionExpiresAt: null,
|
|
382
1377
|
createdAt: now,
|
|
383
1378
|
updatedAt: now
|
|
384
1379
|
};
|
|
385
|
-
this.users.set(userId, credits);
|
|
1380
|
+
this.store.users.set(userId, credits);
|
|
386
1381
|
return credits;
|
|
387
1382
|
}
|
|
388
1383
|
async updateUserCredits(userId, updates) {
|
|
389
|
-
|
|
1384
|
+
assertRepresentableFields(
|
|
1385
|
+
{
|
|
1386
|
+
balance: updates.balance,
|
|
1387
|
+
bonusCredits: updates.bonusCredits,
|
|
1388
|
+
reserved: updates.reserved,
|
|
1389
|
+
monthlyLimit: updates.monthlyLimit,
|
|
1390
|
+
monthlyUsed: updates.monthlyUsed,
|
|
1391
|
+
balanceIncrement: updates.balanceIncrement,
|
|
1392
|
+
bonusCreditsIncrement: updates.bonusCreditsIncrement,
|
|
1393
|
+
reservedIncrement: updates.reservedIncrement,
|
|
1394
|
+
monthlyUsedIncrement: updates.monthlyUsedIncrement
|
|
1395
|
+
},
|
|
1396
|
+
{ userId, operation: "updateUserCredits" }
|
|
1397
|
+
);
|
|
1398
|
+
const credits = this.store.users.get(userId);
|
|
390
1399
|
if (!credits) {
|
|
391
1400
|
throw new Error(`User ${userId} not found`);
|
|
392
1401
|
}
|
|
393
|
-
const
|
|
394
|
-
if (updates.balance !== void 0)
|
|
395
|
-
if (updates.bonusCredits !== void 0)
|
|
396
|
-
if (updates.reserved !== void 0)
|
|
397
|
-
if (updates.tier !== void 0)
|
|
398
|
-
if (updates.monthlyLimit !== void 0)
|
|
399
|
-
if (updates.monthlyUsed !== void 0)
|
|
1402
|
+
const next = { ...credits };
|
|
1403
|
+
if (updates.balance !== void 0) next.balance = updates.balance;
|
|
1404
|
+
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1405
|
+
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1406
|
+
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
1407
|
+
if (updates.monthlyLimit !== void 0) next.monthlyLimit = updates.monthlyLimit;
|
|
1408
|
+
if (updates.monthlyUsed !== void 0) next.monthlyUsed = updates.monthlyUsed;
|
|
400
1409
|
if (updates.monthlyResetAt !== void 0) {
|
|
401
|
-
|
|
1410
|
+
next.monthlyResetAt = isoOf(updates.monthlyResetAt);
|
|
402
1411
|
}
|
|
403
1412
|
if (updates.subscriptionExpiresAt !== void 0) {
|
|
404
|
-
|
|
405
|
-
credits.subscriptionExpiresAt = null;
|
|
406
|
-
} else {
|
|
407
|
-
credits.subscriptionExpiresAt = updates.subscriptionExpiresAt instanceof Date ? updates.subscriptionExpiresAt.toISOString() : updates.subscriptionExpiresAt;
|
|
408
|
-
}
|
|
1413
|
+
next.subscriptionExpiresAt = updates.subscriptionExpiresAt === null ? null : isoOf(updates.subscriptionExpiresAt);
|
|
409
1414
|
}
|
|
410
1415
|
if (updates.balanceIncrement !== void 0) {
|
|
411
|
-
|
|
1416
|
+
next.balance = sumAmounts(next.balance, updates.balanceIncrement);
|
|
412
1417
|
}
|
|
413
1418
|
if (updates.bonusCreditsIncrement !== void 0) {
|
|
414
|
-
|
|
1419
|
+
next.bonusCredits = sumAmounts(next.bonusCredits, updates.bonusCreditsIncrement);
|
|
415
1420
|
}
|
|
416
1421
|
if (updates.reservedIncrement !== void 0) {
|
|
417
|
-
|
|
1422
|
+
next.reserved = sumAmounts(next.reserved, updates.reservedIncrement);
|
|
418
1423
|
}
|
|
419
1424
|
if (updates.monthlyUsedIncrement !== void 0) {
|
|
420
|
-
|
|
1425
|
+
next.monthlyUsed = sumAmounts(next.monthlyUsed, updates.monthlyUsedIncrement);
|
|
421
1426
|
}
|
|
422
|
-
|
|
423
|
-
|
|
1427
|
+
assertRepresentableFields(
|
|
1428
|
+
{
|
|
1429
|
+
balance: next.balance,
|
|
1430
|
+
bonusCredits: next.bonusCredits,
|
|
1431
|
+
reserved: next.reserved,
|
|
1432
|
+
monthlyUsed: next.monthlyUsed
|
|
1433
|
+
},
|
|
1434
|
+
{ userId, operation: "updateUserCredits" }
|
|
1435
|
+
);
|
|
1436
|
+
assertRepresentableTierAmount(next.monthlyLimit, "monthlyLimit", {
|
|
1437
|
+
userId,
|
|
1438
|
+
operation: "updateUserCredits"
|
|
1439
|
+
});
|
|
1440
|
+
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1441
|
+
this.store.users.set(userId, next);
|
|
424
1442
|
}
|
|
425
1443
|
async updateUserTier(userId, input) {
|
|
426
|
-
|
|
1444
|
+
assertRepresentableFields(
|
|
1445
|
+
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1446
|
+
{ userId, operation: "updateUserTier" }
|
|
1447
|
+
);
|
|
1448
|
+
const credits = this.store.users.get(userId);
|
|
427
1449
|
if (!credits) {
|
|
428
1450
|
throw new Error(`User ${userId} not found`);
|
|
429
1451
|
}
|
|
430
1452
|
credits.tier = input.tier;
|
|
431
1453
|
credits.monthlyLimit = input.monthlyLimit;
|
|
432
|
-
if (input.balance !== void 0)
|
|
1454
|
+
if (input.balance !== void 0) {
|
|
1455
|
+
credits.balance = Math.max(
|
|
1456
|
+
input.balance,
|
|
1457
|
+
backedBalanceFloor(credits.reserved, credits.bonusCredits)
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
433
1460
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
434
1461
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
435
1462
|
if (input.subscriptionExpiresAt === null) {
|
|
@@ -439,10 +1466,12 @@ var InMemoryCreditRepository = class {
|
|
|
439
1466
|
}
|
|
440
1467
|
}
|
|
441
1468
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
442
|
-
this.users.set(userId, credits);
|
|
1469
|
+
this.store.users.set(userId, credits);
|
|
443
1470
|
}
|
|
444
1471
|
// ==================== Reservations ====================
|
|
445
1472
|
async createReservation(input) {
|
|
1473
|
+
assertValidCreditAmount(input.amount, { userId: input.userId, operation: "createReservation" });
|
|
1474
|
+
assertUnkeyedDirectReservation(input);
|
|
446
1475
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
447
1476
|
const reservation = {
|
|
448
1477
|
id: generateId(),
|
|
@@ -453,19 +1482,19 @@ var InMemoryCreditRepository = class {
|
|
|
453
1482
|
createdAt: now,
|
|
454
1483
|
expiresAt: input.expiresAt.toISOString()
|
|
455
1484
|
};
|
|
456
|
-
if (!this.reservations.has(input.userId)) {
|
|
457
|
-
this.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
1485
|
+
if (!this.store.reservations.has(input.userId)) {
|
|
1486
|
+
this.store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
458
1487
|
}
|
|
459
|
-
this.reservations.get(input.userId).set(reservation.id, reservation);
|
|
460
|
-
return reservation;
|
|
1488
|
+
this.store.reservations.get(input.userId).set(reservation.id, reservation);
|
|
1489
|
+
return copyRecord(reservation);
|
|
461
1490
|
}
|
|
462
1491
|
async getReservation(userId, reservationId) {
|
|
463
|
-
const userReservations = this.reservations.get(userId);
|
|
1492
|
+
const userReservations = this.store.reservations.get(userId);
|
|
464
1493
|
if (!userReservations) return null;
|
|
465
|
-
return userReservations.get(reservationId) ?? null;
|
|
1494
|
+
return copyRecord(userReservations.get(reservationId)) ?? null;
|
|
466
1495
|
}
|
|
467
1496
|
async updateReservationStatus(userId, reservationId, status, completedAt) {
|
|
468
|
-
const userReservations = this.reservations.get(userId);
|
|
1497
|
+
const userReservations = this.store.reservations.get(userId);
|
|
469
1498
|
if (!userReservations) {
|
|
470
1499
|
throw new Error(`No reservations found for user ${userId}`);
|
|
471
1500
|
}
|
|
@@ -473,86 +1502,125 @@ var InMemoryCreditRepository = class {
|
|
|
473
1502
|
if (!reservation) {
|
|
474
1503
|
throw new Error(`Reservation ${reservationId} not found`);
|
|
475
1504
|
}
|
|
1505
|
+
assertDirectStatusWriteAllowed(reservation, status);
|
|
476
1506
|
reservation.status = status;
|
|
477
1507
|
if (completedAt) reservation.completedAt = completedAt.toISOString();
|
|
478
1508
|
userReservations.set(reservationId, reservation);
|
|
479
1509
|
}
|
|
480
1510
|
// ==================== Atomic Operations ====================
|
|
1511
|
+
// ==================== V2 boundary ====================
|
|
1512
|
+
//
|
|
1513
|
+
// As in the Drizzle adapter, V2 is the real implementation and the legacy
|
|
1514
|
+
// `*Atomic` methods are adapters over it, so both APIs share one set of
|
|
1515
|
+
// guarantees.
|
|
1516
|
+
async reserveCreditsV2(input) {
|
|
1517
|
+
return reserveCreditsV2(this.store, input);
|
|
1518
|
+
}
|
|
1519
|
+
async commitReservationV2(userId, reservationId, options) {
|
|
1520
|
+
return commitReservationV2(this.store, userId, reservationId, options);
|
|
1521
|
+
}
|
|
1522
|
+
async releaseReservationV2(userId, reservationId, options) {
|
|
1523
|
+
return releaseReservationV2(this.store, userId, reservationId, options);
|
|
1524
|
+
}
|
|
1525
|
+
async expireReservationV2(userId, reservationId, options) {
|
|
1526
|
+
return expireReservationV2(this.store, userId, reservationId, options);
|
|
1527
|
+
}
|
|
1528
|
+
// ==================== Legacy atomic operations ====================
|
|
481
1529
|
async reserveCreditsAtomic(userId, amount, operationType, expiresAt) {
|
|
482
|
-
const
|
|
483
|
-
if (
|
|
484
|
-
|
|
1530
|
+
const outcome = await this.reserveCreditsV2({ userId, amount, operationType, expiresAt });
|
|
1531
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
1532
|
+
return outcome.reservation;
|
|
485
1533
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
throw new Error(
|
|
489
|
-
`Insufficient credits. Available: ${available}, Required: ${amount}`
|
|
490
|
-
);
|
|
1534
|
+
if (outcome.outcome === "insufficient") {
|
|
1535
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
491
1536
|
}
|
|
492
|
-
|
|
493
|
-
userId,
|
|
494
|
-
amount,
|
|
495
|
-
operationType,
|
|
496
|
-
expiresAt
|
|
497
|
-
});
|
|
498
|
-
credits.reserved += amount;
|
|
499
|
-
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
500
|
-
this.users.set(userId, credits);
|
|
501
|
-
return reservation;
|
|
1537
|
+
throw createReservationAlreadyProcessedError(outcome.existing.id, outcome.existing.status);
|
|
502
1538
|
}
|
|
503
1539
|
async commitReservationAtomic(userId, reservationId) {
|
|
504
|
-
const
|
|
505
|
-
if (
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (!reservation) {
|
|
510
|
-
throw new Error(`Reservation ${reservationId} not found`);
|
|
511
|
-
}
|
|
512
|
-
if (reservation.status === "committed") {
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
if (reservation.status !== "reserved") {
|
|
516
|
-
throw new Error(
|
|
517
|
-
`Cannot commit reservation in ${reservation.status} state`
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
const amount = reservation.amount;
|
|
521
|
-
credits.balance -= amount;
|
|
522
|
-
credits.reserved -= amount;
|
|
523
|
-
credits.monthlyUsed += amount;
|
|
524
|
-
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
525
|
-
this.users.set(userId, credits);
|
|
526
|
-
await this.updateReservationStatus(userId, reservationId, "committed", /* @__PURE__ */ new Date());
|
|
1540
|
+
const outcome = await this.commitReservationV2(userId, reservationId);
|
|
1541
|
+
if (outcome.outcome === "committed") return;
|
|
1542
|
+
if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
|
|
1543
|
+
if (outcome.terminalStatus === "committed") return;
|
|
1544
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
527
1545
|
}
|
|
528
1546
|
async releaseReservationAtomic(userId, reservationId) {
|
|
529
|
-
const
|
|
530
|
-
if (
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1547
|
+
const outcome = await this.releaseReservationV2(userId, reservationId);
|
|
1548
|
+
if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* The stored transaction carrying this reference, in *any* user's ledger.
|
|
1552
|
+
*
|
|
1553
|
+
* Deliberately not scoped to one user. The SQL adapter's unique index covers
|
|
1554
|
+
* `payment_ref` alone, so searching only the crediting user's own history made
|
|
1555
|
+
* the same reference credit two different accounts here while the SQL adapter
|
|
1556
|
+
* treated the second delivery as a duplicate. See `core/payment-ref.ts`.
|
|
1557
|
+
*/
|
|
1558
|
+
findByPaymentRef(paymentRef) {
|
|
1559
|
+
for (const recorded of this.store.transactions.values()) {
|
|
1560
|
+
const hit = recorded.find((transaction) => transaction.paymentRef === paymentRef);
|
|
1561
|
+
if (hit) return hit;
|
|
539
1562
|
}
|
|
540
|
-
|
|
541
|
-
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
542
|
-
this.users.set(userId, credits);
|
|
543
|
-
await this.updateReservationStatus(userId, reservationId, "released", /* @__PURE__ */ new Date());
|
|
1563
|
+
return void 0;
|
|
544
1564
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1565
|
+
/** The journal entry written alongside a stored purchase, if there is one. */
|
|
1566
|
+
journalForTransaction(transaction) {
|
|
1567
|
+
return this.store.journalEntries.get(transaction.userId)?.find((entry) => entry.referenceId === transaction.id);
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Credit an account, resolving `paymentRef` to created / replayed / conflict.
|
|
1571
|
+
*
|
|
1572
|
+
* The reference is checked and the transaction is recorded with no `await`
|
|
1573
|
+
* between them. `createTransaction` is `async` but its body is synchronous, so
|
|
1574
|
+
* under JavaScript's run-to-completion semantics no other caller can observe
|
|
1575
|
+
* the gap — which is what stands in for the SQL adapter's unique index.
|
|
1576
|
+
*
|
|
1577
|
+
* A `replayed` or `conflict` outcome writes nothing at all: the check runs
|
|
1578
|
+
* before the balance moves, not after.
|
|
1579
|
+
*/
|
|
1580
|
+
async addCreditsV2(input) {
|
|
1581
|
+
const { userId, amount, description, options } = input;
|
|
1582
|
+
assertValidCreditAmount(amount, { userId, operation: "addCredits" });
|
|
1583
|
+
const paymentRef = normalizePaymentRef(input.paymentRef);
|
|
1584
|
+
const payload = {
|
|
1585
|
+
userId,
|
|
1586
|
+
amount,
|
|
1587
|
+
type: "purchase",
|
|
1588
|
+
source: options?.source ?? "purchase",
|
|
1589
|
+
referenceType: options?.referenceType ?? "transaction"
|
|
1590
|
+
};
|
|
1591
|
+
if (paymentRef) {
|
|
1592
|
+
const existing = this.findByPaymentRef(paymentRef);
|
|
1593
|
+
if (existing) {
|
|
1594
|
+
const journal = this.journalForTransaction(existing);
|
|
1595
|
+
const mismatch = describePaymentMismatch(
|
|
1596
|
+
{
|
|
1597
|
+
userId: existing.userId,
|
|
1598
|
+
amount: existing.amount,
|
|
1599
|
+
type: existing.type,
|
|
1600
|
+
source: journal?.source,
|
|
1601
|
+
referenceType: journal?.referenceType
|
|
1602
|
+
},
|
|
1603
|
+
payload
|
|
1604
|
+
);
|
|
1605
|
+
return mismatch === null ? { outcome: "replayed", paymentRef, transaction: copyRecord(existing) } : { outcome: "conflict", paymentRef, existing: copyRecord(existing), mismatch };
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
if (!this.store.users.has(userId)) {
|
|
1609
|
+
const tier = getDefaultTier();
|
|
1610
|
+
this.seedUser(userId, tier, storedMonthlyLimit(getConfigMonthlyLimit(tier)));
|
|
549
1611
|
}
|
|
550
|
-
const
|
|
551
|
-
credits.bonusCredits
|
|
1612
|
+
const credits = this.store.users.get(userId);
|
|
1613
|
+
const previousTotal = sumAmounts(credits.balance, credits.bonusCredits);
|
|
1614
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, amount);
|
|
1615
|
+
const newTotal = sumAmounts(credits.balance, nextBonusCredits);
|
|
1616
|
+
assertRepresentableFields(
|
|
1617
|
+
{ previousBalance: previousTotal, bonusCredits: nextBonusCredits, newBalance: newTotal },
|
|
1618
|
+
{ userId, operation: "addCredits" }
|
|
1619
|
+
);
|
|
1620
|
+
credits.bonusCredits = nextBonusCredits;
|
|
552
1621
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
553
|
-
this.users.set(userId, credits);
|
|
554
|
-
const
|
|
555
|
-
const transaction = await this.createTransaction({
|
|
1622
|
+
this.store.users.set(userId, credits);
|
|
1623
|
+
const transaction = this.recordTransaction({
|
|
556
1624
|
userId,
|
|
557
1625
|
type: "purchase",
|
|
558
1626
|
amount,
|
|
@@ -565,43 +1633,88 @@ var InMemoryCreditRepository = class {
|
|
|
565
1633
|
...paymentRef ? { paymentRef } : {},
|
|
566
1634
|
...options?.metadata ?? {}
|
|
567
1635
|
};
|
|
568
|
-
await this.createJournalEntry({
|
|
1636
|
+
const entry = await this.createJournalEntry({
|
|
569
1637
|
userId,
|
|
570
1638
|
entryType: "credit",
|
|
571
1639
|
amount,
|
|
572
1640
|
balanceAfter: newTotal,
|
|
573
|
-
source:
|
|
1641
|
+
source: payload.source,
|
|
574
1642
|
referenceId: transaction.id ?? paymentRef ?? "unknown",
|
|
575
|
-
referenceType:
|
|
1643
|
+
referenceType: payload.referenceType,
|
|
576
1644
|
description,
|
|
577
1645
|
metadata: Object.keys(journalMetadata).length > 0 ? journalMetadata : void 0
|
|
578
1646
|
});
|
|
1647
|
+
return paymentRef ? { outcome: "created", paymentRef, transaction, journalEntryId: entry.id } : { outcome: "created", transaction, journalEntryId: entry.id };
|
|
579
1648
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1649
|
+
/**
|
|
1650
|
+
* The legacy signature, on top of {@link addCreditsV2}.
|
|
1651
|
+
*
|
|
1652
|
+
* `Promise<void>` has nowhere to report a conflict, and silently returning is
|
|
1653
|
+
* indistinguishable from "credited" — the exact failure this round is closing
|
|
1654
|
+
* — so a conflict throws. A genuine replay still returns quietly, because
|
|
1655
|
+
* that *is* the idempotent no-op the caller asked for.
|
|
1656
|
+
*/
|
|
1657
|
+
async addCreditsAtomic(userId, amount, description, paymentRef, options) {
|
|
1658
|
+
const outcome = await this.addCreditsV2({
|
|
1659
|
+
userId,
|
|
1660
|
+
amount,
|
|
1661
|
+
description,
|
|
1662
|
+
paymentRef,
|
|
1663
|
+
options
|
|
1664
|
+
});
|
|
1665
|
+
if (outcome.outcome === "conflict") {
|
|
1666
|
+
throw createPaymentRefConflictError(outcome.paymentRef, {
|
|
1667
|
+
userId,
|
|
1668
|
+
mismatch: outcome.mismatch,
|
|
1669
|
+
existingUserId: outcome.existing.userId
|
|
1670
|
+
});
|
|
583
1671
|
}
|
|
584
|
-
|
|
1672
|
+
}
|
|
1673
|
+
async deductCreditsAtomic(userId, amount) {
|
|
1674
|
+
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1675
|
+
const credits = this.store.users.get(userId);
|
|
585
1676
|
if (!credits) {
|
|
586
1677
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
587
1678
|
}
|
|
588
|
-
const available = credits.balance
|
|
1679
|
+
const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
|
|
589
1680
|
if (available < amount) {
|
|
590
1681
|
throw new Error(
|
|
591
1682
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
592
1683
|
);
|
|
593
1684
|
}
|
|
594
1685
|
const balanceDeduction = Math.min(credits.balance, amount);
|
|
595
|
-
const bonusDeduction = amount -
|
|
596
|
-
const previousBalance = credits.balance
|
|
597
|
-
credits.balance
|
|
598
|
-
credits.bonusCredits
|
|
1686
|
+
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
1687
|
+
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
1688
|
+
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
1689
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, -bonusDeduction);
|
|
1690
|
+
const newBalance = sumAmounts(previousBalance, -amount);
|
|
1691
|
+
assertRepresentableFields(
|
|
1692
|
+
{ previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
|
|
1693
|
+
{ userId, operation: "deductCredits" }
|
|
1694
|
+
);
|
|
1695
|
+
credits.balance = nextBalance;
|
|
1696
|
+
credits.bonusCredits = nextBonusCredits;
|
|
599
1697
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
600
|
-
this.users.set(userId, credits);
|
|
601
|
-
return { previousBalance, newBalance
|
|
1698
|
+
this.store.users.set(userId, credits);
|
|
1699
|
+
return { previousBalance, newBalance };
|
|
602
1700
|
}
|
|
603
1701
|
// ==================== Transactions ====================
|
|
604
1702
|
async createTransaction(input) {
|
|
1703
|
+
assertUnreferencedDirectTransaction(input);
|
|
1704
|
+
return this.recordTransaction({
|
|
1705
|
+
...input,
|
|
1706
|
+
paymentRef: normalizePaymentRef(input.paymentRef)
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
/** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
|
|
1710
|
+
recordTransaction(input) {
|
|
1711
|
+
assertRepresentableAmount(input.amount, "transaction amount", { userId: input.userId });
|
|
1712
|
+
assertRepresentableAmount(input.previousBalance, "transaction previousBalance", {
|
|
1713
|
+
userId: input.userId
|
|
1714
|
+
});
|
|
1715
|
+
assertRepresentableAmount(input.newBalance, "transaction newBalance", {
|
|
1716
|
+
userId: input.userId
|
|
1717
|
+
});
|
|
605
1718
|
const transaction = {
|
|
606
1719
|
id: generateId(),
|
|
607
1720
|
userId: input.userId,
|
|
@@ -613,23 +1726,24 @@ var InMemoryCreditRepository = class {
|
|
|
613
1726
|
newBalance: input.newBalance,
|
|
614
1727
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
615
1728
|
};
|
|
616
|
-
if (!this.transactions.has(input.userId)) {
|
|
617
|
-
this.transactions.set(input.userId, []);
|
|
1729
|
+
if (!this.store.transactions.has(input.userId)) {
|
|
1730
|
+
this.store.transactions.set(input.userId, []);
|
|
618
1731
|
}
|
|
619
|
-
this.transactions.get(input.userId).push(transaction);
|
|
620
|
-
return transaction;
|
|
1732
|
+
this.store.transactions.get(input.userId).push(transaction);
|
|
1733
|
+
return copyRecord(transaction);
|
|
621
1734
|
}
|
|
622
1735
|
async getTransactions(userId, limit = 50, offset = 0) {
|
|
623
|
-
const userTransactions = this.transactions.get(userId) ?? [];
|
|
1736
|
+
const userTransactions = this.store.transactions.get(userId) ?? [];
|
|
624
1737
|
const sorted = [...userTransactions].sort((a, b) => {
|
|
625
1738
|
const aDate = toDate(a.createdAt).getTime();
|
|
626
1739
|
const bDate = toDate(b.createdAt).getTime();
|
|
627
1740
|
return bDate - aDate;
|
|
628
1741
|
});
|
|
629
|
-
return sorted.slice(offset, offset + limit);
|
|
1742
|
+
return copyRecords(sorted.slice(offset, offset + limit));
|
|
630
1743
|
}
|
|
631
1744
|
// ==================== Usage Logs ====================
|
|
632
1745
|
async logUsage(input) {
|
|
1746
|
+
assertRepresentableAmount(input.creditsUsed, "creditsUsed", { userId: input.userId });
|
|
633
1747
|
const log = {
|
|
634
1748
|
id: generateId(),
|
|
635
1749
|
userId: input.userId,
|
|
@@ -641,14 +1755,14 @@ var InMemoryCreditRepository = class {
|
|
|
641
1755
|
resourceId: input.resourceId,
|
|
642
1756
|
resourceType: input.resourceType,
|
|
643
1757
|
requestId: input.requestId,
|
|
644
|
-
metadata: input.metadata,
|
|
1758
|
+
metadata: input.metadata ? { ...input.metadata } : input.metadata,
|
|
645
1759
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
646
1760
|
};
|
|
647
|
-
this.usageLogs.push(log);
|
|
648
|
-
return log;
|
|
1761
|
+
this.store.usageLogs.push(log);
|
|
1762
|
+
return copyRecord(log);
|
|
649
1763
|
}
|
|
650
1764
|
async getUsageLogs(query) {
|
|
651
|
-
let results = [...this.usageLogs];
|
|
1765
|
+
let results = [...this.store.usageLogs];
|
|
652
1766
|
if (query.userId) {
|
|
653
1767
|
results = results.filter((log) => log.userId === query.userId);
|
|
654
1768
|
}
|
|
@@ -675,7 +1789,7 @@ var InMemoryCreditRepository = class {
|
|
|
675
1789
|
});
|
|
676
1790
|
const offset = query.offset ?? 0;
|
|
677
1791
|
const limit = query.limit ?? 50;
|
|
678
|
-
return results.slice(offset, offset + limit);
|
|
1792
|
+
return copyRecords(results.slice(offset, offset + limit));
|
|
679
1793
|
}
|
|
680
1794
|
async getUsageLogsCount(query) {
|
|
681
1795
|
const results = await this.getUsageLogs({ ...query, limit: Infinity, offset: 0 });
|
|
@@ -683,6 +1797,29 @@ var InMemoryCreditRepository = class {
|
|
|
683
1797
|
}
|
|
684
1798
|
// ==================== Journal Entries ====================
|
|
685
1799
|
async createJournalEntry(input) {
|
|
1800
|
+
return this.recordJournalEntry(input);
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
|
|
1804
|
+
* write its journal line inside the same run-to-completion step as the
|
|
1805
|
+
* balance write — an `await` between the two is exactly the gap that loses
|
|
1806
|
+
* the line when the second half fails.
|
|
1807
|
+
*/
|
|
1808
|
+
recordJournalEntry(input) {
|
|
1809
|
+
assertRepresentableAmount(input.amount, "journal amount", { userId: input.userId });
|
|
1810
|
+
assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
|
|
1811
|
+
userId: input.userId
|
|
1812
|
+
});
|
|
1813
|
+
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
1814
|
+
assertPublicJournalKey(input.idempotencyKey, input.userId);
|
|
1815
|
+
const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
|
|
1816
|
+
if (key !== void 0 && this.store.journalKeys.has(key)) {
|
|
1817
|
+
throw new CreditError(
|
|
1818
|
+
`Journal idempotency key ${input.idempotencyKey} is already used for user ${input.userId}. The unique constraint refuses a second entry.`,
|
|
1819
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
1820
|
+
{ userId: input.userId, idempotencyKey: input.idempotencyKey }
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
686
1823
|
const entry = {
|
|
687
1824
|
id: generateId(),
|
|
688
1825
|
userId: input.userId,
|
|
@@ -693,17 +1830,19 @@ var InMemoryCreditRepository = class {
|
|
|
693
1830
|
referenceId: input.referenceId,
|
|
694
1831
|
referenceType: input.referenceType,
|
|
695
1832
|
description: input.description,
|
|
696
|
-
metadata: input.metadata,
|
|
1833
|
+
metadata: input.metadata ? { ...input.metadata } : input.metadata,
|
|
1834
|
+
idempotencyKey: input.idempotencyKey,
|
|
697
1835
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
698
1836
|
};
|
|
699
|
-
if (!this.journalEntries.has(input.userId)) {
|
|
700
|
-
this.journalEntries.set(input.userId, []);
|
|
1837
|
+
if (!this.store.journalEntries.has(input.userId)) {
|
|
1838
|
+
this.store.journalEntries.set(input.userId, []);
|
|
701
1839
|
}
|
|
702
|
-
this.journalEntries.get(input.userId).push(entry);
|
|
703
|
-
|
|
1840
|
+
this.store.journalEntries.get(input.userId).push(entry);
|
|
1841
|
+
if (key !== void 0) this.store.journalKeys.set(key, entry.id);
|
|
1842
|
+
return copyRecord(entry);
|
|
704
1843
|
}
|
|
705
1844
|
async getJournalEntries(query) {
|
|
706
|
-
let results = this.journalEntries.get(query.userId) ?? [];
|
|
1845
|
+
let results = this.store.journalEntries.get(query.userId) ?? [];
|
|
707
1846
|
if (query.source) {
|
|
708
1847
|
results = results.filter((entry) => entry.source === query.source);
|
|
709
1848
|
}
|
|
@@ -727,7 +1866,7 @@ var InMemoryCreditRepository = class {
|
|
|
727
1866
|
});
|
|
728
1867
|
const offset = query.offset ?? 0;
|
|
729
1868
|
const limit = query.limit ?? 50;
|
|
730
|
-
return results.slice(offset, offset + limit);
|
|
1869
|
+
return copyRecords(results.slice(offset, offset + limit));
|
|
731
1870
|
}
|
|
732
1871
|
async getJournalEntriesCount(query) {
|
|
733
1872
|
const results = await this.getJournalEntries({
|
|
@@ -738,53 +1877,91 @@ var InMemoryCreditRepository = class {
|
|
|
738
1877
|
return results.length;
|
|
739
1878
|
}
|
|
740
1879
|
// ==================== Cleanup Operations ====================
|
|
1880
|
+
/**
|
|
1881
|
+
* Expire every hold whose deadline has passed.
|
|
1882
|
+
*
|
|
1883
|
+
* Each candidate goes through the guarded {@link expireReservationV2}, which
|
|
1884
|
+
* re-checks the status and the deadline under the user's lock. The previous
|
|
1885
|
+
* implementation released the hold and then overwrote the status to
|
|
1886
|
+
* `expired`, so a commit landing in between got its credits handed back and
|
|
1887
|
+
* was then relabelled.
|
|
1888
|
+
*/
|
|
741
1889
|
async findAndExpireReservations(_batchSize = 100, _maxIterations = 100) {
|
|
742
|
-
const
|
|
1890
|
+
const asOf = /* @__PURE__ */ new Date();
|
|
743
1891
|
let expiredCount = 0;
|
|
744
1892
|
let creditsReleased = 0;
|
|
745
1893
|
const errors = [];
|
|
746
|
-
|
|
1894
|
+
const candidates = [];
|
|
1895
|
+
for (const [userId, userReservations] of this.store.reservations) {
|
|
747
1896
|
for (const [reservationId, reservation] of userReservations) {
|
|
748
|
-
if (reservation.status === "reserved" && toDate(reservation.expiresAt).getTime() <
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1897
|
+
if (reservation.status === "reserved" && toDate(reservation.expiresAt).getTime() < asOf.getTime()) {
|
|
1898
|
+
candidates.push({ userId, reservationId });
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
for (const candidate of candidates) {
|
|
1903
|
+
try {
|
|
1904
|
+
const outcome = await this.expireReservationV2(
|
|
1905
|
+
candidate.userId,
|
|
1906
|
+
candidate.reservationId,
|
|
1907
|
+
{ asOf }
|
|
1908
|
+
);
|
|
1909
|
+
if (outcome.outcome === "expired") {
|
|
1910
|
+
expiredCount++;
|
|
1911
|
+
creditsReleased = sumAmounts(creditsReleased, outcome.amount);
|
|
760
1912
|
}
|
|
1913
|
+
} catch (error) {
|
|
1914
|
+
errors.push(
|
|
1915
|
+
`Failed to expire reservation ${candidate.reservationId}: ${error}`
|
|
1916
|
+
);
|
|
761
1917
|
}
|
|
762
1918
|
}
|
|
763
1919
|
return { expiredCount, creditsReleased, errors };
|
|
764
1920
|
}
|
|
765
1921
|
// ==================== Atomic Monthly Reset ====================
|
|
766
1922
|
async atomicMonthlyReset(userId, tier, expectedResetAt) {
|
|
767
|
-
const credits = this.users.get(userId);
|
|
1923
|
+
const credits = this.store.users.get(userId);
|
|
768
1924
|
if (!credits) {
|
|
769
1925
|
throw new Error(`User ${userId} not found`);
|
|
770
1926
|
}
|
|
771
1927
|
const currentResetAt = toDate(credits.monthlyResetAt).getTime();
|
|
772
1928
|
const expected = toDate(expectedResetAt).getTime();
|
|
773
1929
|
if (currentResetAt !== expected) {
|
|
774
|
-
return { wasReset: false, credits };
|
|
1930
|
+
return { wasReset: false, credits: { ...credits } };
|
|
775
1931
|
}
|
|
776
|
-
|
|
1932
|
+
assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
|
|
777
1933
|
const nextReset = getNextMonthlyReset();
|
|
778
|
-
|
|
1934
|
+
const target = monthlyResetBalance(tier);
|
|
1935
|
+
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
1936
|
+
const previousBalance = credits.balance;
|
|
1937
|
+
const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
|
|
1938
|
+
const change = sumAmounts(newBalance, -previousBalance);
|
|
1939
|
+
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
1940
|
+
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
1941
|
+
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
1942
|
+
credits.balance = newBalance;
|
|
779
1943
|
credits.monthlyUsed = 0;
|
|
780
1944
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
781
1945
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
782
|
-
this.users.set(userId, credits);
|
|
783
|
-
|
|
1946
|
+
this.store.users.set(userId, credits);
|
|
1947
|
+
if (change !== 0) {
|
|
1948
|
+
this.recordJournalEntry({
|
|
1949
|
+
userId,
|
|
1950
|
+
entryType: change > 0 ? "credit" : "debit",
|
|
1951
|
+
amount: Math.abs(change),
|
|
1952
|
+
balanceAfter,
|
|
1953
|
+
source: "monthly_reset",
|
|
1954
|
+
referenceId: `reset-${Date.now()}`,
|
|
1955
|
+
referenceType: "reset",
|
|
1956
|
+
description: `Monthly credit reset for ${tier} tier.`,
|
|
1957
|
+
metadata: { tier, previousBalance, newBalance }
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
return { wasReset: true, credits: { ...credits }, journaled: true };
|
|
784
1961
|
}
|
|
785
1962
|
// ==================== Subscription Expiry ====================
|
|
786
1963
|
async checkAndHandleSubscriptionExpiry(userId, gracePeriodDays = 3) {
|
|
787
|
-
const credits = this.users.get(userId);
|
|
1964
|
+
const credits = this.store.users.get(userId);
|
|
788
1965
|
if (!credits) {
|
|
789
1966
|
throw new Error(`User ${userId} not found`);
|
|
790
1967
|
}
|
|
@@ -793,7 +1970,7 @@ var InMemoryCreditRepository = class {
|
|
|
793
1970
|
wasDowngraded: false,
|
|
794
1971
|
inGracePeriod: false,
|
|
795
1972
|
graceDaysRemaining: 0,
|
|
796
|
-
credits
|
|
1973
|
+
credits: { ...credits }
|
|
797
1974
|
};
|
|
798
1975
|
}
|
|
799
1976
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -804,7 +1981,7 @@ var InMemoryCreditRepository = class {
|
|
|
804
1981
|
wasDowngraded: false,
|
|
805
1982
|
inGracePeriod: false,
|
|
806
1983
|
graceDaysRemaining: 0,
|
|
807
|
-
credits
|
|
1984
|
+
credits: { ...credits }
|
|
808
1985
|
};
|
|
809
1986
|
}
|
|
810
1987
|
if (daysSinceExpiry <= gracePeriodDays) {
|
|
@@ -812,22 +1989,48 @@ var InMemoryCreditRepository = class {
|
|
|
812
1989
|
wasDowngraded: false,
|
|
813
1990
|
inGracePeriod: true,
|
|
814
1991
|
graceDaysRemaining: Math.ceil(gracePeriodDays - daysSinceExpiry),
|
|
815
|
-
credits
|
|
1992
|
+
credits: { ...credits }
|
|
816
1993
|
};
|
|
817
1994
|
}
|
|
818
1995
|
const defaultTier = getDefaultTier();
|
|
819
|
-
const
|
|
1996
|
+
const configured = getConfigMonthlyLimit(defaultTier);
|
|
1997
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", {
|
|
1998
|
+
userId,
|
|
1999
|
+
tier: defaultTier
|
|
2000
|
+
});
|
|
2001
|
+
const limit = storedMonthlyLimit(configured);
|
|
2002
|
+
const previousTier = credits.tier;
|
|
2003
|
+
const previousBalance = credits.balance;
|
|
2004
|
+
const newBalance = Math.max(
|
|
2005
|
+
Math.min(credits.balance, limit),
|
|
2006
|
+
backedBalanceFloor(credits.reserved, credits.bonusCredits)
|
|
2007
|
+
);
|
|
2008
|
+
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2009
|
+
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
820
2010
|
credits.tier = defaultTier;
|
|
821
|
-
credits.monthlyLimit =
|
|
822
|
-
credits.balance =
|
|
2011
|
+
credits.monthlyLimit = limit;
|
|
2012
|
+
credits.balance = newBalance;
|
|
823
2013
|
credits.subscriptionExpiresAt = null;
|
|
824
2014
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
825
|
-
this.users.set(userId, credits);
|
|
2015
|
+
this.store.users.set(userId, credits);
|
|
2016
|
+
this.recordJournalEntry({
|
|
2017
|
+
userId,
|
|
2018
|
+
entryType: "debit",
|
|
2019
|
+
amount: 0,
|
|
2020
|
+
// No credits deducted, just tier change
|
|
2021
|
+
balanceAfter,
|
|
2022
|
+
source: "subscription_downgrade",
|
|
2023
|
+
referenceId: `downgrade-${Date.now()}`,
|
|
2024
|
+
referenceType: "subscription",
|
|
2025
|
+
description: `Subscription expired. Downgraded from ${previousTier} to ${defaultTier} tier.`,
|
|
2026
|
+
metadata: { previousTier, previousBalance, newBalance }
|
|
2027
|
+
});
|
|
826
2028
|
return {
|
|
827
2029
|
wasDowngraded: true,
|
|
828
2030
|
inGracePeriod: false,
|
|
829
2031
|
graceDaysRemaining: 0,
|
|
830
|
-
credits
|
|
2032
|
+
credits: { ...credits },
|
|
2033
|
+
journaled: true
|
|
831
2034
|
};
|
|
832
2035
|
}
|
|
833
2036
|
// ==================== Testing Utilities ====================
|
|
@@ -835,25 +2038,33 @@ var InMemoryCreditRepository = class {
|
|
|
835
2038
|
* Clear all data (useful for testing)
|
|
836
2039
|
*/
|
|
837
2040
|
clear() {
|
|
838
|
-
this.
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
2041
|
+
this.store.clear();
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Install a yield point inside every V2 critical section (testing only).
|
|
2045
|
+
*
|
|
2046
|
+
* Unset, the critical sections run start-to-finish synchronously, so
|
|
2047
|
+
* concurrent callers never interleave and a concurrency test would pass even
|
|
2048
|
+
* with the locking removed. Setting this to a real yield (a macrotask or a
|
|
2049
|
+
* barrier) makes callers genuinely overlap, so the tests exercise the lock
|
|
2050
|
+
* instead of the event loop. Never call this in production code.
|
|
2051
|
+
*/
|
|
2052
|
+
setSchedulingHook(hook) {
|
|
2053
|
+
this.store.schedulingHook = hook;
|
|
843
2054
|
}
|
|
844
2055
|
/**
|
|
845
2056
|
* Get all users (useful for testing/debugging)
|
|
846
2057
|
*/
|
|
847
2058
|
getAllUsers() {
|
|
848
|
-
return Array.from(this.users.values());
|
|
2059
|
+
return copyRecords(Array.from(this.store.users.values()));
|
|
849
2060
|
}
|
|
850
2061
|
/**
|
|
851
2062
|
* Get all reservations for a user (useful for testing)
|
|
852
2063
|
*/
|
|
853
2064
|
getAllReservations(userId) {
|
|
854
|
-
const userReservations = this.reservations.get(userId);
|
|
2065
|
+
const userReservations = this.store.reservations.get(userId);
|
|
855
2066
|
if (!userReservations) return [];
|
|
856
|
-
return Array.from(userReservations.values());
|
|
2067
|
+
return copyRecords(Array.from(userReservations.values()));
|
|
857
2068
|
}
|
|
858
2069
|
};
|
|
859
2070
|
function createInMemoryCreditRepository() {
|
|
@@ -862,9 +2073,16 @@ function createInMemoryCreditRepository() {
|
|
|
862
2073
|
// Annotate the CommonJS export names for ESM import in node:
|
|
863
2074
|
0 && (module.exports = {
|
|
864
2075
|
InMemoryCreditRepository,
|
|
2076
|
+
addCreditsThroughRepository,
|
|
2077
|
+
commitThroughRepository,
|
|
865
2078
|
createInMemoryCreditRepository,
|
|
866
2079
|
generateId,
|
|
867
2080
|
getNextMonthlyReset,
|
|
2081
|
+
releaseThroughRepository,
|
|
2082
|
+
reservationJournalKey,
|
|
2083
|
+
reserveThroughRepository,
|
|
2084
|
+
supportsCreditsV2,
|
|
2085
|
+
supportsIdempotentCredit,
|
|
868
2086
|
toClientUserCredits,
|
|
869
2087
|
toDate
|
|
870
2088
|
});
|