@nehorai/credits 1.6.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/LICENSE +21 -0
- 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 +1779 -364
- 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 +1723 -364
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +1413 -181
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +142 -16
- package/dist/repository/index.d.ts +142 -16
- package/dist/repository/index.js +1406 -181
- 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 +483 -105
- package/dist/service/index.cjs.map +1 -1
- package/dist/service/index.d.cts +70 -10
- package/dist/service/index.d.ts +70 -10
- package/dist/service/index.js +483 -105
- 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 +9 -10
- package/dist/types-CZNv1U3e.d.cts +0 -305
- package/dist/types-G4WK0rM-.d.ts +0 -305
package/dist/index.js
CHANGED
|
@@ -1,35 +1,3 @@
|
|
|
1
|
-
// src/core/types.ts
|
|
2
|
-
function calculateAvailableCredits(balance, bonusCredits, reserved) {
|
|
3
|
-
return balance + bonusCredits - reserved;
|
|
4
|
-
}
|
|
5
|
-
function toPortableTimestamp(value) {
|
|
6
|
-
if (!value) {
|
|
7
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
8
|
-
}
|
|
9
|
-
if (typeof value === "string") {
|
|
10
|
-
return value;
|
|
11
|
-
}
|
|
12
|
-
if (value instanceof Date) {
|
|
13
|
-
return value.toISOString();
|
|
14
|
-
}
|
|
15
|
-
if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
|
|
16
|
-
return value.toDate().toISOString();
|
|
17
|
-
}
|
|
18
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
19
|
-
}
|
|
20
|
-
function toDate(value) {
|
|
21
|
-
if (value instanceof Date) {
|
|
22
|
-
return value;
|
|
23
|
-
}
|
|
24
|
-
if (typeof value === "string") {
|
|
25
|
-
return new Date(value);
|
|
26
|
-
}
|
|
27
|
-
if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
|
|
28
|
-
return value.toDate();
|
|
29
|
-
}
|
|
30
|
-
return /* @__PURE__ */ new Date();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
1
|
// src/core/errors.ts
|
|
34
2
|
var CreditErrorCode = {
|
|
35
3
|
INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
|
|
@@ -39,7 +7,32 @@ var CreditErrorCode = {
|
|
|
39
7
|
USER_NOT_FOUND: "USER_NOT_FOUND",
|
|
40
8
|
INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
|
|
41
9
|
CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
|
|
42
|
-
DATABASE_ERROR: "DATABASE_ERROR"
|
|
10
|
+
DATABASE_ERROR: "DATABASE_ERROR",
|
|
11
|
+
/** Caller reused an idempotency key with a different immutable payload. */
|
|
12
|
+
IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT",
|
|
13
|
+
/**
|
|
14
|
+
* Retryable infrastructure failure — serialization/deadlock, lock timeout,
|
|
15
|
+
* connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
|
|
16
|
+
* a deterministic failure that will fail again identically on retry.
|
|
17
|
+
*/
|
|
18
|
+
TRANSIENT_ERROR: "TRANSIENT_ERROR",
|
|
19
|
+
/** The configured repository adapter does not implement the requested V2 method. */
|
|
20
|
+
UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
|
|
21
|
+
/** Amount was not a finite positive number. */
|
|
22
|
+
INVALID_AMOUNT: "INVALID_AMOUNT",
|
|
23
|
+
/** Idempotency key was supplied but empty or whitespace-only. */
|
|
24
|
+
INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY",
|
|
25
|
+
/**
|
|
26
|
+
* A persisted reservation holds a status this library does not define, so no
|
|
27
|
+
* transition can reason about it. Quarantined rather than cast; nothing moved.
|
|
28
|
+
*/
|
|
29
|
+
CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS",
|
|
30
|
+
/**
|
|
31
|
+
* A persisted reservation does not record that its hold was placed
|
|
32
|
+
* atomically, so the credits it claims are not known to be reserved. Moving
|
|
33
|
+
* it would spend another hold's coverage; nothing moved.
|
|
34
|
+
*/
|
|
35
|
+
UNBACKED_RESERVATION: "UNBACKED_RESERVATION"
|
|
43
36
|
};
|
|
44
37
|
var CreditError = class _CreditError extends Error {
|
|
45
38
|
code;
|
|
@@ -81,7 +74,7 @@ function createInsufficientCreditsError(required, available) {
|
|
|
81
74
|
return new CreditError(
|
|
82
75
|
`Insufficient credits: available ${available}, required ${required}`,
|
|
83
76
|
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
84
|
-
{ required, available, shortfall: required -
|
|
77
|
+
{ required, available, shortfall: sumAmounts(required, -available) }
|
|
85
78
|
);
|
|
86
79
|
}
|
|
87
80
|
function createReservationNotFoundError(reservationId) {
|
|
@@ -119,6 +112,435 @@ function createInvalidOperationTypeError(operationType, validTypes) {
|
|
|
119
112
|
{ operationType, validTypes }
|
|
120
113
|
);
|
|
121
114
|
}
|
|
115
|
+
function createIdempotencyConflictError(idempotencyKey, details) {
|
|
116
|
+
return new CreditError(
|
|
117
|
+
`Idempotency key ${idempotencyKey} was reused with a different payload`,
|
|
118
|
+
CreditErrorCode.IDEMPOTENCY_CONFLICT,
|
|
119
|
+
{ idempotencyKey, ...details }
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
function createTransientError(message, details) {
|
|
123
|
+
return new CreditError(message, CreditErrorCode.TRANSIENT_ERROR, details);
|
|
124
|
+
}
|
|
125
|
+
function createUnsupportedOperationError(operation, hint) {
|
|
126
|
+
return new CreditError(
|
|
127
|
+
hint ? `Repository does not support ${operation}. ${hint}` : `Repository does not support ${operation}`,
|
|
128
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
129
|
+
{ operation, ...hint ? { hint } : {} }
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
function createInvalidAmountError(amount) {
|
|
133
|
+
return new CreditError(
|
|
134
|
+
`Credit amount must be a finite positive number (got ${String(amount)})`,
|
|
135
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
136
|
+
{ amount }
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
function isIdempotencyConflictError(error) {
|
|
140
|
+
return isCreditError(error) && error.code === CreditErrorCode.IDEMPOTENCY_CONFLICT;
|
|
141
|
+
}
|
|
142
|
+
function isTransientError(error) {
|
|
143
|
+
return isCreditError(error) && error.code === CreditErrorCode.TRANSIENT_ERROR;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/core/amount.ts
|
|
147
|
+
var CREDIT_AMOUNT_SCALE = 2;
|
|
148
|
+
var CREDIT_AMOUNT_MAX = 999999999999e-2;
|
|
149
|
+
var MAX_CENTS = 999999999999;
|
|
150
|
+
function toCents(value) {
|
|
151
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
152
|
+
const cents = Math.round(value * 100);
|
|
153
|
+
if (!Number.isSafeInteger(cents)) return null;
|
|
154
|
+
return value === cents / 100 ? cents : null;
|
|
155
|
+
}
|
|
156
|
+
function sumAmounts(...values) {
|
|
157
|
+
let cents = 0;
|
|
158
|
+
for (const value of values) {
|
|
159
|
+
const valueCents = toCents(value);
|
|
160
|
+
if (valueCents === null) return floatSum(values);
|
|
161
|
+
cents += valueCents;
|
|
162
|
+
}
|
|
163
|
+
return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);
|
|
164
|
+
}
|
|
165
|
+
function floatSum(values) {
|
|
166
|
+
let total = 0;
|
|
167
|
+
for (const value of values) total += value;
|
|
168
|
+
return total;
|
|
169
|
+
}
|
|
170
|
+
function isValidCreditAmount(value) {
|
|
171
|
+
if (typeof value !== "number") return false;
|
|
172
|
+
const cents = toCents(value);
|
|
173
|
+
return cents !== null && cents > 0 && cents <= MAX_CENTS;
|
|
174
|
+
}
|
|
175
|
+
function assertValidCreditAmount(value, context) {
|
|
176
|
+
if (isValidCreditAmount(value)) return;
|
|
177
|
+
throw new CreditError(
|
|
178
|
+
creditAmountReason(value),
|
|
179
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
180
|
+
{ amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
function creditAmountReason(value) {
|
|
184
|
+
if (typeof value !== "number") {
|
|
185
|
+
return `Credit amount must be a number (got ${typeof value})`;
|
|
186
|
+
}
|
|
187
|
+
if (!Number.isFinite(value)) {
|
|
188
|
+
return `Credit amount must be finite (got ${String(value)})`;
|
|
189
|
+
}
|
|
190
|
+
if (value <= 0) {
|
|
191
|
+
return `Credit amount must be greater than zero (got ${value})`;
|
|
192
|
+
}
|
|
193
|
+
if (value > CREDIT_AMOUNT_MAX) {
|
|
194
|
+
return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;
|
|
195
|
+
}
|
|
196
|
+
return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;
|
|
197
|
+
}
|
|
198
|
+
function isRepresentableAmount(value) {
|
|
199
|
+
if (typeof value !== "number") return false;
|
|
200
|
+
const cents = toCents(value);
|
|
201
|
+
return cents !== null && Math.abs(cents) <= MAX_CENTS;
|
|
202
|
+
}
|
|
203
|
+
function assertRepresentableAmount(value, field, context) {
|
|
204
|
+
if (isRepresentableAmount(value)) return;
|
|
205
|
+
throw new CreditError(
|
|
206
|
+
`${field} must be a finite number exact to ${CREDIT_AMOUNT_SCALE} decimal places within \xB1${CREDIT_AMOUNT_MAX} (got ${String(value)})`,
|
|
207
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
208
|
+
{ field, value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
function assertRepresentableFields(fields, context) {
|
|
212
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
213
|
+
if (value === void 0) continue;
|
|
214
|
+
assertRepresentableAmount(value, field, context);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function backedBalanceFloor(reserved, bonusCredits) {
|
|
218
|
+
const floor = Math.max(0, sumAmounts(reserved, -bonusCredits));
|
|
219
|
+
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
220
|
+
return floor;
|
|
221
|
+
}
|
|
222
|
+
function assertRepresentableTierAmount(value, field, context) {
|
|
223
|
+
if (value === Number.POSITIVE_INFINITY) return;
|
|
224
|
+
assertRepresentableAmount(value, field, context);
|
|
225
|
+
}
|
|
226
|
+
function storedMonthlyLimit(limit) {
|
|
227
|
+
return Number.isFinite(limit) ? limit : CREDIT_AMOUNT_MAX;
|
|
228
|
+
}
|
|
229
|
+
function assertValidStoredAmountRaw(raw, context) {
|
|
230
|
+
const cents = numericToCents(raw);
|
|
231
|
+
if (cents !== null && cents > 0n && cents <= BigInt(MAX_CENTS)) {
|
|
232
|
+
return Number(cents) / 100;
|
|
233
|
+
}
|
|
234
|
+
throw new CreditError(
|
|
235
|
+
`Stored credit amount ${String(raw)} 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.`,
|
|
236
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
237
|
+
{
|
|
238
|
+
amount: typeof raw === "string" ? raw : String(raw),
|
|
239
|
+
reason: "corrupt_stored_amount",
|
|
240
|
+
max: CREDIT_AMOUNT_MAX,
|
|
241
|
+
scale: CREDIT_AMOUNT_SCALE,
|
|
242
|
+
...context
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
function assertValidStoredAmount(value, context) {
|
|
247
|
+
if (isValidCreditAmount(value)) return;
|
|
248
|
+
throw new CreditError(
|
|
249
|
+
`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.`,
|
|
250
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
251
|
+
{ amount: value, reason: "corrupt_stored_amount", max: CREDIT_AMOUNT_MAX, ...context }
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
function numericToCents(value) {
|
|
255
|
+
if (typeof value === "number") {
|
|
256
|
+
const cents2 = toCents(value);
|
|
257
|
+
return cents2 === null ? null : BigInt(cents2);
|
|
258
|
+
}
|
|
259
|
+
if (typeof value !== "string") return null;
|
|
260
|
+
const match = /^([+-]?)(\d+)(?:\.(\d*))?$/.exec(value.trim());
|
|
261
|
+
if (!match) return null;
|
|
262
|
+
const [, sign, whole, fraction = ""] = match;
|
|
263
|
+
if (/[1-9]/.test(fraction.slice(CREDIT_AMOUNT_SCALE))) return null;
|
|
264
|
+
const cents = BigInt(whole) * 100n + BigInt(fraction.slice(0, CREDIT_AMOUNT_SCALE).padEnd(CREDIT_AMOUNT_SCALE, "0"));
|
|
265
|
+
return sign === "-" ? -cents : cents;
|
|
266
|
+
}
|
|
267
|
+
function sameAmount(a, b) {
|
|
268
|
+
const left = numericToCents(a);
|
|
269
|
+
const right = numericToCents(b);
|
|
270
|
+
return left !== null && right !== null && left === right;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/core/types.ts
|
|
274
|
+
function calculateAvailableCredits(balance, bonusCredits, reserved) {
|
|
275
|
+
return sumAmounts(balance, bonusCredits, -reserved);
|
|
276
|
+
}
|
|
277
|
+
function toPortableTimestamp(value) {
|
|
278
|
+
if (!value) {
|
|
279
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
280
|
+
}
|
|
281
|
+
if (typeof value === "string") {
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
if (value instanceof Date) {
|
|
285
|
+
return value.toISOString();
|
|
286
|
+
}
|
|
287
|
+
if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
|
|
288
|
+
return value.toDate().toISOString();
|
|
289
|
+
}
|
|
290
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
291
|
+
}
|
|
292
|
+
function toDate(value) {
|
|
293
|
+
if (value instanceof Date) {
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
if (typeof value === "string") {
|
|
297
|
+
return new Date(value);
|
|
298
|
+
}
|
|
299
|
+
if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
|
|
300
|
+
return value.toDate();
|
|
301
|
+
}
|
|
302
|
+
return /* @__PURE__ */ new Date();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/core/outcomes.ts
|
|
306
|
+
function isCreditedOutcome(outcome) {
|
|
307
|
+
return outcome.outcome === "created";
|
|
308
|
+
}
|
|
309
|
+
function isWinningOutcome(outcome) {
|
|
310
|
+
return outcome.outcome === "committed" || outcome.outcome === "released" || outcome.outcome === "expired";
|
|
311
|
+
}
|
|
312
|
+
function isReservedOutcome(outcome) {
|
|
313
|
+
return outcome.outcome === "created" || outcome.outcome === "replayed";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/core/reservation-integrity.ts
|
|
317
|
+
var RESERVATION_STATUSES = [
|
|
318
|
+
"reserved",
|
|
319
|
+
"committed",
|
|
320
|
+
"released",
|
|
321
|
+
"expired"
|
|
322
|
+
];
|
|
323
|
+
var TERMINAL_RESERVATION_STATUSES = [
|
|
324
|
+
"committed",
|
|
325
|
+
"released",
|
|
326
|
+
"expired"
|
|
327
|
+
];
|
|
328
|
+
function isReservationStatus(value) {
|
|
329
|
+
return RESERVATION_STATUSES.includes(value);
|
|
330
|
+
}
|
|
331
|
+
function isTerminalReservationStatus(value) {
|
|
332
|
+
return TERMINAL_RESERVATION_STATUSES.includes(value);
|
|
333
|
+
}
|
|
334
|
+
function assertKnownReservationStatus(reservation, transition) {
|
|
335
|
+
if (isReservationStatus(reservation.status)) return;
|
|
336
|
+
throw new CreditError(
|
|
337
|
+
`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.`,
|
|
338
|
+
CreditErrorCode.CORRUPT_RESERVATION_STATUS,
|
|
339
|
+
{
|
|
340
|
+
userId: reservation.userId,
|
|
341
|
+
reservationId: reservation.id,
|
|
342
|
+
transition,
|
|
343
|
+
status: typeof reservation.status === "string" ? reservation.status : String(reservation.status),
|
|
344
|
+
allowed: [...RESERVATION_STATUSES],
|
|
345
|
+
reason: "corrupt_reservation_status"
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
function terminalStatusOf(reservation, transition) {
|
|
350
|
+
assertKnownReservationStatus(reservation, transition);
|
|
351
|
+
if (isTerminalReservationStatus(reservation.status)) return reservation.status;
|
|
352
|
+
throw new CreditError(
|
|
353
|
+
`Reservation ${reservation.id} is ${reservation.status}, which is not a terminal status, but the ${transition} tried to report it as one.`,
|
|
354
|
+
CreditErrorCode.CORRUPT_RESERVATION_STATUS,
|
|
355
|
+
{
|
|
356
|
+
userId: reservation.userId,
|
|
357
|
+
reservationId: reservation.id,
|
|
358
|
+
transition,
|
|
359
|
+
status: reservation.status,
|
|
360
|
+
reason: "not_terminal"
|
|
361
|
+
}
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
function assertHoldPlaced(reservation, transition) {
|
|
365
|
+
if (reservation.holdPlacedAt) return;
|
|
366
|
+
throw new CreditError(
|
|
367
|
+
`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.`,
|
|
368
|
+
CreditErrorCode.UNBACKED_RESERVATION,
|
|
369
|
+
{
|
|
370
|
+
userId: reservation.userId,
|
|
371
|
+
reservationId: reservation.id,
|
|
372
|
+
transition,
|
|
373
|
+
idempotencyKey: reservation.idempotencyKey ?? void 0,
|
|
374
|
+
reason: "hold_not_placed"
|
|
375
|
+
}
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
function assertUnkeyedDirectReservation(input) {
|
|
379
|
+
if (input.idempotencyKey === void 0 || input.idempotencyKey === null) return;
|
|
380
|
+
throw new CreditError(
|
|
381
|
+
"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.",
|
|
382
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
383
|
+
{
|
|
384
|
+
userId: input.userId,
|
|
385
|
+
idempotencyKey: input.idempotencyKey,
|
|
386
|
+
operation: "createReservation",
|
|
387
|
+
reason: "keyed_direct_reservation"
|
|
388
|
+
}
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
function hasPlacedHold(reservation) {
|
|
392
|
+
return Boolean(reservation.holdPlacedAt);
|
|
393
|
+
}
|
|
394
|
+
function assertDirectStatusWriteAllowed(reservation, status) {
|
|
395
|
+
if (status === "reserved") {
|
|
396
|
+
throw new CreditError(
|
|
397
|
+
`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.`,
|
|
398
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
399
|
+
{
|
|
400
|
+
userId: reservation.userId,
|
|
401
|
+
reservationId: reservation.id,
|
|
402
|
+
status,
|
|
403
|
+
reason: "reopen_reservation"
|
|
404
|
+
}
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (!reservation.holdPlacedAt) return;
|
|
408
|
+
throw new CreditError(
|
|
409
|
+
`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.`,
|
|
410
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
411
|
+
{
|
|
412
|
+
userId: reservation.userId,
|
|
413
|
+
reservationId: reservation.id,
|
|
414
|
+
status,
|
|
415
|
+
reason: "direct_status_write_on_backed_hold"
|
|
416
|
+
}
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/core/payment-ref.ts
|
|
421
|
+
function normalizePaymentRef(ref) {
|
|
422
|
+
if (typeof ref !== "string") return void 0;
|
|
423
|
+
const trimmed = ref.trim();
|
|
424
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
425
|
+
}
|
|
426
|
+
function describePaymentMismatch(stored, expected) {
|
|
427
|
+
if (stored.userId !== expected.userId) return "userId";
|
|
428
|
+
if (!sameAmount(stored.amount, expected.amount)) return "amount";
|
|
429
|
+
if (stored.type !== expected.type) return "type";
|
|
430
|
+
if (stored.source !== void 0 && stored.source !== expected.source) return "source";
|
|
431
|
+
if (stored.referenceType !== void 0 && stored.referenceType !== expected.referenceType) {
|
|
432
|
+
return "referenceType";
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
function assertUnreferencedDirectTransaction(input) {
|
|
437
|
+
if (normalizePaymentRef(input.paymentRef) === void 0) return;
|
|
438
|
+
throw new CreditError(
|
|
439
|
+
"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.",
|
|
440
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
441
|
+
{
|
|
442
|
+
userId: input.userId,
|
|
443
|
+
paymentRef: input.paymentRef,
|
|
444
|
+
operation: "createTransaction",
|
|
445
|
+
reason: "referenced_direct_transaction"
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
function createPaymentRefConflictError(paymentRef, details) {
|
|
450
|
+
return new CreditError(
|
|
451
|
+
`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.`,
|
|
452
|
+
CreditErrorCode.IDEMPOTENCY_CONFLICT,
|
|
453
|
+
{ paymentRef, reason: "payment_ref_conflict", ...details }
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/core/idempotency.ts
|
|
458
|
+
function isValidIdempotencyKey(value) {
|
|
459
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
460
|
+
}
|
|
461
|
+
function assertValidIdempotencyKey(value, context) {
|
|
462
|
+
if (value === void 0 || value === null) return;
|
|
463
|
+
if (isValidIdempotencyKey(value)) return;
|
|
464
|
+
throw new CreditError(
|
|
465
|
+
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})`,
|
|
466
|
+
CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
|
|
467
|
+
{ idempotencyKey: value, ...context }
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
var RESERVED_JOURNAL_KEY_PREFIX = "reservation:";
|
|
471
|
+
function isReservedJournalKey(key) {
|
|
472
|
+
return typeof key === "string" && key.startsWith(RESERVED_JOURNAL_KEY_PREFIX);
|
|
473
|
+
}
|
|
474
|
+
function assertPublicJournalKey(key, userId) {
|
|
475
|
+
if (!isReservedJournalKey(key)) return;
|
|
476
|
+
throw new CreditError(
|
|
477
|
+
`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.`,
|
|
478
|
+
CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
|
|
479
|
+
{ userId, idempotencyKey: key, reason: "reserved_namespace" }
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/core/error-classify.ts
|
|
484
|
+
var TRANSIENT_SQLSTATES = /* @__PURE__ */ new Set([
|
|
485
|
+
"40000",
|
|
486
|
+
"40001",
|
|
487
|
+
"40P01",
|
|
488
|
+
"55P03",
|
|
489
|
+
"57014",
|
|
490
|
+
"57P01",
|
|
491
|
+
"57P02",
|
|
492
|
+
"57P03",
|
|
493
|
+
"53200",
|
|
494
|
+
"53300",
|
|
495
|
+
"53400"
|
|
496
|
+
]);
|
|
497
|
+
var TRANSIENT_DRIVER_CODES = /* @__PURE__ */ new Set([
|
|
498
|
+
"ECONNRESET",
|
|
499
|
+
"ECONNREFUSED",
|
|
500
|
+
"EPIPE",
|
|
501
|
+
"ETIMEDOUT",
|
|
502
|
+
"ENOTFOUND",
|
|
503
|
+
"EHOSTUNREACH"
|
|
504
|
+
]);
|
|
505
|
+
var NUMERIC_OVERFLOW = "22003";
|
|
506
|
+
function getSqlState(error) {
|
|
507
|
+
const seen = /* @__PURE__ */ new Set();
|
|
508
|
+
let current = error;
|
|
509
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
510
|
+
if (typeof current !== "object" || current === null || seen.has(current)) return void 0;
|
|
511
|
+
seen.add(current);
|
|
512
|
+
const code = current.code;
|
|
513
|
+
if (typeof code === "string") return code;
|
|
514
|
+
current = current.cause;
|
|
515
|
+
}
|
|
516
|
+
return void 0;
|
|
517
|
+
}
|
|
518
|
+
function isUniqueViolation(error) {
|
|
519
|
+
return getSqlState(error) === "23505";
|
|
520
|
+
}
|
|
521
|
+
function isTransientDatabaseError(error) {
|
|
522
|
+
const code = getSqlState(error);
|
|
523
|
+
if (!code) return false;
|
|
524
|
+
if (TRANSIENT_SQLSTATES.has(code)) return true;
|
|
525
|
+
if (TRANSIENT_DRIVER_CODES.has(code)) return true;
|
|
526
|
+
return code.startsWith("08");
|
|
527
|
+
}
|
|
528
|
+
function classifyDatabaseError(error, context) {
|
|
529
|
+
if (isCreditError(error)) return error;
|
|
530
|
+
const sqlState = getSqlState(error);
|
|
531
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
532
|
+
const details = { ...context, ...sqlState ? { sqlState } : {} };
|
|
533
|
+
if (isTransientDatabaseError(error)) {
|
|
534
|
+
return new CreditError(message, CreditErrorCode.TRANSIENT_ERROR, details);
|
|
535
|
+
}
|
|
536
|
+
if (sqlState === NUMERIC_OVERFLOW) {
|
|
537
|
+
return new CreditError(message, CreditErrorCode.INVALID_AMOUNT, {
|
|
538
|
+
...details,
|
|
539
|
+
reason: "amount_out_of_range"
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return new CreditError(message, CreditErrorCode.DATABASE_ERROR, details);
|
|
543
|
+
}
|
|
122
544
|
|
|
123
545
|
// src/core/deferred.ts
|
|
124
546
|
var genericDeferred = {
|
|
@@ -439,6 +861,12 @@ var UNLIMITED_BALANCE_SENTINEL = 999999;
|
|
|
439
861
|
function getUnlimitedSentinelBalance() {
|
|
440
862
|
return UNLIMITED_BALANCE_SENTINEL;
|
|
441
863
|
}
|
|
864
|
+
function monthlyResetBalance(tier) {
|
|
865
|
+
if (isUnlimitedTier(tier)) {
|
|
866
|
+
return { kind: "atLeast", value: UNLIMITED_BALANCE_SENTINEL };
|
|
867
|
+
}
|
|
868
|
+
return { kind: "absolute", value: getConfigMonthlyLimit(tier) };
|
|
869
|
+
}
|
|
442
870
|
function getConfigTierConfig(tier) {
|
|
443
871
|
const cfg = getConfig().tierConfigs[tier];
|
|
444
872
|
if (cfg) return cfg;
|
|
@@ -481,57 +909,203 @@ function resetConfig() {
|
|
|
481
909
|
}
|
|
482
910
|
initializeConfig();
|
|
483
911
|
|
|
484
|
-
// src/
|
|
485
|
-
|
|
912
|
+
// src/repository/types.ts
|
|
913
|
+
function supportsCreditsV2(repository) {
|
|
914
|
+
return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
|
|
915
|
+
}
|
|
916
|
+
function supportsIdempotentCredit(repository) {
|
|
917
|
+
return typeof repository.addCreditsV2 === "function";
|
|
918
|
+
}
|
|
919
|
+
function toClientUserCredits(credits) {
|
|
920
|
+
return {
|
|
921
|
+
userId: credits.userId,
|
|
922
|
+
balance: credits.balance,
|
|
923
|
+
bonusCredits: credits.bonusCredits ?? 0,
|
|
924
|
+
reserved: credits.reserved,
|
|
925
|
+
tier: credits.tier,
|
|
926
|
+
monthlyLimit: credits.monthlyLimit,
|
|
927
|
+
monthlyUsed: credits.monthlyUsed,
|
|
928
|
+
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
929
|
+
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
930
|
+
createdAt: toISOString(credits.createdAt),
|
|
931
|
+
updatedAt: toISOString(credits.updatedAt)
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
function toISOString(value) {
|
|
935
|
+
if (!value) return (/* @__PURE__ */ new Date()).toISOString();
|
|
936
|
+
if (typeof value === "string") return value;
|
|
937
|
+
if (value instanceof Date) return value.toISOString();
|
|
938
|
+
if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
|
|
939
|
+
return value.toDate().toISOString();
|
|
940
|
+
}
|
|
941
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// src/repository/flow.ts
|
|
945
|
+
async function reserveThroughRepository(repository, input) {
|
|
946
|
+
const v2 = supportsCreditsV2(repository);
|
|
947
|
+
if (!v2 && input.idempotencyKey !== void 0) {
|
|
948
|
+
throw createUnsupportedOperationError(
|
|
949
|
+
"reserveCredits with an idempotencyKey",
|
|
950
|
+
"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."
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
assertValidCreditAmount(input.amount, { userId: input.userId });
|
|
954
|
+
if (v2) {
|
|
955
|
+
return repository.reserveCreditsV2(input);
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
const reservation = await repository.reserveCreditsAtomic(
|
|
959
|
+
input.userId,
|
|
960
|
+
input.amount,
|
|
961
|
+
input.operationType,
|
|
962
|
+
input.expiresAt
|
|
963
|
+
);
|
|
964
|
+
return { outcome: "created", reservation };
|
|
965
|
+
} catch (error) {
|
|
966
|
+
if (!isInsufficientCreditsError(error)) throw error;
|
|
967
|
+
const credits = await repository.getUserCredits(input.userId);
|
|
968
|
+
const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
|
|
969
|
+
return {
|
|
970
|
+
outcome: "insufficient",
|
|
971
|
+
available,
|
|
972
|
+
required: input.amount,
|
|
973
|
+
shortfall: Math.max(sumAmounts(input.amount, -available), 0)
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
async function commitThroughRepository(repository, userId, reservationId, options) {
|
|
978
|
+
if (supportsCreditsV2(repository)) {
|
|
979
|
+
return repository.commitReservationV2(userId, reservationId, options);
|
|
980
|
+
}
|
|
486
981
|
const reservation = await repository.getReservation(userId, reservationId);
|
|
487
|
-
if (!reservation) {
|
|
488
|
-
|
|
982
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
983
|
+
if (reservation.status !== "reserved") {
|
|
984
|
+
return {
|
|
985
|
+
outcome: "already_terminal",
|
|
986
|
+
reservation,
|
|
987
|
+
terminalStatus: reservation.status
|
|
988
|
+
};
|
|
489
989
|
}
|
|
490
990
|
await repository.commitReservationAtomic(userId, reservationId);
|
|
491
991
|
const credits = await repository.getUserCredits(userId);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
992
|
+
const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
|
|
993
|
+
const entry = await repository.createJournalEntry({
|
|
994
|
+
userId,
|
|
995
|
+
entryType: "debit",
|
|
996
|
+
amount: reservation.amount,
|
|
997
|
+
balanceAfter,
|
|
998
|
+
source: "operation_commit",
|
|
999
|
+
referenceId: reservationId,
|
|
1000
|
+
referenceType: "reservation",
|
|
1001
|
+
description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1002
|
+
// Deterministic fields last: they carry the identity of the event, and a
|
|
1003
|
+
// caller who happens to send an `operationType` or an `amount` must not
|
|
1004
|
+
// relabel what actually moved.
|
|
1005
|
+
metadata: {
|
|
1006
|
+
...options?.metadata,
|
|
1007
|
+
operationType: reservation.operationType,
|
|
1008
|
+
amount: reservation.amount
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
return {
|
|
1012
|
+
outcome: "committed",
|
|
1013
|
+
reservation: { ...reservation, status: "committed" },
|
|
1014
|
+
amount: reservation.amount,
|
|
1015
|
+
balanceAfter,
|
|
1016
|
+
journalEntryId: entry.id
|
|
1017
|
+
};
|
|
507
1018
|
}
|
|
508
|
-
async function
|
|
1019
|
+
async function releaseThroughRepository(repository, userId, reservationId, options) {
|
|
1020
|
+
if (supportsCreditsV2(repository)) {
|
|
1021
|
+
return repository.releaseReservationV2(userId, reservationId, options);
|
|
1022
|
+
}
|
|
509
1023
|
const reservation = await repository.getReservation(userId, reservationId);
|
|
1024
|
+
if (!reservation) {
|
|
1025
|
+
await repository.releaseReservationAtomic(userId, reservationId);
|
|
1026
|
+
return { outcome: "not_found", reservationId };
|
|
1027
|
+
}
|
|
1028
|
+
if (reservation.status !== "reserved") {
|
|
1029
|
+
await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
|
|
1030
|
+
return {
|
|
1031
|
+
outcome: "already_terminal",
|
|
1032
|
+
reservation,
|
|
1033
|
+
terminalStatus: reservation.status
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
510
1036
|
await repository.releaseReservationAtomic(userId, reservationId);
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
amount: reservation.amount
|
|
527
|
-
}
|
|
528
|
-
});
|
|
1037
|
+
const credits = await repository.getUserCredits(userId);
|
|
1038
|
+
const entry = await repository.createJournalEntry({
|
|
1039
|
+
userId,
|
|
1040
|
+
entryType: "credit",
|
|
1041
|
+
// No credits changed hands — the hold just moved back into "available".
|
|
1042
|
+
amount: 0,
|
|
1043
|
+
balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
|
|
1044
|
+
source: "operation_release",
|
|
1045
|
+
referenceId: reservationId,
|
|
1046
|
+
referenceType: "reservation",
|
|
1047
|
+
description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1048
|
+
metadata: {
|
|
1049
|
+
...options?.metadata,
|
|
1050
|
+
operationType: reservation.operationType,
|
|
1051
|
+
amount: reservation.amount
|
|
529
1052
|
}
|
|
1053
|
+
});
|
|
1054
|
+
return {
|
|
1055
|
+
outcome: "released",
|
|
1056
|
+
reservation: { ...reservation, status: "released" },
|
|
1057
|
+
amount: reservation.amount,
|
|
1058
|
+
journalEntryId: entry.id
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
async function addCreditsThroughRepository(repository, input) {
|
|
1062
|
+
assertValidCreditAmount(input.amount, { userId: input.userId, operation: "addCredits" });
|
|
1063
|
+
const paymentRef = normalizePaymentRef(input.paymentRef);
|
|
1064
|
+
if (supportsIdempotentCredit(repository)) {
|
|
1065
|
+
return repository.addCreditsV2({ ...input, paymentRef });
|
|
1066
|
+
}
|
|
1067
|
+
await repository.addCreditsAtomic(
|
|
1068
|
+
input.userId,
|
|
1069
|
+
input.amount,
|
|
1070
|
+
input.description,
|
|
1071
|
+
paymentRef,
|
|
1072
|
+
input.options
|
|
1073
|
+
);
|
|
1074
|
+
return paymentRef ? { outcome: "created", paymentRef } : { outcome: "created" };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// src/core/operations.ts
|
|
1078
|
+
async function commitReservationWithJournal(repository, userId, reservationId) {
|
|
1079
|
+
const outcome = await commitThroughRepository(repository, userId, reservationId);
|
|
1080
|
+
if (outcome.outcome === "not_found") {
|
|
1081
|
+
throw createReservationNotFoundError(reservationId);
|
|
530
1082
|
}
|
|
1083
|
+
if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
|
|
1084
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
async function releaseReservationWithJournal(repository, userId, reservationId) {
|
|
1088
|
+
await releaseThroughRepository(repository, userId, reservationId);
|
|
531
1089
|
}
|
|
532
|
-
async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3) {
|
|
1090
|
+
async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3, idempotencyKey) {
|
|
533
1091
|
const expiresAt = new Date(Date.now() + expiryMs);
|
|
534
|
-
|
|
1092
|
+
const outcome = await reserveThroughRepository(repository, {
|
|
1093
|
+
userId,
|
|
1094
|
+
amount,
|
|
1095
|
+
operationType,
|
|
1096
|
+
expiresAt,
|
|
1097
|
+
idempotencyKey
|
|
1098
|
+
});
|
|
1099
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
1100
|
+
return outcome.reservation;
|
|
1101
|
+
}
|
|
1102
|
+
if (outcome.outcome === "insufficient") {
|
|
1103
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
1104
|
+
}
|
|
1105
|
+
throw createReservationAlreadyProcessedError(
|
|
1106
|
+
outcome.existing.id,
|
|
1107
|
+
outcome.existing.status
|
|
1108
|
+
);
|
|
535
1109
|
}
|
|
536
1110
|
|
|
537
1111
|
// src/config/costs.ts
|
|
@@ -566,64 +1140,493 @@ function getOperationCostsWithLabels() {
|
|
|
566
1140
|
return result;
|
|
567
1141
|
}
|
|
568
1142
|
|
|
569
|
-
// src/repository/types.ts
|
|
570
|
-
function
|
|
571
|
-
return {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1143
|
+
// src/repository/v2-types.ts
|
|
1144
|
+
function reservationJournalKey(reservationId, transition) {
|
|
1145
|
+
return `${RESERVED_JOURNAL_KEY_PREFIX}${reservationId}:${transition}`;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// src/repository/utils.ts
|
|
1149
|
+
function generateId() {
|
|
1150
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
1151
|
+
}
|
|
1152
|
+
function toDate2(value) {
|
|
1153
|
+
if (value instanceof Date) return value;
|
|
1154
|
+
if (typeof value === "string") return new Date(value);
|
|
1155
|
+
if (typeof value === "object" && value !== null && "toDate" in value) {
|
|
1156
|
+
return value.toDate();
|
|
1157
|
+
}
|
|
1158
|
+
return /* @__PURE__ */ new Date();
|
|
1159
|
+
}
|
|
1160
|
+
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
1161
|
+
const next = new Date(from);
|
|
1162
|
+
next.setMonth(next.getMonth() + 1);
|
|
1163
|
+
next.setDate(1);
|
|
1164
|
+
next.setHours(0, 0, 0, 0);
|
|
1165
|
+
return next;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// src/repository/memory/snapshot.ts
|
|
1169
|
+
function copyRecord(record) {
|
|
1170
|
+
if (!record) return void 0;
|
|
1171
|
+
const copy = { ...record };
|
|
1172
|
+
const metadata = record.metadata;
|
|
1173
|
+
if (metadata && typeof metadata === "object") {
|
|
1174
|
+
copy.metadata = { ...metadata };
|
|
1175
|
+
}
|
|
1176
|
+
return copy;
|
|
1177
|
+
}
|
|
1178
|
+
function copyRecords(records) {
|
|
1179
|
+
return records.map((record) => copyRecord(record));
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// src/repository/memory/mutex.ts
|
|
1183
|
+
var KeyedMutex = class {
|
|
1184
|
+
tails = /* @__PURE__ */ new Map();
|
|
1185
|
+
/**
|
|
1186
|
+
* Run `fn` with exclusive access to `key`.
|
|
1187
|
+
*
|
|
1188
|
+
* Callers queue in arrival order. The stored tail settles either way, so one
|
|
1189
|
+
* caller throwing does not wedge the queue behind it.
|
|
1190
|
+
*/
|
|
1191
|
+
async run(key, fn) {
|
|
1192
|
+
const previous = this.tails.get(key) ?? Promise.resolve();
|
|
1193
|
+
const result = previous.then(fn, fn);
|
|
1194
|
+
const tail = result.then(
|
|
1195
|
+
() => void 0,
|
|
1196
|
+
() => void 0
|
|
1197
|
+
);
|
|
1198
|
+
this.tails.set(key, tail);
|
|
1199
|
+
try {
|
|
1200
|
+
return await result;
|
|
1201
|
+
} finally {
|
|
1202
|
+
if (this.tails.get(key) === tail) {
|
|
1203
|
+
this.tails.delete(key);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
// src/repository/memory/store.ts
|
|
1210
|
+
var MemoryStore = class {
|
|
1211
|
+
users = /* @__PURE__ */ new Map();
|
|
1212
|
+
reservations = /* @__PURE__ */ new Map();
|
|
1213
|
+
transactions = /* @__PURE__ */ new Map();
|
|
1214
|
+
usageLogs = [];
|
|
1215
|
+
journalEntries = /* @__PURE__ */ new Map();
|
|
1216
|
+
/** Stands in for the reservations table's partial unique index. */
|
|
1217
|
+
reservationKeys = /* @__PURE__ */ new Map();
|
|
1218
|
+
/** Stands in for the journal table's partial unique index. */
|
|
1219
|
+
journalKeys = /* @__PURE__ */ new Map();
|
|
1220
|
+
/** Stands in for the database's per-user row locks. */
|
|
1221
|
+
locks = new KeyedMutex();
|
|
1222
|
+
/**
|
|
1223
|
+
* Test-only yield point, awaited between reading state and mutating it.
|
|
1224
|
+
*
|
|
1225
|
+
* Unset in production, so the critical sections stay synchronous and this
|
|
1226
|
+
* costs nothing. Tests set it to a real macrotask yield, which forces
|
|
1227
|
+
* concurrent callers to interleave at exactly the spot a database would have
|
|
1228
|
+
* them contend — turning the lock above from decoration into the thing the
|
|
1229
|
+
* concurrency tests actually depend on.
|
|
1230
|
+
*/
|
|
1231
|
+
schedulingHook;
|
|
1232
|
+
clear() {
|
|
1233
|
+
this.users.clear();
|
|
1234
|
+
this.reservations.clear();
|
|
1235
|
+
this.transactions.clear();
|
|
1236
|
+
this.usageLogs = [];
|
|
1237
|
+
this.journalEntries.clear();
|
|
1238
|
+
this.reservationKeys.clear();
|
|
1239
|
+
this.journalKeys.clear();
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
function scopedKey(userId, key) {
|
|
1243
|
+
return `${userId}
|
|
1244
|
+
${key}`;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// src/repository/memory/transition-journal.ts
|
|
1248
|
+
function describeJournalMismatch(row, input) {
|
|
1249
|
+
if (row.userId !== input.userId) return "user_id";
|
|
1250
|
+
if (row.entryType !== input.entryType) return "entry_type";
|
|
1251
|
+
if (!sameAmount(row.amount, input.amount)) return "amount";
|
|
1252
|
+
if (!sameAmount(row.balanceAfter, input.balanceAfter)) return "balance_after";
|
|
1253
|
+
if (row.source !== input.source) return "source";
|
|
1254
|
+
if (row.referenceId !== input.reservationId) return "reference_id";
|
|
1255
|
+
if (row.referenceType !== "reservation") return "reference_type";
|
|
1256
|
+
const existingMeta = row.metadata ?? {};
|
|
1257
|
+
const expectedMeta = input.metadata ?? {};
|
|
1258
|
+
if (existingMeta.operationType !== expectedMeta.operationType) {
|
|
1259
|
+
return "metadata.operationType";
|
|
1260
|
+
}
|
|
1261
|
+
const hadAmount = existingMeta.amount !== void 0;
|
|
1262
|
+
const wantsAmount = expectedMeta.amount !== void 0;
|
|
1263
|
+
if (hadAmount !== wantsAmount) return "metadata.amount";
|
|
1264
|
+
if (wantsAmount && !sameAmount(existingMeta.amount, expectedMeta.amount)) {
|
|
1265
|
+
return "metadata.amount";
|
|
1266
|
+
}
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
function findEntry(store, userId, entryId) {
|
|
1270
|
+
return store.journalEntries.get(userId)?.find((entry) => entry.id === entryId);
|
|
1271
|
+
}
|
|
1272
|
+
function planTransitionJournal(store, input) {
|
|
1273
|
+
assertRepresentableFields(
|
|
1274
|
+
{ amount: input.amount, "journal balanceAfter": input.balanceAfter },
|
|
1275
|
+
{ userId: input.userId, reservationId: input.reservationId, operation: input.operation }
|
|
1276
|
+
);
|
|
1277
|
+
const existingId = store.journalKeys.get(scopedKey(input.userId, input.idempotencyKey));
|
|
1278
|
+
if (!existingId) return void 0;
|
|
1279
|
+
const row = findEntry(store, input.userId, existingId);
|
|
1280
|
+
const mismatch = row ? describeJournalMismatch(row, input) : "the key is registered but its entry is missing";
|
|
1281
|
+
if (mismatch) {
|
|
1282
|
+
throw new CreditError(
|
|
1283
|
+
`Journal key ${input.idempotencyKey} is already used by a different entry (${mismatch}). The transition was refused rather than reported as applied.`,
|
|
1284
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
1285
|
+
{
|
|
1286
|
+
idempotencyKey: input.idempotencyKey,
|
|
1287
|
+
reservationId: input.reservationId,
|
|
1288
|
+
mismatch
|
|
1289
|
+
}
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
return existingId;
|
|
1293
|
+
}
|
|
1294
|
+
function commitTransitionJournal(store, input, existingId) {
|
|
1295
|
+
if (existingId) return existingId;
|
|
1296
|
+
const entry = {
|
|
1297
|
+
id: generateId(),
|
|
1298
|
+
userId: input.userId,
|
|
1299
|
+
entryType: input.entryType,
|
|
1300
|
+
amount: input.amount,
|
|
1301
|
+
balanceAfter: input.balanceAfter,
|
|
1302
|
+
source: input.source,
|
|
1303
|
+
referenceId: input.reservationId,
|
|
1304
|
+
referenceType: "reservation",
|
|
1305
|
+
description: input.description,
|
|
1306
|
+
metadata: input.metadata,
|
|
1307
|
+
idempotencyKey: input.idempotencyKey,
|
|
1308
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
583
1309
|
};
|
|
1310
|
+
if (!store.journalEntries.has(input.userId)) {
|
|
1311
|
+
store.journalEntries.set(input.userId, []);
|
|
1312
|
+
}
|
|
1313
|
+
store.journalEntries.get(input.userId).push(entry);
|
|
1314
|
+
store.journalKeys.set(scopedKey(input.userId, input.idempotencyKey), entry.id);
|
|
1315
|
+
return entry.id;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// src/repository/memory/v2.ts
|
|
1319
|
+
async function yieldPoint(store) {
|
|
1320
|
+
if (store.schedulingHook) await store.schedulingHook();
|
|
1321
|
+
}
|
|
1322
|
+
function assertReservedCoversHold(credits, userId, reservationId, amount) {
|
|
1323
|
+
if (credits.reserved >= amount) return;
|
|
1324
|
+
throw new CreditError(
|
|
1325
|
+
`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.`,
|
|
1326
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
1327
|
+
{ userId, reservationId, amount, reserved: credits.reserved }
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
function assertLockedAmount(reservation, transition) {
|
|
1331
|
+
assertValidStoredAmount(reservation.amount, {
|
|
1332
|
+
userId: reservation.userId,
|
|
1333
|
+
reservationId: reservation.id,
|
|
1334
|
+
transition
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
function terminalStatusOf2(reservation, transition) {
|
|
1338
|
+
return terminalStatusOf(reservation, transition);
|
|
1339
|
+
}
|
|
1340
|
+
function assertTrustworthy(reservation, transition) {
|
|
1341
|
+
assertKnownReservationStatus(reservation, transition);
|
|
1342
|
+
assertLockedAmount(reservation, transition);
|
|
1343
|
+
assertHoldPlaced(reservation, transition);
|
|
1344
|
+
}
|
|
1345
|
+
function getReservation(store, userId, reservationId) {
|
|
1346
|
+
return store.reservations.get(userId)?.get(reservationId);
|
|
1347
|
+
}
|
|
1348
|
+
function requireUser(store, userId) {
|
|
1349
|
+
const credits = store.users.get(userId);
|
|
1350
|
+
if (!credits) {
|
|
1351
|
+
throw new CreditError(
|
|
1352
|
+
`User credits not found for user ${userId}`,
|
|
1353
|
+
CreditErrorCode.USER_NOT_FOUND,
|
|
1354
|
+
{ userId }
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
return credits;
|
|
1358
|
+
}
|
|
1359
|
+
async function reserveCreditsV2(store, input) {
|
|
1360
|
+
assertValidCreditAmount(input.amount, { userId: input.userId });
|
|
1361
|
+
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
1362
|
+
return store.locks.run(input.userId, async () => {
|
|
1363
|
+
if (input.idempotencyKey) {
|
|
1364
|
+
const existingId = store.reservationKeys.get(
|
|
1365
|
+
scopedKey(input.userId, input.idempotencyKey)
|
|
1366
|
+
);
|
|
1367
|
+
if (existingId) {
|
|
1368
|
+
const existing = getReservation(store, input.userId, existingId);
|
|
1369
|
+
if (existing) {
|
|
1370
|
+
assertHoldPlaced(existing, "reserve replay");
|
|
1371
|
+
const samePayload = existing.amount === input.amount && existing.operationType === input.operationType;
|
|
1372
|
+
return samePayload ? { outcome: "replayed", reservation: copyRecord(existing) } : {
|
|
1373
|
+
outcome: "idempotency_conflict",
|
|
1374
|
+
idempotencyKey: input.idempotencyKey,
|
|
1375
|
+
existing: copyRecord(existing)
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
const credits = requireUser(store, input.userId);
|
|
1381
|
+
const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
|
|
1382
|
+
await yieldPoint(store);
|
|
1383
|
+
if (available < input.amount) {
|
|
1384
|
+
return {
|
|
1385
|
+
outcome: "insufficient",
|
|
1386
|
+
available,
|
|
1387
|
+
required: input.amount,
|
|
1388
|
+
shortfall: sumAmounts(input.amount, -available)
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1392
|
+
const reservation = {
|
|
1393
|
+
id: generateId(),
|
|
1394
|
+
userId: input.userId,
|
|
1395
|
+
amount: input.amount,
|
|
1396
|
+
operationType: input.operationType,
|
|
1397
|
+
status: "reserved",
|
|
1398
|
+
createdAt: now,
|
|
1399
|
+
expiresAt: input.expiresAt.toISOString(),
|
|
1400
|
+
idempotencyKey: input.idempotencyKey,
|
|
1401
|
+
// The hold-origin fact. Written here and nowhere else, in the same
|
|
1402
|
+
// critical section that raises `reserved` below, so the row and the hold
|
|
1403
|
+
// become visible together. `createReservation` cannot set it.
|
|
1404
|
+
holdPlacedAt: now
|
|
1405
|
+
};
|
|
1406
|
+
if (!store.reservations.has(input.userId)) {
|
|
1407
|
+
store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
1408
|
+
}
|
|
1409
|
+
const nextReserved = sumAmounts(credits.reserved, input.amount);
|
|
1410
|
+
assertRepresentableFields(
|
|
1411
|
+
{ reserved: nextReserved },
|
|
1412
|
+
{ userId: input.userId, operation: "reserveCredits" }
|
|
1413
|
+
);
|
|
1414
|
+
store.reservations.get(input.userId).set(reservation.id, reservation);
|
|
1415
|
+
if (input.idempotencyKey) {
|
|
1416
|
+
store.reservationKeys.set(
|
|
1417
|
+
scopedKey(input.userId, input.idempotencyKey),
|
|
1418
|
+
reservation.id
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
credits.reserved = nextReserved;
|
|
1422
|
+
credits.updatedAt = now;
|
|
1423
|
+
return { outcome: "created", reservation: copyRecord(reservation) };
|
|
1424
|
+
});
|
|
584
1425
|
}
|
|
585
|
-
function
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
1426
|
+
async function commitReservationV2(store, userId, reservationId, options) {
|
|
1427
|
+
return store.locks.run(userId, async () => {
|
|
1428
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1429
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1430
|
+
assertTrustworthy(reservation, "commit");
|
|
1431
|
+
if (reservation.status !== "reserved") {
|
|
1432
|
+
return {
|
|
1433
|
+
outcome: "already_terminal",
|
|
1434
|
+
reservation: copyRecord(reservation),
|
|
1435
|
+
terminalStatus: terminalStatusOf2(reservation, "commit")
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
const credits = requireUser(store, userId);
|
|
1439
|
+
const amount = reservation.amount;
|
|
1440
|
+
await yieldPoint(store);
|
|
1441
|
+
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1442
|
+
if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
|
|
1443
|
+
throw new CreditError(
|
|
1444
|
+
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1445
|
+
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1446
|
+
{ userId, reservationId, required: amount }
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
const fromBalance = Math.min(credits.balance, amount);
|
|
1450
|
+
const nextBalance = sumAmounts(credits.balance, -fromBalance);
|
|
1451
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1452
|
+
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1453
|
+
const journal = {
|
|
1454
|
+
userId,
|
|
1455
|
+
operation: "commitReservation",
|
|
1456
|
+
entryType: "debit",
|
|
1457
|
+
amount,
|
|
1458
|
+
balanceAfter,
|
|
1459
|
+
source: "operation_commit",
|
|
1460
|
+
reservationId,
|
|
1461
|
+
description: options?.description ?? `Committed ${amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1462
|
+
// Caller metadata first: the deterministic fields carry the identity of
|
|
1463
|
+
// the event that the collision check compares on.
|
|
1464
|
+
metadata: { ...options?.metadata, operationType: reservation.operationType, amount },
|
|
1465
|
+
idempotencyKey: reservationJournalKey(reservationId, "commit")
|
|
1466
|
+
};
|
|
1467
|
+
const nextReserved = sumAmounts(credits.reserved, -amount);
|
|
1468
|
+
const nextMonthlyUsed = sumAmounts(credits.monthlyUsed, amount);
|
|
1469
|
+
assertRepresentableFields(
|
|
1470
|
+
{
|
|
1471
|
+
balance: nextBalance,
|
|
1472
|
+
bonusCredits: nextBonusCredits,
|
|
1473
|
+
reserved: nextReserved,
|
|
1474
|
+
monthlyUsed: nextMonthlyUsed
|
|
1475
|
+
},
|
|
1476
|
+
{ userId, operation: "commitReservation" }
|
|
1477
|
+
);
|
|
1478
|
+
const existingEntryId = planTransitionJournal(store, journal);
|
|
1479
|
+
reservation.status = "committed";
|
|
1480
|
+
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1481
|
+
credits.balance = nextBalance;
|
|
1482
|
+
credits.bonusCredits = nextBonusCredits;
|
|
1483
|
+
credits.reserved = nextReserved;
|
|
1484
|
+
credits.monthlyUsed = nextMonthlyUsed;
|
|
1485
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1486
|
+
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1487
|
+
return {
|
|
1488
|
+
outcome: "committed",
|
|
1489
|
+
reservation: copyRecord(reservation),
|
|
1490
|
+
amount,
|
|
1491
|
+
balanceAfter,
|
|
1492
|
+
journalEntryId
|
|
1493
|
+
};
|
|
1494
|
+
});
|
|
593
1495
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1496
|
+
async function releaseReservationV2(store, userId, reservationId, options) {
|
|
1497
|
+
return store.locks.run(userId, async () => {
|
|
1498
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1499
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1500
|
+
assertTrustworthy(reservation, "release");
|
|
1501
|
+
if (reservation.status !== "reserved") {
|
|
1502
|
+
return {
|
|
1503
|
+
outcome: "already_terminal",
|
|
1504
|
+
reservation: copyRecord(reservation),
|
|
1505
|
+
terminalStatus: terminalStatusOf2(reservation, "release")
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
await yieldPoint(store);
|
|
1509
|
+
const journalEntryId = finishUnspent(store, reservation, {
|
|
1510
|
+
status: "released",
|
|
1511
|
+
source: "operation_release",
|
|
1512
|
+
transition: "release",
|
|
1513
|
+
verb: "Released",
|
|
1514
|
+
options
|
|
1515
|
+
});
|
|
1516
|
+
return {
|
|
1517
|
+
outcome: "released",
|
|
1518
|
+
reservation: copyRecord(reservation),
|
|
1519
|
+
amount: reservation.amount,
|
|
1520
|
+
journalEntryId
|
|
1521
|
+
};
|
|
1522
|
+
});
|
|
598
1523
|
}
|
|
599
|
-
function
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
1524
|
+
async function expireReservationV2(store, userId, reservationId, options) {
|
|
1525
|
+
const asOf = options?.asOf ?? /* @__PURE__ */ new Date();
|
|
1526
|
+
return store.locks.run(userId, async () => {
|
|
1527
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1528
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1529
|
+
assertTrustworthy(reservation, "expire");
|
|
1530
|
+
if (reservation.status !== "reserved") {
|
|
1531
|
+
return {
|
|
1532
|
+
outcome: "already_terminal",
|
|
1533
|
+
reservation: copyRecord(reservation),
|
|
1534
|
+
terminalStatus: terminalStatusOf2(reservation, "expire")
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
if (new Date(reservation.expiresAt).getTime() > asOf.getTime()) {
|
|
1538
|
+
return { outcome: "not_due", reservation: copyRecord(reservation) };
|
|
1539
|
+
}
|
|
1540
|
+
await yieldPoint(store);
|
|
1541
|
+
const journalEntryId = finishUnspent(store, reservation, {
|
|
1542
|
+
status: "expired",
|
|
1543
|
+
source: "reservation_expired",
|
|
1544
|
+
transition: "expire",
|
|
1545
|
+
verb: "Expired",
|
|
1546
|
+
options
|
|
1547
|
+
});
|
|
1548
|
+
return {
|
|
1549
|
+
outcome: "expired",
|
|
1550
|
+
reservation: copyRecord(reservation),
|
|
1551
|
+
amount: reservation.amount,
|
|
1552
|
+
journalEntryId
|
|
1553
|
+
};
|
|
1554
|
+
});
|
|
606
1555
|
}
|
|
607
|
-
function
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1556
|
+
function finishUnspent(store, reservation, transition) {
|
|
1557
|
+
const credits = requireUser(store, reservation.userId);
|
|
1558
|
+
assertReservedCoversHold(
|
|
1559
|
+
credits,
|
|
1560
|
+
reservation.userId,
|
|
1561
|
+
reservation.id,
|
|
1562
|
+
reservation.amount
|
|
1563
|
+
);
|
|
1564
|
+
const journal = {
|
|
1565
|
+
userId: reservation.userId,
|
|
1566
|
+
operation: `${transition.transition}Reservation`,
|
|
1567
|
+
entryType: "credit",
|
|
1568
|
+
amount: 0,
|
|
1569
|
+
balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1570
|
+
source: transition.source,
|
|
1571
|
+
reservationId: reservation.id,
|
|
1572
|
+
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1573
|
+
metadata: {
|
|
1574
|
+
...transition.options?.metadata,
|
|
1575
|
+
operationType: reservation.operationType,
|
|
1576
|
+
amount: reservation.amount
|
|
1577
|
+
},
|
|
1578
|
+
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1579
|
+
};
|
|
1580
|
+
const nextReserved = sumAmounts(credits.reserved, -reservation.amount);
|
|
1581
|
+
assertRepresentableFields(
|
|
1582
|
+
{ reserved: nextReserved },
|
|
1583
|
+
{ userId: reservation.userId, operation: `${transition.transition}Reservation` }
|
|
1584
|
+
);
|
|
1585
|
+
const existingEntryId = planTransitionJournal(store, journal);
|
|
1586
|
+
reservation.status = transition.status;
|
|
1587
|
+
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1588
|
+
credits.reserved = nextReserved;
|
|
1589
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1590
|
+
return commitTransitionJournal(store, journal, existingEntryId);
|
|
613
1591
|
}
|
|
614
1592
|
|
|
615
1593
|
// src/repository/memory/index.ts
|
|
1594
|
+
function isoOf(value) {
|
|
1595
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
1596
|
+
}
|
|
616
1597
|
var InMemoryCreditRepository = class {
|
|
617
|
-
|
|
618
|
-
reservations = /* @__PURE__ */ new Map();
|
|
619
|
-
transactions = /* @__PURE__ */ new Map();
|
|
620
|
-
usageLogs = [];
|
|
621
|
-
journalEntries = /* @__PURE__ */ new Map();
|
|
1598
|
+
store = new MemoryStore();
|
|
622
1599
|
// ==================== User Credits ====================
|
|
1600
|
+
/**
|
|
1601
|
+
* A copy, never the live record.
|
|
1602
|
+
*
|
|
1603
|
+
* Handing out the stored object made this adapter behave unlike any real
|
|
1604
|
+
* one: a caller holding a "before" snapshot saw it mutate under them. The
|
|
1605
|
+
* service's monthly reset compared `resetResult.credits.balance` against the
|
|
1606
|
+
* balance it had read moments earlier, got zero because both were the same
|
|
1607
|
+
* object, and silently skipped the reset's journal entry — while the SQL
|
|
1608
|
+
* adapter, which maps each row into a fresh object, wrote it.
|
|
1609
|
+
*/
|
|
623
1610
|
async getUserCredits(userId) {
|
|
624
|
-
|
|
1611
|
+
const credits = this.store.users.get(userId);
|
|
1612
|
+
return copyRecord(credits) ?? null;
|
|
625
1613
|
}
|
|
626
1614
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1615
|
+
return copyRecord(this.seedUser(userId, tier, initialBalance));
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* The synchronous body of `initializeUserCredits`.
|
|
1619
|
+
*
|
|
1620
|
+
* Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
|
|
1621
|
+
* its critical section, and an `await` between the payment-reference check
|
|
1622
|
+
* and the transaction write opens a gap another caller can run through — two
|
|
1623
|
+
* simultaneous first deliveries of the same reference would then both credit.
|
|
1624
|
+
*/
|
|
1625
|
+
seedUser(userId, tier, initialBalance) {
|
|
1626
|
+
assertRepresentableAmount(initialBalance, "initialBalance", { userId });
|
|
1627
|
+
const configured = getConfigMonthlyLimit(tier);
|
|
1628
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
|
|
1629
|
+
const monthlyLimit = storedMonthlyLimit(configured);
|
|
627
1630
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
628
1631
|
const credits = {
|
|
629
1632
|
userId,
|
|
@@ -631,61 +1634,93 @@ var InMemoryCreditRepository = class {
|
|
|
631
1634
|
bonusCredits: 0,
|
|
632
1635
|
reserved: 0,
|
|
633
1636
|
tier,
|
|
634
|
-
monthlyLimit
|
|
1637
|
+
monthlyLimit,
|
|
635
1638
|
monthlyUsed: 0,
|
|
636
1639
|
monthlyResetAt: getNextMonthlyReset().toISOString(),
|
|
637
1640
|
subscriptionExpiresAt: null,
|
|
638
1641
|
createdAt: now,
|
|
639
1642
|
updatedAt: now
|
|
640
1643
|
};
|
|
641
|
-
this.users.set(userId, credits);
|
|
1644
|
+
this.store.users.set(userId, credits);
|
|
642
1645
|
return credits;
|
|
643
1646
|
}
|
|
644
1647
|
async updateUserCredits(userId, updates) {
|
|
645
|
-
|
|
1648
|
+
assertRepresentableFields(
|
|
1649
|
+
{
|
|
1650
|
+
balance: updates.balance,
|
|
1651
|
+
bonusCredits: updates.bonusCredits,
|
|
1652
|
+
reserved: updates.reserved,
|
|
1653
|
+
monthlyLimit: updates.monthlyLimit,
|
|
1654
|
+
monthlyUsed: updates.monthlyUsed,
|
|
1655
|
+
balanceIncrement: updates.balanceIncrement,
|
|
1656
|
+
bonusCreditsIncrement: updates.bonusCreditsIncrement,
|
|
1657
|
+
reservedIncrement: updates.reservedIncrement,
|
|
1658
|
+
monthlyUsedIncrement: updates.monthlyUsedIncrement
|
|
1659
|
+
},
|
|
1660
|
+
{ userId, operation: "updateUserCredits" }
|
|
1661
|
+
);
|
|
1662
|
+
const credits = this.store.users.get(userId);
|
|
646
1663
|
if (!credits) {
|
|
647
1664
|
throw new Error(`User ${userId} not found`);
|
|
648
1665
|
}
|
|
649
|
-
const
|
|
650
|
-
if (updates.balance !== void 0)
|
|
651
|
-
if (updates.bonusCredits !== void 0)
|
|
652
|
-
if (updates.reserved !== void 0)
|
|
653
|
-
if (updates.tier !== void 0)
|
|
654
|
-
if (updates.monthlyLimit !== void 0)
|
|
655
|
-
if (updates.monthlyUsed !== void 0)
|
|
1666
|
+
const next = { ...credits };
|
|
1667
|
+
if (updates.balance !== void 0) next.balance = updates.balance;
|
|
1668
|
+
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1669
|
+
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1670
|
+
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
1671
|
+
if (updates.monthlyLimit !== void 0) next.monthlyLimit = updates.monthlyLimit;
|
|
1672
|
+
if (updates.monthlyUsed !== void 0) next.monthlyUsed = updates.monthlyUsed;
|
|
656
1673
|
if (updates.monthlyResetAt !== void 0) {
|
|
657
|
-
|
|
1674
|
+
next.monthlyResetAt = isoOf(updates.monthlyResetAt);
|
|
658
1675
|
}
|
|
659
1676
|
if (updates.subscriptionExpiresAt !== void 0) {
|
|
660
|
-
|
|
661
|
-
credits.subscriptionExpiresAt = null;
|
|
662
|
-
} else {
|
|
663
|
-
credits.subscriptionExpiresAt = updates.subscriptionExpiresAt instanceof Date ? updates.subscriptionExpiresAt.toISOString() : updates.subscriptionExpiresAt;
|
|
664
|
-
}
|
|
1677
|
+
next.subscriptionExpiresAt = updates.subscriptionExpiresAt === null ? null : isoOf(updates.subscriptionExpiresAt);
|
|
665
1678
|
}
|
|
666
1679
|
if (updates.balanceIncrement !== void 0) {
|
|
667
|
-
|
|
1680
|
+
next.balance = sumAmounts(next.balance, updates.balanceIncrement);
|
|
668
1681
|
}
|
|
669
1682
|
if (updates.bonusCreditsIncrement !== void 0) {
|
|
670
|
-
|
|
1683
|
+
next.bonusCredits = sumAmounts(next.bonusCredits, updates.bonusCreditsIncrement);
|
|
671
1684
|
}
|
|
672
1685
|
if (updates.reservedIncrement !== void 0) {
|
|
673
|
-
|
|
1686
|
+
next.reserved = sumAmounts(next.reserved, updates.reservedIncrement);
|
|
674
1687
|
}
|
|
675
1688
|
if (updates.monthlyUsedIncrement !== void 0) {
|
|
676
|
-
|
|
1689
|
+
next.monthlyUsed = sumAmounts(next.monthlyUsed, updates.monthlyUsedIncrement);
|
|
677
1690
|
}
|
|
678
|
-
|
|
679
|
-
|
|
1691
|
+
assertRepresentableFields(
|
|
1692
|
+
{
|
|
1693
|
+
balance: next.balance,
|
|
1694
|
+
bonusCredits: next.bonusCredits,
|
|
1695
|
+
reserved: next.reserved,
|
|
1696
|
+
monthlyUsed: next.monthlyUsed
|
|
1697
|
+
},
|
|
1698
|
+
{ userId, operation: "updateUserCredits" }
|
|
1699
|
+
);
|
|
1700
|
+
assertRepresentableTierAmount(next.monthlyLimit, "monthlyLimit", {
|
|
1701
|
+
userId,
|
|
1702
|
+
operation: "updateUserCredits"
|
|
1703
|
+
});
|
|
1704
|
+
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1705
|
+
this.store.users.set(userId, next);
|
|
680
1706
|
}
|
|
681
1707
|
async updateUserTier(userId, input) {
|
|
682
|
-
|
|
1708
|
+
assertRepresentableFields(
|
|
1709
|
+
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1710
|
+
{ userId, operation: "updateUserTier" }
|
|
1711
|
+
);
|
|
1712
|
+
const credits = this.store.users.get(userId);
|
|
683
1713
|
if (!credits) {
|
|
684
1714
|
throw new Error(`User ${userId} not found`);
|
|
685
1715
|
}
|
|
686
1716
|
credits.tier = input.tier;
|
|
687
1717
|
credits.monthlyLimit = input.monthlyLimit;
|
|
688
|
-
if (input.balance !== void 0)
|
|
1718
|
+
if (input.balance !== void 0) {
|
|
1719
|
+
credits.balance = Math.max(
|
|
1720
|
+
input.balance,
|
|
1721
|
+
backedBalanceFloor(credits.reserved, credits.bonusCredits)
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
689
1724
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
690
1725
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
691
1726
|
if (input.subscriptionExpiresAt === null) {
|
|
@@ -695,10 +1730,12 @@ var InMemoryCreditRepository = class {
|
|
|
695
1730
|
}
|
|
696
1731
|
}
|
|
697
1732
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
698
|
-
this.users.set(userId, credits);
|
|
1733
|
+
this.store.users.set(userId, credits);
|
|
699
1734
|
}
|
|
700
1735
|
// ==================== Reservations ====================
|
|
701
1736
|
async createReservation(input) {
|
|
1737
|
+
assertValidCreditAmount(input.amount, { userId: input.userId, operation: "createReservation" });
|
|
1738
|
+
assertUnkeyedDirectReservation(input);
|
|
702
1739
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
703
1740
|
const reservation = {
|
|
704
1741
|
id: generateId(),
|
|
@@ -709,19 +1746,19 @@ var InMemoryCreditRepository = class {
|
|
|
709
1746
|
createdAt: now,
|
|
710
1747
|
expiresAt: input.expiresAt.toISOString()
|
|
711
1748
|
};
|
|
712
|
-
if (!this.reservations.has(input.userId)) {
|
|
713
|
-
this.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
1749
|
+
if (!this.store.reservations.has(input.userId)) {
|
|
1750
|
+
this.store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
714
1751
|
}
|
|
715
|
-
this.reservations.get(input.userId).set(reservation.id, reservation);
|
|
716
|
-
return reservation;
|
|
1752
|
+
this.store.reservations.get(input.userId).set(reservation.id, reservation);
|
|
1753
|
+
return copyRecord(reservation);
|
|
717
1754
|
}
|
|
718
1755
|
async getReservation(userId, reservationId) {
|
|
719
|
-
const userReservations = this.reservations.get(userId);
|
|
1756
|
+
const userReservations = this.store.reservations.get(userId);
|
|
720
1757
|
if (!userReservations) return null;
|
|
721
|
-
return userReservations.get(reservationId) ?? null;
|
|
1758
|
+
return copyRecord(userReservations.get(reservationId)) ?? null;
|
|
722
1759
|
}
|
|
723
1760
|
async updateReservationStatus(userId, reservationId, status, completedAt) {
|
|
724
|
-
const userReservations = this.reservations.get(userId);
|
|
1761
|
+
const userReservations = this.store.reservations.get(userId);
|
|
725
1762
|
if (!userReservations) {
|
|
726
1763
|
throw new Error(`No reservations found for user ${userId}`);
|
|
727
1764
|
}
|
|
@@ -729,119 +1766,219 @@ var InMemoryCreditRepository = class {
|
|
|
729
1766
|
if (!reservation) {
|
|
730
1767
|
throw new Error(`Reservation ${reservationId} not found`);
|
|
731
1768
|
}
|
|
1769
|
+
assertDirectStatusWriteAllowed(reservation, status);
|
|
732
1770
|
reservation.status = status;
|
|
733
1771
|
if (completedAt) reservation.completedAt = completedAt.toISOString();
|
|
734
1772
|
userReservations.set(reservationId, reservation);
|
|
735
1773
|
}
|
|
736
1774
|
// ==================== Atomic Operations ====================
|
|
1775
|
+
// ==================== V2 boundary ====================
|
|
1776
|
+
//
|
|
1777
|
+
// As in the Drizzle adapter, V2 is the real implementation and the legacy
|
|
1778
|
+
// `*Atomic` methods are adapters over it, so both APIs share one set of
|
|
1779
|
+
// guarantees.
|
|
1780
|
+
async reserveCreditsV2(input) {
|
|
1781
|
+
return reserveCreditsV2(this.store, input);
|
|
1782
|
+
}
|
|
1783
|
+
async commitReservationV2(userId, reservationId, options) {
|
|
1784
|
+
return commitReservationV2(this.store, userId, reservationId, options);
|
|
1785
|
+
}
|
|
1786
|
+
async releaseReservationV2(userId, reservationId, options) {
|
|
1787
|
+
return releaseReservationV2(this.store, userId, reservationId, options);
|
|
1788
|
+
}
|
|
1789
|
+
async expireReservationV2(userId, reservationId, options) {
|
|
1790
|
+
return expireReservationV2(this.store, userId, reservationId, options);
|
|
1791
|
+
}
|
|
1792
|
+
// ==================== Legacy atomic operations ====================
|
|
737
1793
|
async reserveCreditsAtomic(userId, amount, operationType, expiresAt) {
|
|
738
|
-
const
|
|
739
|
-
if (
|
|
740
|
-
|
|
1794
|
+
const outcome = await this.reserveCreditsV2({ userId, amount, operationType, expiresAt });
|
|
1795
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
1796
|
+
return outcome.reservation;
|
|
741
1797
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
throw new Error(
|
|
745
|
-
`Insufficient credits. Available: ${available}, Required: ${amount}`
|
|
746
|
-
);
|
|
1798
|
+
if (outcome.outcome === "insufficient") {
|
|
1799
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
747
1800
|
}
|
|
748
|
-
|
|
749
|
-
userId,
|
|
750
|
-
amount,
|
|
751
|
-
operationType,
|
|
752
|
-
expiresAt
|
|
753
|
-
});
|
|
754
|
-
credits.reserved += amount;
|
|
755
|
-
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
756
|
-
this.users.set(userId, credits);
|
|
757
|
-
return reservation;
|
|
1801
|
+
throw createReservationAlreadyProcessedError(outcome.existing.id, outcome.existing.status);
|
|
758
1802
|
}
|
|
759
1803
|
async commitReservationAtomic(userId, reservationId) {
|
|
760
|
-
const
|
|
761
|
-
if (
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if (!reservation) {
|
|
766
|
-
throw new Error(`Reservation ${reservationId} not found`);
|
|
767
|
-
}
|
|
768
|
-
if (reservation.status === "committed") {
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
if (reservation.status !== "reserved") {
|
|
772
|
-
throw new Error(
|
|
773
|
-
`Cannot commit reservation in ${reservation.status} state`
|
|
774
|
-
);
|
|
775
|
-
}
|
|
776
|
-
const amount = reservation.amount;
|
|
777
|
-
credits.balance -= amount;
|
|
778
|
-
credits.reserved -= amount;
|
|
779
|
-
credits.monthlyUsed += amount;
|
|
780
|
-
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
781
|
-
this.users.set(userId, credits);
|
|
782
|
-
await this.updateReservationStatus(userId, reservationId, "committed", /* @__PURE__ */ new Date());
|
|
1804
|
+
const outcome = await this.commitReservationV2(userId, reservationId);
|
|
1805
|
+
if (outcome.outcome === "committed") return;
|
|
1806
|
+
if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
|
|
1807
|
+
if (outcome.terminalStatus === "committed") return;
|
|
1808
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
783
1809
|
}
|
|
784
1810
|
async releaseReservationAtomic(userId, reservationId) {
|
|
785
|
-
const
|
|
786
|
-
if (
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1811
|
+
const outcome = await this.releaseReservationV2(userId, reservationId);
|
|
1812
|
+
if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* The stored transaction carrying this reference, in *any* user's ledger.
|
|
1816
|
+
*
|
|
1817
|
+
* Deliberately not scoped to one user. The SQL adapter's unique index covers
|
|
1818
|
+
* `payment_ref` alone, so searching only the crediting user's own history made
|
|
1819
|
+
* the same reference credit two different accounts here while the SQL adapter
|
|
1820
|
+
* treated the second delivery as a duplicate. See `core/payment-ref.ts`.
|
|
1821
|
+
*/
|
|
1822
|
+
findByPaymentRef(paymentRef) {
|
|
1823
|
+
for (const recorded of this.store.transactions.values()) {
|
|
1824
|
+
const hit = recorded.find((transaction) => transaction.paymentRef === paymentRef);
|
|
1825
|
+
if (hit) return hit;
|
|
795
1826
|
}
|
|
796
|
-
|
|
797
|
-
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
798
|
-
this.users.set(userId, credits);
|
|
799
|
-
await this.updateReservationStatus(userId, reservationId, "released", /* @__PURE__ */ new Date());
|
|
1827
|
+
return void 0;
|
|
800
1828
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1829
|
+
/** The journal entry written alongside a stored purchase, if there is one. */
|
|
1830
|
+
journalForTransaction(transaction) {
|
|
1831
|
+
return this.store.journalEntries.get(transaction.userId)?.find((entry) => entry.referenceId === transaction.id);
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Credit an account, resolving `paymentRef` to created / replayed / conflict.
|
|
1835
|
+
*
|
|
1836
|
+
* The reference is checked and the transaction is recorded with no `await`
|
|
1837
|
+
* between them. `createTransaction` is `async` but its body is synchronous, so
|
|
1838
|
+
* under JavaScript's run-to-completion semantics no other caller can observe
|
|
1839
|
+
* the gap — which is what stands in for the SQL adapter's unique index.
|
|
1840
|
+
*
|
|
1841
|
+
* A `replayed` or `conflict` outcome writes nothing at all: the check runs
|
|
1842
|
+
* before the balance moves, not after.
|
|
1843
|
+
*/
|
|
1844
|
+
async addCreditsV2(input) {
|
|
1845
|
+
const { userId, amount, description, options } = input;
|
|
1846
|
+
assertValidCreditAmount(amount, { userId, operation: "addCredits" });
|
|
1847
|
+
const paymentRef = normalizePaymentRef(input.paymentRef);
|
|
1848
|
+
const payload = {
|
|
1849
|
+
userId,
|
|
1850
|
+
amount,
|
|
1851
|
+
type: "purchase",
|
|
1852
|
+
source: options?.source ?? "purchase",
|
|
1853
|
+
referenceType: options?.referenceType ?? "transaction"
|
|
1854
|
+
};
|
|
1855
|
+
if (paymentRef) {
|
|
1856
|
+
const existing = this.findByPaymentRef(paymentRef);
|
|
1857
|
+
if (existing) {
|
|
1858
|
+
const journal = this.journalForTransaction(existing);
|
|
1859
|
+
const mismatch = describePaymentMismatch(
|
|
1860
|
+
{
|
|
1861
|
+
userId: existing.userId,
|
|
1862
|
+
amount: existing.amount,
|
|
1863
|
+
type: existing.type,
|
|
1864
|
+
source: journal?.source,
|
|
1865
|
+
referenceType: journal?.referenceType
|
|
1866
|
+
},
|
|
1867
|
+
payload
|
|
1868
|
+
);
|
|
1869
|
+
return mismatch === null ? { outcome: "replayed", paymentRef, transaction: copyRecord(existing) } : { outcome: "conflict", paymentRef, existing: copyRecord(existing), mismatch };
|
|
1870
|
+
}
|
|
805
1871
|
}
|
|
806
|
-
|
|
807
|
-
|
|
1872
|
+
if (!this.store.users.has(userId)) {
|
|
1873
|
+
const tier = getDefaultTier();
|
|
1874
|
+
this.seedUser(userId, tier, storedMonthlyLimit(getConfigMonthlyLimit(tier)));
|
|
1875
|
+
}
|
|
1876
|
+
const credits = this.store.users.get(userId);
|
|
1877
|
+
const previousTotal = sumAmounts(credits.balance, credits.bonusCredits);
|
|
1878
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, amount);
|
|
1879
|
+
const newTotal = sumAmounts(credits.balance, nextBonusCredits);
|
|
1880
|
+
assertRepresentableFields(
|
|
1881
|
+
{ previousBalance: previousTotal, bonusCredits: nextBonusCredits, newBalance: newTotal },
|
|
1882
|
+
{ userId, operation: "addCredits" }
|
|
1883
|
+
);
|
|
1884
|
+
credits.bonusCredits = nextBonusCredits;
|
|
808
1885
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
809
|
-
this.users.set(userId, credits);
|
|
810
|
-
|
|
1886
|
+
this.store.users.set(userId, credits);
|
|
1887
|
+
const transaction = this.recordTransaction({
|
|
811
1888
|
userId,
|
|
812
1889
|
type: "purchase",
|
|
813
1890
|
amount,
|
|
814
1891
|
description,
|
|
815
1892
|
paymentRef,
|
|
816
|
-
previousBalance,
|
|
817
|
-
newBalance:
|
|
1893
|
+
previousBalance: previousTotal,
|
|
1894
|
+
newBalance: newTotal
|
|
1895
|
+
});
|
|
1896
|
+
const journalMetadata = {
|
|
1897
|
+
...paymentRef ? { paymentRef } : {},
|
|
1898
|
+
...options?.metadata ?? {}
|
|
1899
|
+
};
|
|
1900
|
+
const entry = await this.createJournalEntry({
|
|
1901
|
+
userId,
|
|
1902
|
+
entryType: "credit",
|
|
1903
|
+
amount,
|
|
1904
|
+
balanceAfter: newTotal,
|
|
1905
|
+
source: payload.source,
|
|
1906
|
+
referenceId: transaction.id ?? paymentRef ?? "unknown",
|
|
1907
|
+
referenceType: payload.referenceType,
|
|
1908
|
+
description,
|
|
1909
|
+
metadata: Object.keys(journalMetadata).length > 0 ? journalMetadata : void 0
|
|
818
1910
|
});
|
|
1911
|
+
return paymentRef ? { outcome: "created", paymentRef, transaction, journalEntryId: entry.id } : { outcome: "created", transaction, journalEntryId: entry.id };
|
|
819
1912
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1913
|
+
/**
|
|
1914
|
+
* The legacy signature, on top of {@link addCreditsV2}.
|
|
1915
|
+
*
|
|
1916
|
+
* `Promise<void>` has nowhere to report a conflict, and silently returning is
|
|
1917
|
+
* indistinguishable from "credited" — the exact failure this round is closing
|
|
1918
|
+
* — so a conflict throws. A genuine replay still returns quietly, because
|
|
1919
|
+
* that *is* the idempotent no-op the caller asked for.
|
|
1920
|
+
*/
|
|
1921
|
+
async addCreditsAtomic(userId, amount, description, paymentRef, options) {
|
|
1922
|
+
const outcome = await this.addCreditsV2({
|
|
1923
|
+
userId,
|
|
1924
|
+
amount,
|
|
1925
|
+
description,
|
|
1926
|
+
paymentRef,
|
|
1927
|
+
options
|
|
1928
|
+
});
|
|
1929
|
+
if (outcome.outcome === "conflict") {
|
|
1930
|
+
throw createPaymentRefConflictError(outcome.paymentRef, {
|
|
1931
|
+
userId,
|
|
1932
|
+
mismatch: outcome.mismatch,
|
|
1933
|
+
existingUserId: outcome.existing.userId
|
|
1934
|
+
});
|
|
823
1935
|
}
|
|
824
|
-
|
|
1936
|
+
}
|
|
1937
|
+
async deductCreditsAtomic(userId, amount) {
|
|
1938
|
+
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1939
|
+
const credits = this.store.users.get(userId);
|
|
825
1940
|
if (!credits) {
|
|
826
1941
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
827
1942
|
}
|
|
828
|
-
const available = credits.balance
|
|
1943
|
+
const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
|
|
829
1944
|
if (available < amount) {
|
|
830
1945
|
throw new Error(
|
|
831
1946
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
832
1947
|
);
|
|
833
1948
|
}
|
|
834
1949
|
const balanceDeduction = Math.min(credits.balance, amount);
|
|
835
|
-
const bonusDeduction = amount -
|
|
836
|
-
const previousBalance = credits.balance
|
|
837
|
-
credits.balance
|
|
838
|
-
credits.bonusCredits
|
|
1950
|
+
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
1951
|
+
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
1952
|
+
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
1953
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, -bonusDeduction);
|
|
1954
|
+
const newBalance = sumAmounts(previousBalance, -amount);
|
|
1955
|
+
assertRepresentableFields(
|
|
1956
|
+
{ previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
|
|
1957
|
+
{ userId, operation: "deductCredits" }
|
|
1958
|
+
);
|
|
1959
|
+
credits.balance = nextBalance;
|
|
1960
|
+
credits.bonusCredits = nextBonusCredits;
|
|
839
1961
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
840
|
-
this.users.set(userId, credits);
|
|
841
|
-
return { previousBalance, newBalance
|
|
1962
|
+
this.store.users.set(userId, credits);
|
|
1963
|
+
return { previousBalance, newBalance };
|
|
842
1964
|
}
|
|
843
1965
|
// ==================== Transactions ====================
|
|
844
1966
|
async createTransaction(input) {
|
|
1967
|
+
assertUnreferencedDirectTransaction(input);
|
|
1968
|
+
return this.recordTransaction({
|
|
1969
|
+
...input,
|
|
1970
|
+
paymentRef: normalizePaymentRef(input.paymentRef)
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
/** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
|
|
1974
|
+
recordTransaction(input) {
|
|
1975
|
+
assertRepresentableAmount(input.amount, "transaction amount", { userId: input.userId });
|
|
1976
|
+
assertRepresentableAmount(input.previousBalance, "transaction previousBalance", {
|
|
1977
|
+
userId: input.userId
|
|
1978
|
+
});
|
|
1979
|
+
assertRepresentableAmount(input.newBalance, "transaction newBalance", {
|
|
1980
|
+
userId: input.userId
|
|
1981
|
+
});
|
|
845
1982
|
const transaction = {
|
|
846
1983
|
id: generateId(),
|
|
847
1984
|
userId: input.userId,
|
|
@@ -853,23 +1990,24 @@ var InMemoryCreditRepository = class {
|
|
|
853
1990
|
newBalance: input.newBalance,
|
|
854
1991
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
855
1992
|
};
|
|
856
|
-
if (!this.transactions.has(input.userId)) {
|
|
857
|
-
this.transactions.set(input.userId, []);
|
|
1993
|
+
if (!this.store.transactions.has(input.userId)) {
|
|
1994
|
+
this.store.transactions.set(input.userId, []);
|
|
858
1995
|
}
|
|
859
|
-
this.transactions.get(input.userId).push(transaction);
|
|
860
|
-
return transaction;
|
|
1996
|
+
this.store.transactions.get(input.userId).push(transaction);
|
|
1997
|
+
return copyRecord(transaction);
|
|
861
1998
|
}
|
|
862
1999
|
async getTransactions(userId, limit = 50, offset = 0) {
|
|
863
|
-
const userTransactions = this.transactions.get(userId) ?? [];
|
|
2000
|
+
const userTransactions = this.store.transactions.get(userId) ?? [];
|
|
864
2001
|
const sorted = [...userTransactions].sort((a, b) => {
|
|
865
2002
|
const aDate = toDate2(a.createdAt).getTime();
|
|
866
2003
|
const bDate = toDate2(b.createdAt).getTime();
|
|
867
2004
|
return bDate - aDate;
|
|
868
2005
|
});
|
|
869
|
-
return sorted.slice(offset, offset + limit);
|
|
2006
|
+
return copyRecords(sorted.slice(offset, offset + limit));
|
|
870
2007
|
}
|
|
871
2008
|
// ==================== Usage Logs ====================
|
|
872
2009
|
async logUsage(input) {
|
|
2010
|
+
assertRepresentableAmount(input.creditsUsed, "creditsUsed", { userId: input.userId });
|
|
873
2011
|
const log = {
|
|
874
2012
|
id: generateId(),
|
|
875
2013
|
userId: input.userId,
|
|
@@ -881,14 +2019,14 @@ var InMemoryCreditRepository = class {
|
|
|
881
2019
|
resourceId: input.resourceId,
|
|
882
2020
|
resourceType: input.resourceType,
|
|
883
2021
|
requestId: input.requestId,
|
|
884
|
-
metadata: input.metadata,
|
|
2022
|
+
metadata: input.metadata ? { ...input.metadata } : input.metadata,
|
|
885
2023
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
886
2024
|
};
|
|
887
|
-
this.usageLogs.push(log);
|
|
888
|
-
return log;
|
|
2025
|
+
this.store.usageLogs.push(log);
|
|
2026
|
+
return copyRecord(log);
|
|
889
2027
|
}
|
|
890
2028
|
async getUsageLogs(query) {
|
|
891
|
-
let results = [...this.usageLogs];
|
|
2029
|
+
let results = [...this.store.usageLogs];
|
|
892
2030
|
if (query.userId) {
|
|
893
2031
|
results = results.filter((log) => log.userId === query.userId);
|
|
894
2032
|
}
|
|
@@ -915,7 +2053,7 @@ var InMemoryCreditRepository = class {
|
|
|
915
2053
|
});
|
|
916
2054
|
const offset = query.offset ?? 0;
|
|
917
2055
|
const limit = query.limit ?? 50;
|
|
918
|
-
return results.slice(offset, offset + limit);
|
|
2056
|
+
return copyRecords(results.slice(offset, offset + limit));
|
|
919
2057
|
}
|
|
920
2058
|
async getUsageLogsCount(query) {
|
|
921
2059
|
const results = await this.getUsageLogs({ ...query, limit: Infinity, offset: 0 });
|
|
@@ -923,6 +2061,29 @@ var InMemoryCreditRepository = class {
|
|
|
923
2061
|
}
|
|
924
2062
|
// ==================== Journal Entries ====================
|
|
925
2063
|
async createJournalEntry(input) {
|
|
2064
|
+
return this.recordJournalEntry(input);
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
|
|
2068
|
+
* write its journal line inside the same run-to-completion step as the
|
|
2069
|
+
* balance write — an `await` between the two is exactly the gap that loses
|
|
2070
|
+
* the line when the second half fails.
|
|
2071
|
+
*/
|
|
2072
|
+
recordJournalEntry(input) {
|
|
2073
|
+
assertRepresentableAmount(input.amount, "journal amount", { userId: input.userId });
|
|
2074
|
+
assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
|
|
2075
|
+
userId: input.userId
|
|
2076
|
+
});
|
|
2077
|
+
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
2078
|
+
assertPublicJournalKey(input.idempotencyKey, input.userId);
|
|
2079
|
+
const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
|
|
2080
|
+
if (key !== void 0 && this.store.journalKeys.has(key)) {
|
|
2081
|
+
throw new CreditError(
|
|
2082
|
+
`Journal idempotency key ${input.idempotencyKey} is already used for user ${input.userId}. The unique constraint refuses a second entry.`,
|
|
2083
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
2084
|
+
{ userId: input.userId, idempotencyKey: input.idempotencyKey }
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
926
2087
|
const entry = {
|
|
927
2088
|
id: generateId(),
|
|
928
2089
|
userId: input.userId,
|
|
@@ -933,17 +2094,19 @@ var InMemoryCreditRepository = class {
|
|
|
933
2094
|
referenceId: input.referenceId,
|
|
934
2095
|
referenceType: input.referenceType,
|
|
935
2096
|
description: input.description,
|
|
936
|
-
metadata: input.metadata,
|
|
2097
|
+
metadata: input.metadata ? { ...input.metadata } : input.metadata,
|
|
2098
|
+
idempotencyKey: input.idempotencyKey,
|
|
937
2099
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
938
2100
|
};
|
|
939
|
-
if (!this.journalEntries.has(input.userId)) {
|
|
940
|
-
this.journalEntries.set(input.userId, []);
|
|
2101
|
+
if (!this.store.journalEntries.has(input.userId)) {
|
|
2102
|
+
this.store.journalEntries.set(input.userId, []);
|
|
941
2103
|
}
|
|
942
|
-
this.journalEntries.get(input.userId).push(entry);
|
|
943
|
-
|
|
2104
|
+
this.store.journalEntries.get(input.userId).push(entry);
|
|
2105
|
+
if (key !== void 0) this.store.journalKeys.set(key, entry.id);
|
|
2106
|
+
return copyRecord(entry);
|
|
944
2107
|
}
|
|
945
2108
|
async getJournalEntries(query) {
|
|
946
|
-
let results = this.journalEntries.get(query.userId) ?? [];
|
|
2109
|
+
let results = this.store.journalEntries.get(query.userId) ?? [];
|
|
947
2110
|
if (query.source) {
|
|
948
2111
|
results = results.filter((entry) => entry.source === query.source);
|
|
949
2112
|
}
|
|
@@ -967,7 +2130,7 @@ var InMemoryCreditRepository = class {
|
|
|
967
2130
|
});
|
|
968
2131
|
const offset = query.offset ?? 0;
|
|
969
2132
|
const limit = query.limit ?? 50;
|
|
970
|
-
return results.slice(offset, offset + limit);
|
|
2133
|
+
return copyRecords(results.slice(offset, offset + limit));
|
|
971
2134
|
}
|
|
972
2135
|
async getJournalEntriesCount(query) {
|
|
973
2136
|
const results = await this.getJournalEntries({
|
|
@@ -978,53 +2141,91 @@ var InMemoryCreditRepository = class {
|
|
|
978
2141
|
return results.length;
|
|
979
2142
|
}
|
|
980
2143
|
// ==================== Cleanup Operations ====================
|
|
2144
|
+
/**
|
|
2145
|
+
* Expire every hold whose deadline has passed.
|
|
2146
|
+
*
|
|
2147
|
+
* Each candidate goes through the guarded {@link expireReservationV2}, which
|
|
2148
|
+
* re-checks the status and the deadline under the user's lock. The previous
|
|
2149
|
+
* implementation released the hold and then overwrote the status to
|
|
2150
|
+
* `expired`, so a commit landing in between got its credits handed back and
|
|
2151
|
+
* was then relabelled.
|
|
2152
|
+
*/
|
|
981
2153
|
async findAndExpireReservations(_batchSize = 100, _maxIterations = 100) {
|
|
982
|
-
const
|
|
2154
|
+
const asOf = /* @__PURE__ */ new Date();
|
|
983
2155
|
let expiredCount = 0;
|
|
984
2156
|
let creditsReleased = 0;
|
|
985
2157
|
const errors = [];
|
|
986
|
-
|
|
2158
|
+
const candidates = [];
|
|
2159
|
+
for (const [userId, userReservations] of this.store.reservations) {
|
|
987
2160
|
for (const [reservationId, reservation] of userReservations) {
|
|
988
|
-
if (reservation.status === "reserved" && toDate2(reservation.expiresAt).getTime() <
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
2161
|
+
if (reservation.status === "reserved" && toDate2(reservation.expiresAt).getTime() < asOf.getTime()) {
|
|
2162
|
+
candidates.push({ userId, reservationId });
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
for (const candidate of candidates) {
|
|
2167
|
+
try {
|
|
2168
|
+
const outcome = await this.expireReservationV2(
|
|
2169
|
+
candidate.userId,
|
|
2170
|
+
candidate.reservationId,
|
|
2171
|
+
{ asOf }
|
|
2172
|
+
);
|
|
2173
|
+
if (outcome.outcome === "expired") {
|
|
2174
|
+
expiredCount++;
|
|
2175
|
+
creditsReleased = sumAmounts(creditsReleased, outcome.amount);
|
|
1000
2176
|
}
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
errors.push(
|
|
2179
|
+
`Failed to expire reservation ${candidate.reservationId}: ${error}`
|
|
2180
|
+
);
|
|
1001
2181
|
}
|
|
1002
2182
|
}
|
|
1003
2183
|
return { expiredCount, creditsReleased, errors };
|
|
1004
2184
|
}
|
|
1005
2185
|
// ==================== Atomic Monthly Reset ====================
|
|
1006
2186
|
async atomicMonthlyReset(userId, tier, expectedResetAt) {
|
|
1007
|
-
const credits = this.users.get(userId);
|
|
2187
|
+
const credits = this.store.users.get(userId);
|
|
1008
2188
|
if (!credits) {
|
|
1009
2189
|
throw new Error(`User ${userId} not found`);
|
|
1010
2190
|
}
|
|
1011
2191
|
const currentResetAt = toDate2(credits.monthlyResetAt).getTime();
|
|
1012
2192
|
const expected = toDate2(expectedResetAt).getTime();
|
|
1013
2193
|
if (currentResetAt !== expected) {
|
|
1014
|
-
return { wasReset: false, credits };
|
|
2194
|
+
return { wasReset: false, credits: { ...credits } };
|
|
1015
2195
|
}
|
|
1016
|
-
|
|
2196
|
+
assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
|
|
1017
2197
|
const nextReset = getNextMonthlyReset();
|
|
1018
|
-
|
|
2198
|
+
const target = monthlyResetBalance(tier);
|
|
2199
|
+
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
2200
|
+
const previousBalance = credits.balance;
|
|
2201
|
+
const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
|
|
2202
|
+
const change = sumAmounts(newBalance, -previousBalance);
|
|
2203
|
+
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2204
|
+
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
2205
|
+
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2206
|
+
credits.balance = newBalance;
|
|
1019
2207
|
credits.monthlyUsed = 0;
|
|
1020
2208
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
1021
2209
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1022
|
-
this.users.set(userId, credits);
|
|
1023
|
-
|
|
2210
|
+
this.store.users.set(userId, credits);
|
|
2211
|
+
if (change !== 0) {
|
|
2212
|
+
this.recordJournalEntry({
|
|
2213
|
+
userId,
|
|
2214
|
+
entryType: change > 0 ? "credit" : "debit",
|
|
2215
|
+
amount: Math.abs(change),
|
|
2216
|
+
balanceAfter,
|
|
2217
|
+
source: "monthly_reset",
|
|
2218
|
+
referenceId: `reset-${Date.now()}`,
|
|
2219
|
+
referenceType: "reset",
|
|
2220
|
+
description: `Monthly credit reset for ${tier} tier.`,
|
|
2221
|
+
metadata: { tier, previousBalance, newBalance }
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
return { wasReset: true, credits: { ...credits }, journaled: true };
|
|
1024
2225
|
}
|
|
1025
2226
|
// ==================== Subscription Expiry ====================
|
|
1026
2227
|
async checkAndHandleSubscriptionExpiry(userId, gracePeriodDays = 3) {
|
|
1027
|
-
const credits = this.users.get(userId);
|
|
2228
|
+
const credits = this.store.users.get(userId);
|
|
1028
2229
|
if (!credits) {
|
|
1029
2230
|
throw new Error(`User ${userId} not found`);
|
|
1030
2231
|
}
|
|
@@ -1033,7 +2234,7 @@ var InMemoryCreditRepository = class {
|
|
|
1033
2234
|
wasDowngraded: false,
|
|
1034
2235
|
inGracePeriod: false,
|
|
1035
2236
|
graceDaysRemaining: 0,
|
|
1036
|
-
credits
|
|
2237
|
+
credits: { ...credits }
|
|
1037
2238
|
};
|
|
1038
2239
|
}
|
|
1039
2240
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -1044,7 +2245,7 @@ var InMemoryCreditRepository = class {
|
|
|
1044
2245
|
wasDowngraded: false,
|
|
1045
2246
|
inGracePeriod: false,
|
|
1046
2247
|
graceDaysRemaining: 0,
|
|
1047
|
-
credits
|
|
2248
|
+
credits: { ...credits }
|
|
1048
2249
|
};
|
|
1049
2250
|
}
|
|
1050
2251
|
if (daysSinceExpiry <= gracePeriodDays) {
|
|
@@ -1052,22 +2253,48 @@ var InMemoryCreditRepository = class {
|
|
|
1052
2253
|
wasDowngraded: false,
|
|
1053
2254
|
inGracePeriod: true,
|
|
1054
2255
|
graceDaysRemaining: Math.ceil(gracePeriodDays - daysSinceExpiry),
|
|
1055
|
-
credits
|
|
2256
|
+
credits: { ...credits }
|
|
1056
2257
|
};
|
|
1057
2258
|
}
|
|
1058
2259
|
const defaultTier = getDefaultTier();
|
|
1059
|
-
const
|
|
2260
|
+
const configured = getConfigMonthlyLimit(defaultTier);
|
|
2261
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", {
|
|
2262
|
+
userId,
|
|
2263
|
+
tier: defaultTier
|
|
2264
|
+
});
|
|
2265
|
+
const limit = storedMonthlyLimit(configured);
|
|
2266
|
+
const previousTier = credits.tier;
|
|
2267
|
+
const previousBalance = credits.balance;
|
|
2268
|
+
const newBalance = Math.max(
|
|
2269
|
+
Math.min(credits.balance, limit),
|
|
2270
|
+
backedBalanceFloor(credits.reserved, credits.bonusCredits)
|
|
2271
|
+
);
|
|
2272
|
+
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2273
|
+
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
1060
2274
|
credits.tier = defaultTier;
|
|
1061
|
-
credits.monthlyLimit =
|
|
1062
|
-
credits.balance =
|
|
2275
|
+
credits.monthlyLimit = limit;
|
|
2276
|
+
credits.balance = newBalance;
|
|
1063
2277
|
credits.subscriptionExpiresAt = null;
|
|
1064
2278
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1065
|
-
this.users.set(userId, credits);
|
|
2279
|
+
this.store.users.set(userId, credits);
|
|
2280
|
+
this.recordJournalEntry({
|
|
2281
|
+
userId,
|
|
2282
|
+
entryType: "debit",
|
|
2283
|
+
amount: 0,
|
|
2284
|
+
// No credits deducted, just tier change
|
|
2285
|
+
balanceAfter,
|
|
2286
|
+
source: "subscription_downgrade",
|
|
2287
|
+
referenceId: `downgrade-${Date.now()}`,
|
|
2288
|
+
referenceType: "subscription",
|
|
2289
|
+
description: `Subscription expired. Downgraded from ${previousTier} to ${defaultTier} tier.`,
|
|
2290
|
+
metadata: { previousTier, previousBalance, newBalance }
|
|
2291
|
+
});
|
|
1066
2292
|
return {
|
|
1067
2293
|
wasDowngraded: true,
|
|
1068
2294
|
inGracePeriod: false,
|
|
1069
2295
|
graceDaysRemaining: 0,
|
|
1070
|
-
credits
|
|
2296
|
+
credits: { ...credits },
|
|
2297
|
+
journaled: true
|
|
1071
2298
|
};
|
|
1072
2299
|
}
|
|
1073
2300
|
// ==================== Testing Utilities ====================
|
|
@@ -1075,25 +2302,33 @@ var InMemoryCreditRepository = class {
|
|
|
1075
2302
|
* Clear all data (useful for testing)
|
|
1076
2303
|
*/
|
|
1077
2304
|
clear() {
|
|
1078
|
-
this.
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
2305
|
+
this.store.clear();
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Install a yield point inside every V2 critical section (testing only).
|
|
2309
|
+
*
|
|
2310
|
+
* Unset, the critical sections run start-to-finish synchronously, so
|
|
2311
|
+
* concurrent callers never interleave and a concurrency test would pass even
|
|
2312
|
+
* with the locking removed. Setting this to a real yield (a macrotask or a
|
|
2313
|
+
* barrier) makes callers genuinely overlap, so the tests exercise the lock
|
|
2314
|
+
* instead of the event loop. Never call this in production code.
|
|
2315
|
+
*/
|
|
2316
|
+
setSchedulingHook(hook) {
|
|
2317
|
+
this.store.schedulingHook = hook;
|
|
1083
2318
|
}
|
|
1084
2319
|
/**
|
|
1085
2320
|
* Get all users (useful for testing/debugging)
|
|
1086
2321
|
*/
|
|
1087
2322
|
getAllUsers() {
|
|
1088
|
-
return Array.from(this.users.values());
|
|
2323
|
+
return copyRecords(Array.from(this.store.users.values()));
|
|
1089
2324
|
}
|
|
1090
2325
|
/**
|
|
1091
2326
|
* Get all reservations for a user (useful for testing)
|
|
1092
2327
|
*/
|
|
1093
2328
|
getAllReservations(userId) {
|
|
1094
|
-
const userReservations = this.reservations.get(userId);
|
|
2329
|
+
const userReservations = this.store.reservations.get(userId);
|
|
1095
2330
|
if (!userReservations) return [];
|
|
1096
|
-
return Array.from(userReservations.values());
|
|
2331
|
+
return copyRecords(Array.from(userReservations.values()));
|
|
1097
2332
|
}
|
|
1098
2333
|
};
|
|
1099
2334
|
function createInMemoryCreditRepository() {
|
|
@@ -1204,28 +2439,38 @@ var CreditsService = class {
|
|
|
1204
2439
|
userId,
|
|
1205
2440
|
getConfig().subscriptionGracePeriodDays
|
|
1206
2441
|
);
|
|
1207
|
-
if (expiryResult.wasDowngraded) {
|
|
2442
|
+
if (expiryResult.wasDowngraded && !expiryResult.journaled) {
|
|
1208
2443
|
await this.repository.createJournalEntry({
|
|
1209
2444
|
userId,
|
|
1210
2445
|
entryType: "debit",
|
|
1211
2446
|
amount: 0,
|
|
1212
2447
|
// No credits deducted, just tier change
|
|
1213
|
-
|
|
2448
|
+
// The ledger balance, matching every other journal writer: the V2
|
|
2449
|
+
// transitions and add/deduct all record `balance + bonusCredits`, so
|
|
2450
|
+
// recording `balance` alone made the audit trail disagree with itself
|
|
2451
|
+
// for any user holding bonus credits.
|
|
2452
|
+
balanceAfter: sumAmounts(
|
|
2453
|
+
expiryResult.credits.balance,
|
|
2454
|
+
expiryResult.credits.bonusCredits
|
|
2455
|
+
),
|
|
1214
2456
|
source: "subscription_downgrade",
|
|
1215
2457
|
referenceId: `downgrade-${Date.now()}`,
|
|
1216
2458
|
referenceType: "subscription",
|
|
1217
|
-
|
|
2459
|
+
// The tier actually landed on, not a hard-coded "free": the downgrade
|
|
2460
|
+
// target is configurable, so naming `free` here could describe a
|
|
2461
|
+
// transition that never happened.
|
|
2462
|
+
description: `Subscription expired. Downgraded from ${data.tier} to ${expiryResult.credits.tier} tier.`,
|
|
1218
2463
|
metadata: {
|
|
1219
2464
|
previousTier: data.tier,
|
|
1220
2465
|
previousBalance: data.balance,
|
|
1221
2466
|
newBalance: expiryResult.credits.balance
|
|
1222
2467
|
}
|
|
1223
2468
|
});
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
}
|
|
2469
|
+
}
|
|
2470
|
+
if (expiryResult.wasDowngraded && this.subscriptionExpiredCallback) {
|
|
2471
|
+
this.subscriptionExpiredCallback(userId, true).catch((error) => {
|
|
2472
|
+
console.error("[Credits] Failed to send subscription expired notification:", error);
|
|
2473
|
+
});
|
|
1229
2474
|
}
|
|
1230
2475
|
data = expiryResult.credits;
|
|
1231
2476
|
}
|
|
@@ -1236,14 +2481,17 @@ var CreditsService = class {
|
|
|
1236
2481
|
data.tier,
|
|
1237
2482
|
expectedResetAt
|
|
1238
2483
|
);
|
|
1239
|
-
if (resetResult.wasReset) {
|
|
1240
|
-
const balanceChange = resetResult.credits.balance -
|
|
2484
|
+
if (resetResult.wasReset && !resetResult.journaled) {
|
|
2485
|
+
const balanceChange = sumAmounts(resetResult.credits.balance, -data.balance);
|
|
1241
2486
|
if (balanceChange !== 0) {
|
|
1242
2487
|
await this.repository.createJournalEntry({
|
|
1243
2488
|
userId,
|
|
1244
2489
|
entryType: balanceChange > 0 ? "credit" : "debit",
|
|
1245
2490
|
amount: Math.abs(balanceChange),
|
|
1246
|
-
balanceAfter:
|
|
2491
|
+
balanceAfter: sumAmounts(
|
|
2492
|
+
resetResult.credits.balance,
|
|
2493
|
+
resetResult.credits.bonusCredits
|
|
2494
|
+
),
|
|
1247
2495
|
source: "monthly_reset",
|
|
1248
2496
|
referenceId: `reset-${Date.now()}`,
|
|
1249
2497
|
referenceType: "reset",
|
|
@@ -1294,14 +2542,14 @@ var CreditsService = class {
|
|
|
1294
2542
|
*/
|
|
1295
2543
|
async checkCredits(userId, requiredCredits) {
|
|
1296
2544
|
const credits = await this.getOrCreateUserCredits(userId);
|
|
1297
|
-
const totalBalance = credits.balance
|
|
1298
|
-
const available = totalBalance -
|
|
2545
|
+
const totalBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
2546
|
+
const available = sumAmounts(totalBalance, -credits.reserved);
|
|
1299
2547
|
const hasCredits = available >= requiredCredits;
|
|
1300
2548
|
return {
|
|
1301
2549
|
hasCredits,
|
|
1302
2550
|
balance: totalBalance,
|
|
1303
2551
|
required: requiredCredits,
|
|
1304
|
-
shortfall: hasCredits ? 0 : requiredCredits -
|
|
2552
|
+
shortfall: hasCredits ? 0 : sumAmounts(requiredCredits, -available)
|
|
1305
2553
|
};
|
|
1306
2554
|
}
|
|
1307
2555
|
/**
|
|
@@ -1315,74 +2563,110 @@ var CreditsService = class {
|
|
|
1315
2563
|
* @throws Error if insufficient credits
|
|
1316
2564
|
*/
|
|
1317
2565
|
async reserveCredits(userId, amount, operationType, options) {
|
|
2566
|
+
const outcome = await this.reserveCreditsDetailed(userId, amount, operationType, options);
|
|
2567
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
2568
|
+
return outcome.reservation;
|
|
2569
|
+
}
|
|
2570
|
+
if (outcome.outcome === "insufficient") {
|
|
2571
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
2572
|
+
}
|
|
2573
|
+
throw createIdempotencyConflictError(outcome.idempotencyKey, {
|
|
2574
|
+
userId,
|
|
2575
|
+
requested: { amount, operationType },
|
|
2576
|
+
existing: {
|
|
2577
|
+
amount: outcome.existing.amount,
|
|
2578
|
+
operationType: outcome.existing.operationType
|
|
2579
|
+
}
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* Reserve credits and get the typed outcome instead of an exception.
|
|
2584
|
+
*
|
|
2585
|
+
* Prefer this over {@link reserveCredits} in async callers: it distinguishes
|
|
2586
|
+
* a fresh hold from an idempotent replay, and reports insufficient funds as
|
|
2587
|
+
* a value rather than as control flow.
|
|
2588
|
+
*/
|
|
2589
|
+
async reserveCreditsDetailed(userId, amount, operationType, options) {
|
|
1318
2590
|
const ttlMs = options?.ttlMs ?? getConfig().reservationExpiryMs;
|
|
1319
2591
|
const expiresAt = new Date(Date.now() + ttlMs);
|
|
1320
|
-
return this.repository
|
|
2592
|
+
return reserveThroughRepository(this.repository, {
|
|
2593
|
+
userId,
|
|
2594
|
+
amount,
|
|
2595
|
+
operationType,
|
|
2596
|
+
expiresAt,
|
|
2597
|
+
idempotencyKey: options?.idempotencyKey
|
|
2598
|
+
});
|
|
1321
2599
|
}
|
|
1322
2600
|
/**
|
|
1323
|
-
* Commit a reservation (phase 2 of two-phase commit - success)
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
2601
|
+
* Commit a reservation (phase 2 of two-phase commit - success).
|
|
2602
|
+
*
|
|
2603
|
+
* Idempotent: re-delivering a commit for an already-committed reservation is
|
|
2604
|
+
* a no-op. Committing one that was released or expired throws, because the
|
|
2605
|
+
* credits are no longer held and pretending otherwise would hide a real bug.
|
|
1326
2606
|
*/
|
|
1327
|
-
async commitCredits(userId, reservationId) {
|
|
1328
|
-
const
|
|
1329
|
-
if (
|
|
1330
|
-
throw
|
|
2607
|
+
async commitCredits(userId, reservationId, options) {
|
|
2608
|
+
const outcome = await this.commitCreditsDetailed(userId, reservationId, options);
|
|
2609
|
+
if (outcome.outcome === "not_found") {
|
|
2610
|
+
throw createReservationNotFoundError(reservationId);
|
|
1331
2611
|
}
|
|
1332
|
-
if (
|
|
1333
|
-
|
|
2612
|
+
if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
|
|
2613
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
1334
2614
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* Commit a reservation and get the typed outcome.
|
|
2618
|
+
*
|
|
2619
|
+
* Exactly one concurrent caller sees `committed`; that call moved the balance
|
|
2620
|
+
* and wrote the single journal entry. On a V2 repository the journal is
|
|
2621
|
+
* written inside the same transaction, so this method adds none of its own —
|
|
2622
|
+
* the duplicate entry the previous implementation wrote is gone.
|
|
2623
|
+
*
|
|
2624
|
+
* The low-balance notification fires only for the winner, and only after the
|
|
2625
|
+
* transaction has committed, so no callback runs inside a database
|
|
2626
|
+
* transaction.
|
|
2627
|
+
*/
|
|
2628
|
+
async commitCreditsDetailed(userId, reservationId, options) {
|
|
2629
|
+
const outcome = await commitThroughRepository(
|
|
2630
|
+
this.repository,
|
|
2631
|
+
userId,
|
|
2632
|
+
reservationId,
|
|
2633
|
+
options
|
|
2634
|
+
);
|
|
2635
|
+
if (outcome.outcome === "committed" && this.lowBalanceCallback) {
|
|
2636
|
+
this.lowBalanceCallback(userId, outcome.balanceAfter).catch((error) => {
|
|
2637
|
+
console.error("[Credits] Failed to send low balance notification:", error);
|
|
1350
2638
|
});
|
|
1351
|
-
if (this.lowBalanceCallback) {
|
|
1352
|
-
this.lowBalanceCallback(userId, credits.balance).catch((error) => {
|
|
1353
|
-
console.error("[Credits] Failed to send low balance notification:", error);
|
|
1354
|
-
});
|
|
1355
|
-
}
|
|
1356
2639
|
}
|
|
2640
|
+
return outcome;
|
|
1357
2641
|
}
|
|
1358
2642
|
/**
|
|
1359
|
-
* Release a reservation (phase 2 of two-phase commit - failure)
|
|
1360
|
-
*
|
|
2643
|
+
* Release a reservation (phase 2 of two-phase commit - failure).
|
|
2644
|
+
*
|
|
2645
|
+
* Throws `RESERVATION_NOT_FOUND` for an unknown reservation, matching the
|
|
2646
|
+
* pre-V2 behaviour — a release naming a reservation that does not exist is a
|
|
2647
|
+
* caller bug, and swallowing it hides the bug while the credits stay held.
|
|
2648
|
+
*
|
|
2649
|
+
* Releasing one that is already terminal — `released`, `expired`, or
|
|
2650
|
+
* `committed` — is a no-op, which is the behaviour this method has always
|
|
2651
|
+
* had. A release that loses a race to a concurrent commit is the common
|
|
2652
|
+
* case in a retry-heavy caller, and callers written against the pre-V2
|
|
2653
|
+
* contract handle it by not handling it.
|
|
2654
|
+
*
|
|
2655
|
+
* That does mean this method cannot tell you that the credits were spent
|
|
2656
|
+
* rather than returned. Use {@link releaseCreditsDetailed}, which reports
|
|
2657
|
+
* `already_terminal` with `terminalStatus: 'committed'`, when you need to
|
|
2658
|
+
* branch on it.
|
|
1361
2659
|
*/
|
|
1362
|
-
async releaseCredits(userId, reservationId) {
|
|
1363
|
-
const
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
const credits = await this.repository.getUserCredits(userId);
|
|
1367
|
-
if (credits) {
|
|
1368
|
-
await this.repository.createJournalEntry({
|
|
1369
|
-
userId,
|
|
1370
|
-
entryType: "credit",
|
|
1371
|
-
amount: 0,
|
|
1372
|
-
// No actual credits returned (they were reserved, not spent)
|
|
1373
|
-
balanceAfter: credits.balance,
|
|
1374
|
-
source: "operation_release",
|
|
1375
|
-
referenceId: reservationId,
|
|
1376
|
-
referenceType: "reservation",
|
|
1377
|
-
description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1378
|
-
metadata: {
|
|
1379
|
-
operationType: reservation.operationType,
|
|
1380
|
-
amount: reservation.amount
|
|
1381
|
-
}
|
|
1382
|
-
});
|
|
1383
|
-
}
|
|
2660
|
+
async releaseCredits(userId, reservationId, options) {
|
|
2661
|
+
const outcome = await this.releaseCreditsDetailed(userId, reservationId, options);
|
|
2662
|
+
if (outcome.outcome === "not_found") {
|
|
2663
|
+
throw createReservationNotFoundError(reservationId);
|
|
1384
2664
|
}
|
|
1385
2665
|
}
|
|
2666
|
+
/** Release a reservation and get the typed outcome. */
|
|
2667
|
+
async releaseCreditsDetailed(userId, reservationId, options) {
|
|
2668
|
+
return releaseThroughRepository(this.repository, userId, reservationId, options);
|
|
2669
|
+
}
|
|
1386
2670
|
/**
|
|
1387
2671
|
* Log usage for audit trail
|
|
1388
2672
|
* @param log - Usage log data
|
|
@@ -1396,9 +2680,22 @@ var CreditsService = class {
|
|
|
1396
2680
|
* @param amount - Credits to add
|
|
1397
2681
|
* @param description - Transaction description
|
|
1398
2682
|
* @param paymentRef - Optional payment reference
|
|
2683
|
+
* @param options - Optional journal source / reference type / extra metadata
|
|
2684
|
+
* for the credit journal entry (e.g. revenue-attribution fields).
|
|
2685
|
+
* @returns Whether this delivery credited the account (`created`), repeated an
|
|
2686
|
+
* earlier one (`replayed`), or reused the reference for a different credit
|
|
2687
|
+
* event (`conflict`, which credited nothing). On a repository without
|
|
2688
|
+
* `addCreditsV2` the result is always `created` and carries no
|
|
2689
|
+
* deduplication guarantee — see {@link addCreditsThroughRepository}.
|
|
1399
2690
|
*/
|
|
1400
|
-
async addCredits(userId, amount, description, paymentRef) {
|
|
1401
|
-
return this.repository
|
|
2691
|
+
async addCredits(userId, amount, description, paymentRef, options) {
|
|
2692
|
+
return addCreditsThroughRepository(this.repository, {
|
|
2693
|
+
userId,
|
|
2694
|
+
amount,
|
|
2695
|
+
description,
|
|
2696
|
+
paymentRef,
|
|
2697
|
+
options
|
|
2698
|
+
});
|
|
1402
2699
|
}
|
|
1403
2700
|
/**
|
|
1404
2701
|
* One-shot atomic "deduct-if-sufficient" credit charge.
|
|
@@ -1422,15 +2719,14 @@ var CreditsService = class {
|
|
|
1422
2719
|
* @throws Error if amount is not a positive number
|
|
1423
2720
|
*/
|
|
1424
2721
|
async deductCredits(userId, amount, options) {
|
|
1425
|
-
|
|
1426
|
-
throw new Error(`deductCredits amount must be positive (got ${amount})`);
|
|
1427
|
-
}
|
|
2722
|
+
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1428
2723
|
let deduction;
|
|
1429
2724
|
try {
|
|
1430
2725
|
deduction = await this.repository.deductCreditsAtomic(userId, amount);
|
|
1431
2726
|
} catch (error) {
|
|
2727
|
+
if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
|
|
1432
2728
|
const credits = await this.repository.getUserCredits(userId);
|
|
1433
|
-
const available = credits ? credits.balance
|
|
2729
|
+
const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
|
|
1434
2730
|
if (available >= amount) {
|
|
1435
2731
|
throw error;
|
|
1436
2732
|
}
|
|
@@ -1439,7 +2735,7 @@ var CreditsService = class {
|
|
|
1439
2735
|
reason: "insufficient",
|
|
1440
2736
|
available,
|
|
1441
2737
|
required: amount,
|
|
1442
|
-
shortfall: amount -
|
|
2738
|
+
shortfall: sumAmounts(amount, -available)
|
|
1443
2739
|
};
|
|
1444
2740
|
}
|
|
1445
2741
|
const operationType = options?.operationType;
|
|
@@ -1453,8 +2749,8 @@ var CreditsService = class {
|
|
|
1453
2749
|
referenceType: "adjustment",
|
|
1454
2750
|
description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
|
|
1455
2751
|
metadata: {
|
|
1456
|
-
...
|
|
1457
|
-
...
|
|
2752
|
+
...options?.metadata,
|
|
2753
|
+
...operationType && { operationType }
|
|
1458
2754
|
}
|
|
1459
2755
|
});
|
|
1460
2756
|
if (operationType) {
|
|
@@ -1489,8 +2785,15 @@ var CreditsService = class {
|
|
|
1489
2785
|
const free = isFreeTier(tier);
|
|
1490
2786
|
await this.repository.updateUserTier(userId, {
|
|
1491
2787
|
tier,
|
|
1492
|
-
//
|
|
1493
|
-
|
|
2788
|
+
// Through the shared contract, like every path that resolves the tier's
|
|
2789
|
+
// limit from configuration. (The repository's `updateUserCredits` and
|
|
2790
|
+
// `updateUserTier` still store a caller-supplied `monthlyLimit` verbatim;
|
|
2791
|
+
// they are given a value, not a tier, so there is no sentinel to map.)
|
|
2792
|
+
// This used to store 0 for an unlimited tier under a "0 means unlimited"
|
|
2793
|
+
// convention that nothing actually implemented — no read path anywhere in
|
|
2794
|
+
// the library branches on it — so upgrading a user to unlimited gave them
|
|
2795
|
+
// an allowance of zero.
|
|
2796
|
+
monthlyLimit: storedMonthlyLimit(monthlyLimit),
|
|
1494
2797
|
// Reset balance to new tier limit if upgrading
|
|
1495
2798
|
balance: !free ? unlimited ? getUnlimitedSentinelBalance() : monthlyLimit : void 0,
|
|
1496
2799
|
monthlyUsed: !free ? 0 : void 0,
|
|
@@ -2181,7 +3484,7 @@ var CreditsClient = class {
|
|
|
2181
3484
|
*/
|
|
2182
3485
|
async getAvailableCredits() {
|
|
2183
3486
|
const balance = await this.getBalance();
|
|
2184
|
-
return balance.balance
|
|
3487
|
+
return sumAmounts(balance.balance, balance.bonusCredits, -balance.reserved);
|
|
2185
3488
|
}
|
|
2186
3489
|
/**
|
|
2187
3490
|
* Execute an operation with automatic credit handling
|
|
@@ -2410,7 +3713,7 @@ function parseCreditError(error) {
|
|
|
2410
3713
|
return {
|
|
2411
3714
|
available,
|
|
2412
3715
|
required,
|
|
2413
|
-
shortfall: required -
|
|
3716
|
+
shortfall: sumAmounts(required, -available),
|
|
2414
3717
|
rawMessage: error
|
|
2415
3718
|
};
|
|
2416
3719
|
}
|
|
@@ -2421,7 +3724,7 @@ function isCreditErrorMessage(error) {
|
|
|
2421
3724
|
|
|
2422
3725
|
// src/utils/index.ts
|
|
2423
3726
|
function getTotalCredits(credits) {
|
|
2424
|
-
return credits.balance
|
|
3727
|
+
return sumAmounts(credits.balance, credits.bonusCredits);
|
|
2425
3728
|
}
|
|
2426
3729
|
function generateRequestId2() {
|
|
2427
3730
|
const timestamp = Date.now();
|
|
@@ -2448,6 +3751,8 @@ export {
|
|
|
2448
3751
|
ApiKeyCreditsProvider,
|
|
2449
3752
|
AuthenticationError,
|
|
2450
3753
|
AuthorizationError,
|
|
3754
|
+
CREDIT_AMOUNT_MAX,
|
|
3755
|
+
CREDIT_AMOUNT_SCALE,
|
|
2451
3756
|
ConsoleNotificationHandler,
|
|
2452
3757
|
CreditError,
|
|
2453
3758
|
CreditErrorCode,
|
|
@@ -2456,31 +3761,57 @@ export {
|
|
|
2456
3761
|
CreditsService,
|
|
2457
3762
|
InMemoryCreditRepository,
|
|
2458
3763
|
NetworkError,
|
|
3764
|
+
RESERVATION_STATUSES,
|
|
3765
|
+
RESERVED_JOURNAL_KEY_PREFIX,
|
|
2459
3766
|
SDKErrorCode,
|
|
2460
3767
|
InsufficientCreditsError as SDKInsufficientCreditsError,
|
|
2461
3768
|
ReservationExpiredError as SDKReservationExpiredError,
|
|
2462
3769
|
ReservationNotFoundError as SDKReservationNotFoundError,
|
|
2463
3770
|
ServerError,
|
|
3771
|
+
TERMINAL_RESERVATION_STATUSES,
|
|
2464
3772
|
UNLIMITED_BALANCE_SENTINEL,
|
|
2465
3773
|
ValidationError,
|
|
3774
|
+
addCreditsThroughRepository,
|
|
3775
|
+
assertDirectStatusWriteAllowed,
|
|
3776
|
+
assertHoldPlaced,
|
|
3777
|
+
assertKnownReservationStatus,
|
|
3778
|
+
assertPublicJournalKey,
|
|
3779
|
+
assertRepresentableAmount,
|
|
3780
|
+
assertRepresentableFields,
|
|
3781
|
+
assertRepresentableTierAmount,
|
|
3782
|
+
assertUnkeyedDirectReservation,
|
|
3783
|
+
assertUnreferencedDirectTransaction,
|
|
3784
|
+
assertValidCreditAmount,
|
|
3785
|
+
assertValidIdempotencyKey,
|
|
3786
|
+
assertValidStoredAmount,
|
|
3787
|
+
assertValidStoredAmountRaw,
|
|
3788
|
+
backedBalanceFloor,
|
|
2466
3789
|
calculateAvailableCredits,
|
|
2467
3790
|
checkAndNotifyLowBalance,
|
|
3791
|
+
classifyDatabaseError,
|
|
2468
3792
|
clearConfigProvider,
|
|
2469
3793
|
clearCooldownState,
|
|
2470
3794
|
clearNotificationHandlers,
|
|
2471
3795
|
commitReservationWithJournal,
|
|
3796
|
+
commitThroughRepository,
|
|
2472
3797
|
createApiKeyProvider,
|
|
2473
3798
|
createCreditsService,
|
|
2474
3799
|
createDeferredExecutor,
|
|
2475
3800
|
createDummyReservation2 as createDummyReservation,
|
|
2476
3801
|
createGenericAdapter,
|
|
3802
|
+
createIdempotencyConflictError,
|
|
2477
3803
|
createInMemoryCreditRepository,
|
|
2478
3804
|
createInsufficientCreditsError,
|
|
3805
|
+
createInvalidAmountError,
|
|
2479
3806
|
createInvalidOperationTypeError,
|
|
3807
|
+
createPaymentRefConflictError,
|
|
2480
3808
|
createReservationAlreadyProcessedError,
|
|
2481
3809
|
createReservationExpiredError,
|
|
2482
3810
|
createReservationNotFoundError,
|
|
3811
|
+
createTransientError,
|
|
3812
|
+
createUnsupportedOperationError,
|
|
2483
3813
|
createUserNotFoundError,
|
|
3814
|
+
describePaymentMismatch,
|
|
2484
3815
|
generateId,
|
|
2485
3816
|
generateRequestId2 as generateRequestId,
|
|
2486
3817
|
genericDeferred,
|
|
@@ -2499,35 +3830,63 @@ export {
|
|
|
2499
3830
|
getOperationLabel,
|
|
2500
3831
|
getOperationLabels,
|
|
2501
3832
|
getReservationExpiryMs,
|
|
3833
|
+
getSqlState,
|
|
2502
3834
|
getTierConfig,
|
|
2503
3835
|
getTotalCredits,
|
|
2504
3836
|
getUnlimitedSentinelBalance,
|
|
2505
3837
|
getValidOperationTypes,
|
|
2506
3838
|
getValidTiers,
|
|
3839
|
+
hasPlacedHold,
|
|
2507
3840
|
initializeConfig,
|
|
2508
3841
|
isCreditError,
|
|
2509
3842
|
isCreditErrorMessage,
|
|
3843
|
+
isCreditedOutcome,
|
|
2510
3844
|
isFeatureEnabled,
|
|
2511
3845
|
isFreeTier,
|
|
3846
|
+
isIdempotencyConflictError,
|
|
2512
3847
|
isInsufficientCreditsError,
|
|
2513
3848
|
isPreviewMode2 as isPreviewMode,
|
|
3849
|
+
isRepresentableAmount,
|
|
3850
|
+
isReservationStatus,
|
|
3851
|
+
isReservedJournalKey,
|
|
3852
|
+
isReservedOutcome,
|
|
3853
|
+
isTerminalReservationStatus,
|
|
3854
|
+
isTransientDatabaseError,
|
|
3855
|
+
isTransientError,
|
|
3856
|
+
isUniqueViolation,
|
|
2514
3857
|
isUnlimitedTier,
|
|
3858
|
+
isValidCreditAmount,
|
|
3859
|
+
isValidIdempotencyKey,
|
|
2515
3860
|
isValidOperationType,
|
|
2516
3861
|
isValidTier,
|
|
3862
|
+
isWinningOutcome,
|
|
2517
3863
|
loadConfigFromEnv,
|
|
3864
|
+
monthlyResetBalance,
|
|
2518
3865
|
noopDeferred,
|
|
3866
|
+
normalizePaymentRef,
|
|
2519
3867
|
notifySubscriptionExpired,
|
|
2520
3868
|
notifySubscriptionExpiring,
|
|
3869
|
+
numericToCents,
|
|
2521
3870
|
parseApiError,
|
|
2522
3871
|
parseCreditError,
|
|
2523
3872
|
parseTier,
|
|
2524
3873
|
registerConfigProvider,
|
|
2525
3874
|
registerNotificationHandler,
|
|
2526
3875
|
releaseReservationWithJournal,
|
|
3876
|
+
releaseThroughRepository,
|
|
3877
|
+
reservationJournalKey,
|
|
2527
3878
|
reserveCreditsForOperation,
|
|
3879
|
+
reserveThroughRepository,
|
|
2528
3880
|
resetConfig,
|
|
3881
|
+
sameAmount,
|
|
3882
|
+
storedMonthlyLimit,
|
|
3883
|
+
sumAmounts,
|
|
3884
|
+
supportsCreditsV2,
|
|
3885
|
+
supportsIdempotentCredit,
|
|
2529
3886
|
synchronousDeferred,
|
|
3887
|
+
terminalStatusOf,
|
|
2530
3888
|
tierSchema,
|
|
3889
|
+
toCents,
|
|
2531
3890
|
toClientUserCredits,
|
|
2532
3891
|
toDate,
|
|
2533
3892
|
toPortableTimestamp,
|