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