@nehorai/credits 1.7.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +384 -153
- package/dist/adapters/index.cjs +308 -46
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.d.cts +3 -2
- package/dist/adapters/index.d.ts +3 -2
- package/dist/adapters/index.js +308 -46
- package/dist/adapters/index.js.map +1 -1
- package/dist/auth/index.cjs.map +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.cjs +700 -76
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +583 -5
- package/dist/core/index.d.ts +583 -5
- package/dist/core/index.js +652 -76
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +1772 -371
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -7
- package/dist/index.d.ts +44 -7
- package/dist/index.js +1716 -371
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +1403 -185
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +141 -15
- package/dist/repository/index.d.ts +141 -15
- package/dist/repository/index.js +1396 -185
- package/dist/repository/index.js.map +1 -1
- package/dist/sdk/index.cjs +23 -1
- package/dist/sdk/index.cjs.map +1 -1
- package/dist/sdk/index.d.cts +2 -2
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/index.js +23 -1
- package/dist/sdk/index.js.map +1 -1
- package/dist/service/index.cjs +484 -106
- package/dist/service/index.cjs.map +1 -1
- package/dist/service/index.d.cts +68 -10
- package/dist/service/index.d.ts +68 -10
- package/dist/service/index.js +484 -106
- package/dist/service/index.js.map +1 -1
- package/dist/types-BhCdep_t.d.ts +646 -0
- package/dist/{types-CjLj3GW5.d.cts → types-Dzc443vy.d.cts} +44 -2
- package/dist/{types-CjLj3GW5.d.ts → types-Dzc443vy.d.ts} +44 -2
- package/dist/types-wtksMa4n.d.cts +646 -0
- package/package.json +71 -70
- package/LICENSE +0 -21
- package/dist/types-DFe5BoJY.d.cts +0 -329
- package/dist/types-_bSybQnb.d.ts +0 -329
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 = {
|
|
@@ -177,11 +599,13 @@ var creditSystemConfigSchema = z.object({
|
|
|
177
599
|
/**
|
|
178
600
|
* Operation costs in credits
|
|
179
601
|
* Keys are operation type names (any non-empty string)
|
|
180
|
-
* Values are
|
|
602
|
+
* Values are non-negative numbers representing credit cost. 0 marks a free
|
|
603
|
+
* operation: `withCredits` runs it without touching the ledger. Amounts that
|
|
604
|
+
* move credits stay strictly positive, so `reserveCredits` refuses a 0.
|
|
181
605
|
*/
|
|
182
606
|
operationCosts: z.record(
|
|
183
607
|
z.string().min(1),
|
|
184
|
-
z.number().
|
|
608
|
+
z.number().nonnegative()
|
|
185
609
|
),
|
|
186
610
|
/**
|
|
187
611
|
* Display labels for operation types (English canonical labels).
|
|
@@ -439,6 +863,12 @@ var UNLIMITED_BALANCE_SENTINEL = 999999;
|
|
|
439
863
|
function getUnlimitedSentinelBalance() {
|
|
440
864
|
return UNLIMITED_BALANCE_SENTINEL;
|
|
441
865
|
}
|
|
866
|
+
function monthlyResetBalance(tier) {
|
|
867
|
+
if (isUnlimitedTier(tier)) {
|
|
868
|
+
return { kind: "atLeast", value: UNLIMITED_BALANCE_SENTINEL };
|
|
869
|
+
}
|
|
870
|
+
return { kind: "absolute", value: getConfigMonthlyLimit(tier) };
|
|
871
|
+
}
|
|
442
872
|
function getConfigTierConfig(tier) {
|
|
443
873
|
const cfg = getConfig().tierConfigs[tier];
|
|
444
874
|
if (cfg) return cfg;
|
|
@@ -481,57 +911,203 @@ function resetConfig() {
|
|
|
481
911
|
}
|
|
482
912
|
initializeConfig();
|
|
483
913
|
|
|
484
|
-
// src/
|
|
485
|
-
|
|
914
|
+
// src/repository/types.ts
|
|
915
|
+
function supportsCreditsV2(repository) {
|
|
916
|
+
return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
|
|
917
|
+
}
|
|
918
|
+
function supportsIdempotentCredit(repository) {
|
|
919
|
+
return typeof repository.addCreditsV2 === "function";
|
|
920
|
+
}
|
|
921
|
+
function toClientUserCredits(credits) {
|
|
922
|
+
return {
|
|
923
|
+
userId: credits.userId,
|
|
924
|
+
balance: credits.balance,
|
|
925
|
+
bonusCredits: credits.bonusCredits ?? 0,
|
|
926
|
+
reserved: credits.reserved,
|
|
927
|
+
tier: credits.tier,
|
|
928
|
+
monthlyLimit: credits.monthlyLimit,
|
|
929
|
+
monthlyUsed: credits.monthlyUsed,
|
|
930
|
+
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
931
|
+
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
932
|
+
createdAt: toISOString(credits.createdAt),
|
|
933
|
+
updatedAt: toISOString(credits.updatedAt)
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
function toISOString(value) {
|
|
937
|
+
if (!value) return (/* @__PURE__ */ new Date()).toISOString();
|
|
938
|
+
if (typeof value === "string") return value;
|
|
939
|
+
if (value instanceof Date) return value.toISOString();
|
|
940
|
+
if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
|
|
941
|
+
return value.toDate().toISOString();
|
|
942
|
+
}
|
|
943
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// src/repository/flow.ts
|
|
947
|
+
async function reserveThroughRepository(repository, input) {
|
|
948
|
+
const v2 = supportsCreditsV2(repository);
|
|
949
|
+
if (!v2 && input.idempotencyKey !== void 0) {
|
|
950
|
+
throw createUnsupportedOperationError(
|
|
951
|
+
"reserveCredits with an idempotencyKey",
|
|
952
|
+
"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."
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
assertValidCreditAmount(input.amount, { userId: input.userId });
|
|
956
|
+
if (v2) {
|
|
957
|
+
return repository.reserveCreditsV2(input);
|
|
958
|
+
}
|
|
959
|
+
try {
|
|
960
|
+
const reservation = await repository.reserveCreditsAtomic(
|
|
961
|
+
input.userId,
|
|
962
|
+
input.amount,
|
|
963
|
+
input.operationType,
|
|
964
|
+
input.expiresAt
|
|
965
|
+
);
|
|
966
|
+
return { outcome: "created", reservation };
|
|
967
|
+
} catch (error) {
|
|
968
|
+
if (!isInsufficientCreditsError(error)) throw error;
|
|
969
|
+
const credits = await repository.getUserCredits(input.userId);
|
|
970
|
+
const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
|
|
971
|
+
return {
|
|
972
|
+
outcome: "insufficient",
|
|
973
|
+
available,
|
|
974
|
+
required: input.amount,
|
|
975
|
+
shortfall: Math.max(sumAmounts(input.amount, -available), 0)
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
async function commitThroughRepository(repository, userId, reservationId, options) {
|
|
980
|
+
if (supportsCreditsV2(repository)) {
|
|
981
|
+
return repository.commitReservationV2(userId, reservationId, options);
|
|
982
|
+
}
|
|
486
983
|
const reservation = await repository.getReservation(userId, reservationId);
|
|
487
|
-
if (!reservation) {
|
|
488
|
-
|
|
984
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
985
|
+
if (reservation.status !== "reserved") {
|
|
986
|
+
return {
|
|
987
|
+
outcome: "already_terminal",
|
|
988
|
+
reservation,
|
|
989
|
+
terminalStatus: reservation.status
|
|
990
|
+
};
|
|
489
991
|
}
|
|
490
992
|
await repository.commitReservationAtomic(userId, reservationId);
|
|
491
993
|
const credits = await repository.getUserCredits(userId);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
994
|
+
const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
|
|
995
|
+
const entry = await repository.createJournalEntry({
|
|
996
|
+
userId,
|
|
997
|
+
entryType: "debit",
|
|
998
|
+
amount: reservation.amount,
|
|
999
|
+
balanceAfter,
|
|
1000
|
+
source: "operation_commit",
|
|
1001
|
+
referenceId: reservationId,
|
|
1002
|
+
referenceType: "reservation",
|
|
1003
|
+
description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1004
|
+
// Deterministic fields last: they carry the identity of the event, and a
|
|
1005
|
+
// caller who happens to send an `operationType` or an `amount` must not
|
|
1006
|
+
// relabel what actually moved.
|
|
1007
|
+
metadata: {
|
|
1008
|
+
...options?.metadata,
|
|
1009
|
+
operationType: reservation.operationType,
|
|
1010
|
+
amount: reservation.amount
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
return {
|
|
1014
|
+
outcome: "committed",
|
|
1015
|
+
reservation: { ...reservation, status: "committed" },
|
|
1016
|
+
amount: reservation.amount,
|
|
1017
|
+
balanceAfter,
|
|
1018
|
+
journalEntryId: entry.id
|
|
1019
|
+
};
|
|
507
1020
|
}
|
|
508
|
-
async function
|
|
1021
|
+
async function releaseThroughRepository(repository, userId, reservationId, options) {
|
|
1022
|
+
if (supportsCreditsV2(repository)) {
|
|
1023
|
+
return repository.releaseReservationV2(userId, reservationId, options);
|
|
1024
|
+
}
|
|
509
1025
|
const reservation = await repository.getReservation(userId, reservationId);
|
|
1026
|
+
if (!reservation) {
|
|
1027
|
+
await repository.releaseReservationAtomic(userId, reservationId);
|
|
1028
|
+
return { outcome: "not_found", reservationId };
|
|
1029
|
+
}
|
|
1030
|
+
if (reservation.status !== "reserved") {
|
|
1031
|
+
await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
|
|
1032
|
+
return {
|
|
1033
|
+
outcome: "already_terminal",
|
|
1034
|
+
reservation,
|
|
1035
|
+
terminalStatus: reservation.status
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
510
1038
|
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
|
-
});
|
|
1039
|
+
const credits = await repository.getUserCredits(userId);
|
|
1040
|
+
const entry = await repository.createJournalEntry({
|
|
1041
|
+
userId,
|
|
1042
|
+
entryType: "credit",
|
|
1043
|
+
// No credits changed hands — the hold just moved back into "available".
|
|
1044
|
+
amount: 0,
|
|
1045
|
+
balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
|
|
1046
|
+
source: "operation_release",
|
|
1047
|
+
referenceId: reservationId,
|
|
1048
|
+
referenceType: "reservation",
|
|
1049
|
+
description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1050
|
+
metadata: {
|
|
1051
|
+
...options?.metadata,
|
|
1052
|
+
operationType: reservation.operationType,
|
|
1053
|
+
amount: reservation.amount
|
|
529
1054
|
}
|
|
1055
|
+
});
|
|
1056
|
+
return {
|
|
1057
|
+
outcome: "released",
|
|
1058
|
+
reservation: { ...reservation, status: "released" },
|
|
1059
|
+
amount: reservation.amount,
|
|
1060
|
+
journalEntryId: entry.id
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
async function addCreditsThroughRepository(repository, input) {
|
|
1064
|
+
assertValidCreditAmount(input.amount, { userId: input.userId, operation: "addCredits" });
|
|
1065
|
+
const paymentRef = normalizePaymentRef(input.paymentRef);
|
|
1066
|
+
if (supportsIdempotentCredit(repository)) {
|
|
1067
|
+
return repository.addCreditsV2({ ...input, paymentRef });
|
|
1068
|
+
}
|
|
1069
|
+
await repository.addCreditsAtomic(
|
|
1070
|
+
input.userId,
|
|
1071
|
+
input.amount,
|
|
1072
|
+
input.description,
|
|
1073
|
+
paymentRef,
|
|
1074
|
+
input.options
|
|
1075
|
+
);
|
|
1076
|
+
return paymentRef ? { outcome: "created", paymentRef } : { outcome: "created" };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// src/core/operations.ts
|
|
1080
|
+
async function commitReservationWithJournal(repository, userId, reservationId) {
|
|
1081
|
+
const outcome = await commitThroughRepository(repository, userId, reservationId);
|
|
1082
|
+
if (outcome.outcome === "not_found") {
|
|
1083
|
+
throw createReservationNotFoundError(reservationId);
|
|
530
1084
|
}
|
|
1085
|
+
if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
|
|
1086
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
async function releaseReservationWithJournal(repository, userId, reservationId) {
|
|
1090
|
+
await releaseThroughRepository(repository, userId, reservationId);
|
|
531
1091
|
}
|
|
532
|
-
async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3) {
|
|
1092
|
+
async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3, idempotencyKey) {
|
|
533
1093
|
const expiresAt = new Date(Date.now() + expiryMs);
|
|
534
|
-
|
|
1094
|
+
const outcome = await reserveThroughRepository(repository, {
|
|
1095
|
+
userId,
|
|
1096
|
+
amount,
|
|
1097
|
+
operationType,
|
|
1098
|
+
expiresAt,
|
|
1099
|
+
idempotencyKey
|
|
1100
|
+
});
|
|
1101
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
1102
|
+
return outcome.reservation;
|
|
1103
|
+
}
|
|
1104
|
+
if (outcome.outcome === "insufficient") {
|
|
1105
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
1106
|
+
}
|
|
1107
|
+
throw createReservationAlreadyProcessedError(
|
|
1108
|
+
outcome.existing.id,
|
|
1109
|
+
outcome.existing.status
|
|
1110
|
+
);
|
|
535
1111
|
}
|
|
536
1112
|
|
|
537
1113
|
// src/config/costs.ts
|
|
@@ -566,64 +1142,493 @@ function getOperationCostsWithLabels() {
|
|
|
566
1142
|
return result;
|
|
567
1143
|
}
|
|
568
1144
|
|
|
569
|
-
// src/repository/types.ts
|
|
570
|
-
function
|
|
571
|
-
return {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1145
|
+
// src/repository/v2-types.ts
|
|
1146
|
+
function reservationJournalKey(reservationId, transition) {
|
|
1147
|
+
return `${RESERVED_JOURNAL_KEY_PREFIX}${reservationId}:${transition}`;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// src/repository/utils.ts
|
|
1151
|
+
function generateId() {
|
|
1152
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
1153
|
+
}
|
|
1154
|
+
function toDate2(value) {
|
|
1155
|
+
if (value instanceof Date) return value;
|
|
1156
|
+
if (typeof value === "string") return new Date(value);
|
|
1157
|
+
if (typeof value === "object" && value !== null && "toDate" in value) {
|
|
1158
|
+
return value.toDate();
|
|
1159
|
+
}
|
|
1160
|
+
return /* @__PURE__ */ new Date();
|
|
1161
|
+
}
|
|
1162
|
+
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
1163
|
+
const next = new Date(from);
|
|
1164
|
+
next.setMonth(next.getMonth() + 1);
|
|
1165
|
+
next.setDate(1);
|
|
1166
|
+
next.setHours(0, 0, 0, 0);
|
|
1167
|
+
return next;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/repository/memory/snapshot.ts
|
|
1171
|
+
function copyRecord(record) {
|
|
1172
|
+
if (!record) return void 0;
|
|
1173
|
+
const copy = { ...record };
|
|
1174
|
+
const metadata = record.metadata;
|
|
1175
|
+
if (metadata && typeof metadata === "object") {
|
|
1176
|
+
copy.metadata = { ...metadata };
|
|
1177
|
+
}
|
|
1178
|
+
return copy;
|
|
1179
|
+
}
|
|
1180
|
+
function copyRecords(records) {
|
|
1181
|
+
return records.map((record) => copyRecord(record));
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// src/repository/memory/mutex.ts
|
|
1185
|
+
var KeyedMutex = class {
|
|
1186
|
+
tails = /* @__PURE__ */ new Map();
|
|
1187
|
+
/**
|
|
1188
|
+
* Run `fn` with exclusive access to `key`.
|
|
1189
|
+
*
|
|
1190
|
+
* Callers queue in arrival order. The stored tail settles either way, so one
|
|
1191
|
+
* caller throwing does not wedge the queue behind it.
|
|
1192
|
+
*/
|
|
1193
|
+
async run(key, fn) {
|
|
1194
|
+
const previous = this.tails.get(key) ?? Promise.resolve();
|
|
1195
|
+
const result = previous.then(fn, fn);
|
|
1196
|
+
const tail = result.then(
|
|
1197
|
+
() => void 0,
|
|
1198
|
+
() => void 0
|
|
1199
|
+
);
|
|
1200
|
+
this.tails.set(key, tail);
|
|
1201
|
+
try {
|
|
1202
|
+
return await result;
|
|
1203
|
+
} finally {
|
|
1204
|
+
if (this.tails.get(key) === tail) {
|
|
1205
|
+
this.tails.delete(key);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
|
|
1211
|
+
// src/repository/memory/store.ts
|
|
1212
|
+
var MemoryStore = class {
|
|
1213
|
+
users = /* @__PURE__ */ new Map();
|
|
1214
|
+
reservations = /* @__PURE__ */ new Map();
|
|
1215
|
+
transactions = /* @__PURE__ */ new Map();
|
|
1216
|
+
usageLogs = [];
|
|
1217
|
+
journalEntries = /* @__PURE__ */ new Map();
|
|
1218
|
+
/** Stands in for the reservations table's partial unique index. */
|
|
1219
|
+
reservationKeys = /* @__PURE__ */ new Map();
|
|
1220
|
+
/** Stands in for the journal table's partial unique index. */
|
|
1221
|
+
journalKeys = /* @__PURE__ */ new Map();
|
|
1222
|
+
/** Stands in for the database's per-user row locks. */
|
|
1223
|
+
locks = new KeyedMutex();
|
|
1224
|
+
/**
|
|
1225
|
+
* Test-only yield point, awaited between reading state and mutating it.
|
|
1226
|
+
*
|
|
1227
|
+
* Unset in production, so the critical sections stay synchronous and this
|
|
1228
|
+
* costs nothing. Tests set it to a real macrotask yield, which forces
|
|
1229
|
+
* concurrent callers to interleave at exactly the spot a database would have
|
|
1230
|
+
* them contend — turning the lock above from decoration into the thing the
|
|
1231
|
+
* concurrency tests actually depend on.
|
|
1232
|
+
*/
|
|
1233
|
+
schedulingHook;
|
|
1234
|
+
clear() {
|
|
1235
|
+
this.users.clear();
|
|
1236
|
+
this.reservations.clear();
|
|
1237
|
+
this.transactions.clear();
|
|
1238
|
+
this.usageLogs = [];
|
|
1239
|
+
this.journalEntries.clear();
|
|
1240
|
+
this.reservationKeys.clear();
|
|
1241
|
+
this.journalKeys.clear();
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
function scopedKey(userId, key) {
|
|
1245
|
+
return `${userId}
|
|
1246
|
+
${key}`;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// src/repository/memory/transition-journal.ts
|
|
1250
|
+
function describeJournalMismatch(row, input) {
|
|
1251
|
+
if (row.userId !== input.userId) return "user_id";
|
|
1252
|
+
if (row.entryType !== input.entryType) return "entry_type";
|
|
1253
|
+
if (!sameAmount(row.amount, input.amount)) return "amount";
|
|
1254
|
+
if (!sameAmount(row.balanceAfter, input.balanceAfter)) return "balance_after";
|
|
1255
|
+
if (row.source !== input.source) return "source";
|
|
1256
|
+
if (row.referenceId !== input.reservationId) return "reference_id";
|
|
1257
|
+
if (row.referenceType !== "reservation") return "reference_type";
|
|
1258
|
+
const existingMeta = row.metadata ?? {};
|
|
1259
|
+
const expectedMeta = input.metadata ?? {};
|
|
1260
|
+
if (existingMeta.operationType !== expectedMeta.operationType) {
|
|
1261
|
+
return "metadata.operationType";
|
|
1262
|
+
}
|
|
1263
|
+
const hadAmount = existingMeta.amount !== void 0;
|
|
1264
|
+
const wantsAmount = expectedMeta.amount !== void 0;
|
|
1265
|
+
if (hadAmount !== wantsAmount) return "metadata.amount";
|
|
1266
|
+
if (wantsAmount && !sameAmount(existingMeta.amount, expectedMeta.amount)) {
|
|
1267
|
+
return "metadata.amount";
|
|
1268
|
+
}
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
function findEntry(store, userId, entryId) {
|
|
1272
|
+
return store.journalEntries.get(userId)?.find((entry) => entry.id === entryId);
|
|
1273
|
+
}
|
|
1274
|
+
function planTransitionJournal(store, input) {
|
|
1275
|
+
assertRepresentableFields(
|
|
1276
|
+
{ amount: input.amount, "journal balanceAfter": input.balanceAfter },
|
|
1277
|
+
{ userId: input.userId, reservationId: input.reservationId, operation: input.operation }
|
|
1278
|
+
);
|
|
1279
|
+
const existingId = store.journalKeys.get(scopedKey(input.userId, input.idempotencyKey));
|
|
1280
|
+
if (!existingId) return void 0;
|
|
1281
|
+
const row = findEntry(store, input.userId, existingId);
|
|
1282
|
+
const mismatch = row ? describeJournalMismatch(row, input) : "the key is registered but its entry is missing";
|
|
1283
|
+
if (mismatch) {
|
|
1284
|
+
throw new CreditError(
|
|
1285
|
+
`Journal key ${input.idempotencyKey} is already used by a different entry (${mismatch}). The transition was refused rather than reported as applied.`,
|
|
1286
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
1287
|
+
{
|
|
1288
|
+
idempotencyKey: input.idempotencyKey,
|
|
1289
|
+
reservationId: input.reservationId,
|
|
1290
|
+
mismatch
|
|
1291
|
+
}
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
return existingId;
|
|
1295
|
+
}
|
|
1296
|
+
function commitTransitionJournal(store, input, existingId) {
|
|
1297
|
+
if (existingId) return existingId;
|
|
1298
|
+
const entry = {
|
|
1299
|
+
id: generateId(),
|
|
1300
|
+
userId: input.userId,
|
|
1301
|
+
entryType: input.entryType,
|
|
1302
|
+
amount: input.amount,
|
|
1303
|
+
balanceAfter: input.balanceAfter,
|
|
1304
|
+
source: input.source,
|
|
1305
|
+
referenceId: input.reservationId,
|
|
1306
|
+
referenceType: "reservation",
|
|
1307
|
+
description: input.description,
|
|
1308
|
+
metadata: input.metadata,
|
|
1309
|
+
idempotencyKey: input.idempotencyKey,
|
|
1310
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
583
1311
|
};
|
|
1312
|
+
if (!store.journalEntries.has(input.userId)) {
|
|
1313
|
+
store.journalEntries.set(input.userId, []);
|
|
1314
|
+
}
|
|
1315
|
+
store.journalEntries.get(input.userId).push(entry);
|
|
1316
|
+
store.journalKeys.set(scopedKey(input.userId, input.idempotencyKey), entry.id);
|
|
1317
|
+
return entry.id;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// src/repository/memory/v2.ts
|
|
1321
|
+
async function yieldPoint(store) {
|
|
1322
|
+
if (store.schedulingHook) await store.schedulingHook();
|
|
1323
|
+
}
|
|
1324
|
+
function assertReservedCoversHold(credits, userId, reservationId, amount) {
|
|
1325
|
+
if (credits.reserved >= amount) return;
|
|
1326
|
+
throw new CreditError(
|
|
1327
|
+
`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.`,
|
|
1328
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
1329
|
+
{ userId, reservationId, amount, reserved: credits.reserved }
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
function assertLockedAmount(reservation, transition) {
|
|
1333
|
+
assertValidStoredAmount(reservation.amount, {
|
|
1334
|
+
userId: reservation.userId,
|
|
1335
|
+
reservationId: reservation.id,
|
|
1336
|
+
transition
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
function terminalStatusOf2(reservation, transition) {
|
|
1340
|
+
return terminalStatusOf(reservation, transition);
|
|
1341
|
+
}
|
|
1342
|
+
function assertTrustworthy(reservation, transition) {
|
|
1343
|
+
assertKnownReservationStatus(reservation, transition);
|
|
1344
|
+
assertLockedAmount(reservation, transition);
|
|
1345
|
+
assertHoldPlaced(reservation, transition);
|
|
1346
|
+
}
|
|
1347
|
+
function getReservation(store, userId, reservationId) {
|
|
1348
|
+
return store.reservations.get(userId)?.get(reservationId);
|
|
1349
|
+
}
|
|
1350
|
+
function requireUser(store, userId) {
|
|
1351
|
+
const credits = store.users.get(userId);
|
|
1352
|
+
if (!credits) {
|
|
1353
|
+
throw new CreditError(
|
|
1354
|
+
`User credits not found for user ${userId}`,
|
|
1355
|
+
CreditErrorCode.USER_NOT_FOUND,
|
|
1356
|
+
{ userId }
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
return credits;
|
|
1360
|
+
}
|
|
1361
|
+
async function reserveCreditsV2(store, input) {
|
|
1362
|
+
assertValidCreditAmount(input.amount, { userId: input.userId });
|
|
1363
|
+
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
1364
|
+
return store.locks.run(input.userId, async () => {
|
|
1365
|
+
if (input.idempotencyKey) {
|
|
1366
|
+
const existingId = store.reservationKeys.get(
|
|
1367
|
+
scopedKey(input.userId, input.idempotencyKey)
|
|
1368
|
+
);
|
|
1369
|
+
if (existingId) {
|
|
1370
|
+
const existing = getReservation(store, input.userId, existingId);
|
|
1371
|
+
if (existing) {
|
|
1372
|
+
assertHoldPlaced(existing, "reserve replay");
|
|
1373
|
+
const samePayload = existing.amount === input.amount && existing.operationType === input.operationType;
|
|
1374
|
+
return samePayload ? { outcome: "replayed", reservation: copyRecord(existing) } : {
|
|
1375
|
+
outcome: "idempotency_conflict",
|
|
1376
|
+
idempotencyKey: input.idempotencyKey,
|
|
1377
|
+
existing: copyRecord(existing)
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
const credits = requireUser(store, input.userId);
|
|
1383
|
+
const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
|
|
1384
|
+
await yieldPoint(store);
|
|
1385
|
+
if (available < input.amount) {
|
|
1386
|
+
return {
|
|
1387
|
+
outcome: "insufficient",
|
|
1388
|
+
available,
|
|
1389
|
+
required: input.amount,
|
|
1390
|
+
shortfall: sumAmounts(input.amount, -available)
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1394
|
+
const reservation = {
|
|
1395
|
+
id: generateId(),
|
|
1396
|
+
userId: input.userId,
|
|
1397
|
+
amount: input.amount,
|
|
1398
|
+
operationType: input.operationType,
|
|
1399
|
+
status: "reserved",
|
|
1400
|
+
createdAt: now,
|
|
1401
|
+
expiresAt: input.expiresAt.toISOString(),
|
|
1402
|
+
idempotencyKey: input.idempotencyKey,
|
|
1403
|
+
// The hold-origin fact. Written here and nowhere else, in the same
|
|
1404
|
+
// critical section that raises `reserved` below, so the row and the hold
|
|
1405
|
+
// become visible together. `createReservation` cannot set it.
|
|
1406
|
+
holdPlacedAt: now
|
|
1407
|
+
};
|
|
1408
|
+
if (!store.reservations.has(input.userId)) {
|
|
1409
|
+
store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
1410
|
+
}
|
|
1411
|
+
const nextReserved = sumAmounts(credits.reserved, input.amount);
|
|
1412
|
+
assertRepresentableFields(
|
|
1413
|
+
{ reserved: nextReserved },
|
|
1414
|
+
{ userId: input.userId, operation: "reserveCredits" }
|
|
1415
|
+
);
|
|
1416
|
+
store.reservations.get(input.userId).set(reservation.id, reservation);
|
|
1417
|
+
if (input.idempotencyKey) {
|
|
1418
|
+
store.reservationKeys.set(
|
|
1419
|
+
scopedKey(input.userId, input.idempotencyKey),
|
|
1420
|
+
reservation.id
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
credits.reserved = nextReserved;
|
|
1424
|
+
credits.updatedAt = now;
|
|
1425
|
+
return { outcome: "created", reservation: copyRecord(reservation) };
|
|
1426
|
+
});
|
|
584
1427
|
}
|
|
585
|
-
function
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
1428
|
+
async function commitReservationV2(store, userId, reservationId, options) {
|
|
1429
|
+
return store.locks.run(userId, async () => {
|
|
1430
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1431
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1432
|
+
assertTrustworthy(reservation, "commit");
|
|
1433
|
+
if (reservation.status !== "reserved") {
|
|
1434
|
+
return {
|
|
1435
|
+
outcome: "already_terminal",
|
|
1436
|
+
reservation: copyRecord(reservation),
|
|
1437
|
+
terminalStatus: terminalStatusOf2(reservation, "commit")
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
const credits = requireUser(store, userId);
|
|
1441
|
+
const amount = reservation.amount;
|
|
1442
|
+
await yieldPoint(store);
|
|
1443
|
+
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1444
|
+
if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
|
|
1445
|
+
throw new CreditError(
|
|
1446
|
+
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1447
|
+
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1448
|
+
{ userId, reservationId, required: amount }
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
const fromBalance = Math.min(credits.balance, amount);
|
|
1452
|
+
const nextBalance = sumAmounts(credits.balance, -fromBalance);
|
|
1453
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1454
|
+
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1455
|
+
const journal = {
|
|
1456
|
+
userId,
|
|
1457
|
+
operation: "commitReservation",
|
|
1458
|
+
entryType: "debit",
|
|
1459
|
+
amount,
|
|
1460
|
+
balanceAfter,
|
|
1461
|
+
source: "operation_commit",
|
|
1462
|
+
reservationId,
|
|
1463
|
+
description: options?.description ?? `Committed ${amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1464
|
+
// Caller metadata first: the deterministic fields carry the identity of
|
|
1465
|
+
// the event that the collision check compares on.
|
|
1466
|
+
metadata: { ...options?.metadata, operationType: reservation.operationType, amount },
|
|
1467
|
+
idempotencyKey: reservationJournalKey(reservationId, "commit")
|
|
1468
|
+
};
|
|
1469
|
+
const nextReserved = sumAmounts(credits.reserved, -amount);
|
|
1470
|
+
const nextMonthlyUsed = sumAmounts(credits.monthlyUsed, amount);
|
|
1471
|
+
assertRepresentableFields(
|
|
1472
|
+
{
|
|
1473
|
+
balance: nextBalance,
|
|
1474
|
+
bonusCredits: nextBonusCredits,
|
|
1475
|
+
reserved: nextReserved,
|
|
1476
|
+
monthlyUsed: nextMonthlyUsed
|
|
1477
|
+
},
|
|
1478
|
+
{ userId, operation: "commitReservation" }
|
|
1479
|
+
);
|
|
1480
|
+
const existingEntryId = planTransitionJournal(store, journal);
|
|
1481
|
+
reservation.status = "committed";
|
|
1482
|
+
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1483
|
+
credits.balance = nextBalance;
|
|
1484
|
+
credits.bonusCredits = nextBonusCredits;
|
|
1485
|
+
credits.reserved = nextReserved;
|
|
1486
|
+
credits.monthlyUsed = nextMonthlyUsed;
|
|
1487
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1488
|
+
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1489
|
+
return {
|
|
1490
|
+
outcome: "committed",
|
|
1491
|
+
reservation: copyRecord(reservation),
|
|
1492
|
+
amount,
|
|
1493
|
+
balanceAfter,
|
|
1494
|
+
journalEntryId
|
|
1495
|
+
};
|
|
1496
|
+
});
|
|
593
1497
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1498
|
+
async function releaseReservationV2(store, userId, reservationId, options) {
|
|
1499
|
+
return store.locks.run(userId, async () => {
|
|
1500
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1501
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1502
|
+
assertTrustworthy(reservation, "release");
|
|
1503
|
+
if (reservation.status !== "reserved") {
|
|
1504
|
+
return {
|
|
1505
|
+
outcome: "already_terminal",
|
|
1506
|
+
reservation: copyRecord(reservation),
|
|
1507
|
+
terminalStatus: terminalStatusOf2(reservation, "release")
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
await yieldPoint(store);
|
|
1511
|
+
const journalEntryId = finishUnspent(store, reservation, {
|
|
1512
|
+
status: "released",
|
|
1513
|
+
source: "operation_release",
|
|
1514
|
+
transition: "release",
|
|
1515
|
+
verb: "Released",
|
|
1516
|
+
options
|
|
1517
|
+
});
|
|
1518
|
+
return {
|
|
1519
|
+
outcome: "released",
|
|
1520
|
+
reservation: copyRecord(reservation),
|
|
1521
|
+
amount: reservation.amount,
|
|
1522
|
+
journalEntryId
|
|
1523
|
+
};
|
|
1524
|
+
});
|
|
598
1525
|
}
|
|
599
|
-
function
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
1526
|
+
async function expireReservationV2(store, userId, reservationId, options) {
|
|
1527
|
+
const asOf = options?.asOf ?? /* @__PURE__ */ new Date();
|
|
1528
|
+
return store.locks.run(userId, async () => {
|
|
1529
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1530
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1531
|
+
assertTrustworthy(reservation, "expire");
|
|
1532
|
+
if (reservation.status !== "reserved") {
|
|
1533
|
+
return {
|
|
1534
|
+
outcome: "already_terminal",
|
|
1535
|
+
reservation: copyRecord(reservation),
|
|
1536
|
+
terminalStatus: terminalStatusOf2(reservation, "expire")
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
if (new Date(reservation.expiresAt).getTime() > asOf.getTime()) {
|
|
1540
|
+
return { outcome: "not_due", reservation: copyRecord(reservation) };
|
|
1541
|
+
}
|
|
1542
|
+
await yieldPoint(store);
|
|
1543
|
+
const journalEntryId = finishUnspent(store, reservation, {
|
|
1544
|
+
status: "expired",
|
|
1545
|
+
source: "reservation_expired",
|
|
1546
|
+
transition: "expire",
|
|
1547
|
+
verb: "Expired",
|
|
1548
|
+
options
|
|
1549
|
+
});
|
|
1550
|
+
return {
|
|
1551
|
+
outcome: "expired",
|
|
1552
|
+
reservation: copyRecord(reservation),
|
|
1553
|
+
amount: reservation.amount,
|
|
1554
|
+
journalEntryId
|
|
1555
|
+
};
|
|
1556
|
+
});
|
|
606
1557
|
}
|
|
607
|
-
function
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1558
|
+
function finishUnspent(store, reservation, transition) {
|
|
1559
|
+
const credits = requireUser(store, reservation.userId);
|
|
1560
|
+
assertReservedCoversHold(
|
|
1561
|
+
credits,
|
|
1562
|
+
reservation.userId,
|
|
1563
|
+
reservation.id,
|
|
1564
|
+
reservation.amount
|
|
1565
|
+
);
|
|
1566
|
+
const journal = {
|
|
1567
|
+
userId: reservation.userId,
|
|
1568
|
+
operation: `${transition.transition}Reservation`,
|
|
1569
|
+
entryType: "credit",
|
|
1570
|
+
amount: 0,
|
|
1571
|
+
balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1572
|
+
source: transition.source,
|
|
1573
|
+
reservationId: reservation.id,
|
|
1574
|
+
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1575
|
+
metadata: {
|
|
1576
|
+
...transition.options?.metadata,
|
|
1577
|
+
operationType: reservation.operationType,
|
|
1578
|
+
amount: reservation.amount
|
|
1579
|
+
},
|
|
1580
|
+
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1581
|
+
};
|
|
1582
|
+
const nextReserved = sumAmounts(credits.reserved, -reservation.amount);
|
|
1583
|
+
assertRepresentableFields(
|
|
1584
|
+
{ reserved: nextReserved },
|
|
1585
|
+
{ userId: reservation.userId, operation: `${transition.transition}Reservation` }
|
|
1586
|
+
);
|
|
1587
|
+
const existingEntryId = planTransitionJournal(store, journal);
|
|
1588
|
+
reservation.status = transition.status;
|
|
1589
|
+
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1590
|
+
credits.reserved = nextReserved;
|
|
1591
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1592
|
+
return commitTransitionJournal(store, journal, existingEntryId);
|
|
613
1593
|
}
|
|
614
1594
|
|
|
615
1595
|
// src/repository/memory/index.ts
|
|
1596
|
+
function isoOf(value) {
|
|
1597
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
1598
|
+
}
|
|
616
1599
|
var InMemoryCreditRepository = class {
|
|
617
|
-
|
|
618
|
-
reservations = /* @__PURE__ */ new Map();
|
|
619
|
-
transactions = /* @__PURE__ */ new Map();
|
|
620
|
-
usageLogs = [];
|
|
621
|
-
journalEntries = /* @__PURE__ */ new Map();
|
|
1600
|
+
store = new MemoryStore();
|
|
622
1601
|
// ==================== User Credits ====================
|
|
1602
|
+
/**
|
|
1603
|
+
* A copy, never the live record.
|
|
1604
|
+
*
|
|
1605
|
+
* Handing out the stored object made this adapter behave unlike any real
|
|
1606
|
+
* one: a caller holding a "before" snapshot saw it mutate under them. The
|
|
1607
|
+
* service's monthly reset compared `resetResult.credits.balance` against the
|
|
1608
|
+
* balance it had read moments earlier, got zero because both were the same
|
|
1609
|
+
* object, and silently skipped the reset's journal entry — while the SQL
|
|
1610
|
+
* adapter, which maps each row into a fresh object, wrote it.
|
|
1611
|
+
*/
|
|
623
1612
|
async getUserCredits(userId) {
|
|
624
|
-
|
|
1613
|
+
const credits = this.store.users.get(userId);
|
|
1614
|
+
return copyRecord(credits) ?? null;
|
|
625
1615
|
}
|
|
626
1616
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1617
|
+
return copyRecord(this.seedUser(userId, tier, initialBalance));
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* The synchronous body of `initializeUserCredits`.
|
|
1621
|
+
*
|
|
1622
|
+
* Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
|
|
1623
|
+
* its critical section, and an `await` between the payment-reference check
|
|
1624
|
+
* and the transaction write opens a gap another caller can run through — two
|
|
1625
|
+
* simultaneous first deliveries of the same reference would then both credit.
|
|
1626
|
+
*/
|
|
1627
|
+
seedUser(userId, tier, initialBalance) {
|
|
1628
|
+
assertRepresentableAmount(initialBalance, "initialBalance", { userId });
|
|
1629
|
+
const configured = getConfigMonthlyLimit(tier);
|
|
1630
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
|
|
1631
|
+
const monthlyLimit = storedMonthlyLimit(configured);
|
|
627
1632
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
628
1633
|
const credits = {
|
|
629
1634
|
userId,
|
|
@@ -631,61 +1636,93 @@ var InMemoryCreditRepository = class {
|
|
|
631
1636
|
bonusCredits: 0,
|
|
632
1637
|
reserved: 0,
|
|
633
1638
|
tier,
|
|
634
|
-
monthlyLimit
|
|
1639
|
+
monthlyLimit,
|
|
635
1640
|
monthlyUsed: 0,
|
|
636
1641
|
monthlyResetAt: getNextMonthlyReset().toISOString(),
|
|
637
1642
|
subscriptionExpiresAt: null,
|
|
638
1643
|
createdAt: now,
|
|
639
1644
|
updatedAt: now
|
|
640
1645
|
};
|
|
641
|
-
this.users.set(userId, credits);
|
|
1646
|
+
this.store.users.set(userId, credits);
|
|
642
1647
|
return credits;
|
|
643
1648
|
}
|
|
644
1649
|
async updateUserCredits(userId, updates) {
|
|
645
|
-
|
|
1650
|
+
assertRepresentableFields(
|
|
1651
|
+
{
|
|
1652
|
+
balance: updates.balance,
|
|
1653
|
+
bonusCredits: updates.bonusCredits,
|
|
1654
|
+
reserved: updates.reserved,
|
|
1655
|
+
monthlyLimit: updates.monthlyLimit,
|
|
1656
|
+
monthlyUsed: updates.monthlyUsed,
|
|
1657
|
+
balanceIncrement: updates.balanceIncrement,
|
|
1658
|
+
bonusCreditsIncrement: updates.bonusCreditsIncrement,
|
|
1659
|
+
reservedIncrement: updates.reservedIncrement,
|
|
1660
|
+
monthlyUsedIncrement: updates.monthlyUsedIncrement
|
|
1661
|
+
},
|
|
1662
|
+
{ userId, operation: "updateUserCredits" }
|
|
1663
|
+
);
|
|
1664
|
+
const credits = this.store.users.get(userId);
|
|
646
1665
|
if (!credits) {
|
|
647
1666
|
throw new Error(`User ${userId} not found`);
|
|
648
1667
|
}
|
|
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)
|
|
1668
|
+
const next = { ...credits };
|
|
1669
|
+
if (updates.balance !== void 0) next.balance = updates.balance;
|
|
1670
|
+
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1671
|
+
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1672
|
+
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
1673
|
+
if (updates.monthlyLimit !== void 0) next.monthlyLimit = updates.monthlyLimit;
|
|
1674
|
+
if (updates.monthlyUsed !== void 0) next.monthlyUsed = updates.monthlyUsed;
|
|
656
1675
|
if (updates.monthlyResetAt !== void 0) {
|
|
657
|
-
|
|
1676
|
+
next.monthlyResetAt = isoOf(updates.monthlyResetAt);
|
|
658
1677
|
}
|
|
659
1678
|
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
|
-
}
|
|
1679
|
+
next.subscriptionExpiresAt = updates.subscriptionExpiresAt === null ? null : isoOf(updates.subscriptionExpiresAt);
|
|
665
1680
|
}
|
|
666
1681
|
if (updates.balanceIncrement !== void 0) {
|
|
667
|
-
|
|
1682
|
+
next.balance = sumAmounts(next.balance, updates.balanceIncrement);
|
|
668
1683
|
}
|
|
669
1684
|
if (updates.bonusCreditsIncrement !== void 0) {
|
|
670
|
-
|
|
1685
|
+
next.bonusCredits = sumAmounts(next.bonusCredits, updates.bonusCreditsIncrement);
|
|
671
1686
|
}
|
|
672
1687
|
if (updates.reservedIncrement !== void 0) {
|
|
673
|
-
|
|
1688
|
+
next.reserved = sumAmounts(next.reserved, updates.reservedIncrement);
|
|
674
1689
|
}
|
|
675
1690
|
if (updates.monthlyUsedIncrement !== void 0) {
|
|
676
|
-
|
|
1691
|
+
next.monthlyUsed = sumAmounts(next.monthlyUsed, updates.monthlyUsedIncrement);
|
|
677
1692
|
}
|
|
678
|
-
|
|
679
|
-
|
|
1693
|
+
assertRepresentableFields(
|
|
1694
|
+
{
|
|
1695
|
+
balance: next.balance,
|
|
1696
|
+
bonusCredits: next.bonusCredits,
|
|
1697
|
+
reserved: next.reserved,
|
|
1698
|
+
monthlyUsed: next.monthlyUsed
|
|
1699
|
+
},
|
|
1700
|
+
{ userId, operation: "updateUserCredits" }
|
|
1701
|
+
);
|
|
1702
|
+
assertRepresentableTierAmount(next.monthlyLimit, "monthlyLimit", {
|
|
1703
|
+
userId,
|
|
1704
|
+
operation: "updateUserCredits"
|
|
1705
|
+
});
|
|
1706
|
+
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1707
|
+
this.store.users.set(userId, next);
|
|
680
1708
|
}
|
|
681
1709
|
async updateUserTier(userId, input) {
|
|
682
|
-
|
|
1710
|
+
assertRepresentableFields(
|
|
1711
|
+
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1712
|
+
{ userId, operation: "updateUserTier" }
|
|
1713
|
+
);
|
|
1714
|
+
const credits = this.store.users.get(userId);
|
|
683
1715
|
if (!credits) {
|
|
684
1716
|
throw new Error(`User ${userId} not found`);
|
|
685
1717
|
}
|
|
686
1718
|
credits.tier = input.tier;
|
|
687
1719
|
credits.monthlyLimit = input.monthlyLimit;
|
|
688
|
-
if (input.balance !== void 0)
|
|
1720
|
+
if (input.balance !== void 0) {
|
|
1721
|
+
credits.balance = Math.max(
|
|
1722
|
+
input.balance,
|
|
1723
|
+
backedBalanceFloor(credits.reserved, credits.bonusCredits)
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
689
1726
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
690
1727
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
691
1728
|
if (input.subscriptionExpiresAt === null) {
|
|
@@ -695,10 +1732,12 @@ var InMemoryCreditRepository = class {
|
|
|
695
1732
|
}
|
|
696
1733
|
}
|
|
697
1734
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
698
|
-
this.users.set(userId, credits);
|
|
1735
|
+
this.store.users.set(userId, credits);
|
|
699
1736
|
}
|
|
700
1737
|
// ==================== Reservations ====================
|
|
701
1738
|
async createReservation(input) {
|
|
1739
|
+
assertValidCreditAmount(input.amount, { userId: input.userId, operation: "createReservation" });
|
|
1740
|
+
assertUnkeyedDirectReservation(input);
|
|
702
1741
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
703
1742
|
const reservation = {
|
|
704
1743
|
id: generateId(),
|
|
@@ -709,19 +1748,19 @@ var InMemoryCreditRepository = class {
|
|
|
709
1748
|
createdAt: now,
|
|
710
1749
|
expiresAt: input.expiresAt.toISOString()
|
|
711
1750
|
};
|
|
712
|
-
if (!this.reservations.has(input.userId)) {
|
|
713
|
-
this.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
1751
|
+
if (!this.store.reservations.has(input.userId)) {
|
|
1752
|
+
this.store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
714
1753
|
}
|
|
715
|
-
this.reservations.get(input.userId).set(reservation.id, reservation);
|
|
716
|
-
return reservation;
|
|
1754
|
+
this.store.reservations.get(input.userId).set(reservation.id, reservation);
|
|
1755
|
+
return copyRecord(reservation);
|
|
717
1756
|
}
|
|
718
1757
|
async getReservation(userId, reservationId) {
|
|
719
|
-
const userReservations = this.reservations.get(userId);
|
|
1758
|
+
const userReservations = this.store.reservations.get(userId);
|
|
720
1759
|
if (!userReservations) return null;
|
|
721
|
-
return userReservations.get(reservationId) ?? null;
|
|
1760
|
+
return copyRecord(userReservations.get(reservationId)) ?? null;
|
|
722
1761
|
}
|
|
723
1762
|
async updateReservationStatus(userId, reservationId, status, completedAt) {
|
|
724
|
-
const userReservations = this.reservations.get(userId);
|
|
1763
|
+
const userReservations = this.store.reservations.get(userId);
|
|
725
1764
|
if (!userReservations) {
|
|
726
1765
|
throw new Error(`No reservations found for user ${userId}`);
|
|
727
1766
|
}
|
|
@@ -729,86 +1768,125 @@ var InMemoryCreditRepository = class {
|
|
|
729
1768
|
if (!reservation) {
|
|
730
1769
|
throw new Error(`Reservation ${reservationId} not found`);
|
|
731
1770
|
}
|
|
1771
|
+
assertDirectStatusWriteAllowed(reservation, status);
|
|
732
1772
|
reservation.status = status;
|
|
733
1773
|
if (completedAt) reservation.completedAt = completedAt.toISOString();
|
|
734
1774
|
userReservations.set(reservationId, reservation);
|
|
735
1775
|
}
|
|
736
1776
|
// ==================== Atomic Operations ====================
|
|
1777
|
+
// ==================== V2 boundary ====================
|
|
1778
|
+
//
|
|
1779
|
+
// As in the Drizzle adapter, V2 is the real implementation and the legacy
|
|
1780
|
+
// `*Atomic` methods are adapters over it, so both APIs share one set of
|
|
1781
|
+
// guarantees.
|
|
1782
|
+
async reserveCreditsV2(input) {
|
|
1783
|
+
return reserveCreditsV2(this.store, input);
|
|
1784
|
+
}
|
|
1785
|
+
async commitReservationV2(userId, reservationId, options) {
|
|
1786
|
+
return commitReservationV2(this.store, userId, reservationId, options);
|
|
1787
|
+
}
|
|
1788
|
+
async releaseReservationV2(userId, reservationId, options) {
|
|
1789
|
+
return releaseReservationV2(this.store, userId, reservationId, options);
|
|
1790
|
+
}
|
|
1791
|
+
async expireReservationV2(userId, reservationId, options) {
|
|
1792
|
+
return expireReservationV2(this.store, userId, reservationId, options);
|
|
1793
|
+
}
|
|
1794
|
+
// ==================== Legacy atomic operations ====================
|
|
737
1795
|
async reserveCreditsAtomic(userId, amount, operationType, expiresAt) {
|
|
738
|
-
const
|
|
739
|
-
if (
|
|
740
|
-
|
|
1796
|
+
const outcome = await this.reserveCreditsV2({ userId, amount, operationType, expiresAt });
|
|
1797
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
1798
|
+
return outcome.reservation;
|
|
741
1799
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
throw new Error(
|
|
745
|
-
`Insufficient credits. Available: ${available}, Required: ${amount}`
|
|
746
|
-
);
|
|
1800
|
+
if (outcome.outcome === "insufficient") {
|
|
1801
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
747
1802
|
}
|
|
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;
|
|
1803
|
+
throw createReservationAlreadyProcessedError(outcome.existing.id, outcome.existing.status);
|
|
758
1804
|
}
|
|
759
1805
|
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());
|
|
1806
|
+
const outcome = await this.commitReservationV2(userId, reservationId);
|
|
1807
|
+
if (outcome.outcome === "committed") return;
|
|
1808
|
+
if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
|
|
1809
|
+
if (outcome.terminalStatus === "committed") return;
|
|
1810
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
783
1811
|
}
|
|
784
1812
|
async releaseReservationAtomic(userId, reservationId) {
|
|
785
|
-
const
|
|
786
|
-
if (
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1813
|
+
const outcome = await this.releaseReservationV2(userId, reservationId);
|
|
1814
|
+
if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* The stored transaction carrying this reference, in *any* user's ledger.
|
|
1818
|
+
*
|
|
1819
|
+
* Deliberately not scoped to one user. The SQL adapter's unique index covers
|
|
1820
|
+
* `payment_ref` alone, so searching only the crediting user's own history made
|
|
1821
|
+
* the same reference credit two different accounts here while the SQL adapter
|
|
1822
|
+
* treated the second delivery as a duplicate. See `core/payment-ref.ts`.
|
|
1823
|
+
*/
|
|
1824
|
+
findByPaymentRef(paymentRef) {
|
|
1825
|
+
for (const recorded of this.store.transactions.values()) {
|
|
1826
|
+
const hit = recorded.find((transaction) => transaction.paymentRef === paymentRef);
|
|
1827
|
+
if (hit) return hit;
|
|
795
1828
|
}
|
|
796
|
-
|
|
797
|
-
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
798
|
-
this.users.set(userId, credits);
|
|
799
|
-
await this.updateReservationStatus(userId, reservationId, "released", /* @__PURE__ */ new Date());
|
|
1829
|
+
return void 0;
|
|
800
1830
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1831
|
+
/** The journal entry written alongside a stored purchase, if there is one. */
|
|
1832
|
+
journalForTransaction(transaction) {
|
|
1833
|
+
return this.store.journalEntries.get(transaction.userId)?.find((entry) => entry.referenceId === transaction.id);
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Credit an account, resolving `paymentRef` to created / replayed / conflict.
|
|
1837
|
+
*
|
|
1838
|
+
* The reference is checked and the transaction is recorded with no `await`
|
|
1839
|
+
* between them. `createTransaction` is `async` but its body is synchronous, so
|
|
1840
|
+
* under JavaScript's run-to-completion semantics no other caller can observe
|
|
1841
|
+
* the gap — which is what stands in for the SQL adapter's unique index.
|
|
1842
|
+
*
|
|
1843
|
+
* A `replayed` or `conflict` outcome writes nothing at all: the check runs
|
|
1844
|
+
* before the balance moves, not after.
|
|
1845
|
+
*/
|
|
1846
|
+
async addCreditsV2(input) {
|
|
1847
|
+
const { userId, amount, description, options } = input;
|
|
1848
|
+
assertValidCreditAmount(amount, { userId, operation: "addCredits" });
|
|
1849
|
+
const paymentRef = normalizePaymentRef(input.paymentRef);
|
|
1850
|
+
const payload = {
|
|
1851
|
+
userId,
|
|
1852
|
+
amount,
|
|
1853
|
+
type: "purchase",
|
|
1854
|
+
source: options?.source ?? "purchase",
|
|
1855
|
+
referenceType: options?.referenceType ?? "transaction"
|
|
1856
|
+
};
|
|
1857
|
+
if (paymentRef) {
|
|
1858
|
+
const existing = this.findByPaymentRef(paymentRef);
|
|
1859
|
+
if (existing) {
|
|
1860
|
+
const journal = this.journalForTransaction(existing);
|
|
1861
|
+
const mismatch = describePaymentMismatch(
|
|
1862
|
+
{
|
|
1863
|
+
userId: existing.userId,
|
|
1864
|
+
amount: existing.amount,
|
|
1865
|
+
type: existing.type,
|
|
1866
|
+
source: journal?.source,
|
|
1867
|
+
referenceType: journal?.referenceType
|
|
1868
|
+
},
|
|
1869
|
+
payload
|
|
1870
|
+
);
|
|
1871
|
+
return mismatch === null ? { outcome: "replayed", paymentRef, transaction: copyRecord(existing) } : { outcome: "conflict", paymentRef, existing: copyRecord(existing), mismatch };
|
|
1872
|
+
}
|
|
805
1873
|
}
|
|
806
|
-
|
|
807
|
-
|
|
1874
|
+
if (!this.store.users.has(userId)) {
|
|
1875
|
+
const tier = getDefaultTier();
|
|
1876
|
+
this.seedUser(userId, tier, storedMonthlyLimit(getConfigMonthlyLimit(tier)));
|
|
1877
|
+
}
|
|
1878
|
+
const credits = this.store.users.get(userId);
|
|
1879
|
+
const previousTotal = sumAmounts(credits.balance, credits.bonusCredits);
|
|
1880
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, amount);
|
|
1881
|
+
const newTotal = sumAmounts(credits.balance, nextBonusCredits);
|
|
1882
|
+
assertRepresentableFields(
|
|
1883
|
+
{ previousBalance: previousTotal, bonusCredits: nextBonusCredits, newBalance: newTotal },
|
|
1884
|
+
{ userId, operation: "addCredits" }
|
|
1885
|
+
);
|
|
1886
|
+
credits.bonusCredits = nextBonusCredits;
|
|
808
1887
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
809
|
-
this.users.set(userId, credits);
|
|
810
|
-
const
|
|
811
|
-
const transaction = await this.createTransaction({
|
|
1888
|
+
this.store.users.set(userId, credits);
|
|
1889
|
+
const transaction = this.recordTransaction({
|
|
812
1890
|
userId,
|
|
813
1891
|
type: "purchase",
|
|
814
1892
|
amount,
|
|
@@ -821,43 +1899,88 @@ var InMemoryCreditRepository = class {
|
|
|
821
1899
|
...paymentRef ? { paymentRef } : {},
|
|
822
1900
|
...options?.metadata ?? {}
|
|
823
1901
|
};
|
|
824
|
-
await this.createJournalEntry({
|
|
1902
|
+
const entry = await this.createJournalEntry({
|
|
825
1903
|
userId,
|
|
826
1904
|
entryType: "credit",
|
|
827
1905
|
amount,
|
|
828
1906
|
balanceAfter: newTotal,
|
|
829
|
-
source:
|
|
1907
|
+
source: payload.source,
|
|
830
1908
|
referenceId: transaction.id ?? paymentRef ?? "unknown",
|
|
831
|
-
referenceType:
|
|
1909
|
+
referenceType: payload.referenceType,
|
|
832
1910
|
description,
|
|
833
1911
|
metadata: Object.keys(journalMetadata).length > 0 ? journalMetadata : void 0
|
|
834
1912
|
});
|
|
1913
|
+
return paymentRef ? { outcome: "created", paymentRef, transaction, journalEntryId: entry.id } : { outcome: "created", transaction, journalEntryId: entry.id };
|
|
835
1914
|
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1915
|
+
/**
|
|
1916
|
+
* The legacy signature, on top of {@link addCreditsV2}.
|
|
1917
|
+
*
|
|
1918
|
+
* `Promise<void>` has nowhere to report a conflict, and silently returning is
|
|
1919
|
+
* indistinguishable from "credited" — the exact failure this round is closing
|
|
1920
|
+
* — so a conflict throws. A genuine replay still returns quietly, because
|
|
1921
|
+
* that *is* the idempotent no-op the caller asked for.
|
|
1922
|
+
*/
|
|
1923
|
+
async addCreditsAtomic(userId, amount, description, paymentRef, options) {
|
|
1924
|
+
const outcome = await this.addCreditsV2({
|
|
1925
|
+
userId,
|
|
1926
|
+
amount,
|
|
1927
|
+
description,
|
|
1928
|
+
paymentRef,
|
|
1929
|
+
options
|
|
1930
|
+
});
|
|
1931
|
+
if (outcome.outcome === "conflict") {
|
|
1932
|
+
throw createPaymentRefConflictError(outcome.paymentRef, {
|
|
1933
|
+
userId,
|
|
1934
|
+
mismatch: outcome.mismatch,
|
|
1935
|
+
existingUserId: outcome.existing.userId
|
|
1936
|
+
});
|
|
839
1937
|
}
|
|
840
|
-
|
|
1938
|
+
}
|
|
1939
|
+
async deductCreditsAtomic(userId, amount) {
|
|
1940
|
+
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1941
|
+
const credits = this.store.users.get(userId);
|
|
841
1942
|
if (!credits) {
|
|
842
1943
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
843
1944
|
}
|
|
844
|
-
const available = credits.balance
|
|
1945
|
+
const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
|
|
845
1946
|
if (available < amount) {
|
|
846
1947
|
throw new Error(
|
|
847
1948
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
848
1949
|
);
|
|
849
1950
|
}
|
|
850
1951
|
const balanceDeduction = Math.min(credits.balance, amount);
|
|
851
|
-
const bonusDeduction = amount -
|
|
852
|
-
const previousBalance = credits.balance
|
|
853
|
-
credits.balance
|
|
854
|
-
credits.bonusCredits
|
|
1952
|
+
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
1953
|
+
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
1954
|
+
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
1955
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, -bonusDeduction);
|
|
1956
|
+
const newBalance = sumAmounts(previousBalance, -amount);
|
|
1957
|
+
assertRepresentableFields(
|
|
1958
|
+
{ previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
|
|
1959
|
+
{ userId, operation: "deductCredits" }
|
|
1960
|
+
);
|
|
1961
|
+
credits.balance = nextBalance;
|
|
1962
|
+
credits.bonusCredits = nextBonusCredits;
|
|
855
1963
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
856
|
-
this.users.set(userId, credits);
|
|
857
|
-
return { previousBalance, newBalance
|
|
1964
|
+
this.store.users.set(userId, credits);
|
|
1965
|
+
return { previousBalance, newBalance };
|
|
858
1966
|
}
|
|
859
1967
|
// ==================== Transactions ====================
|
|
860
1968
|
async createTransaction(input) {
|
|
1969
|
+
assertUnreferencedDirectTransaction(input);
|
|
1970
|
+
return this.recordTransaction({
|
|
1971
|
+
...input,
|
|
1972
|
+
paymentRef: normalizePaymentRef(input.paymentRef)
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
/** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
|
|
1976
|
+
recordTransaction(input) {
|
|
1977
|
+
assertRepresentableAmount(input.amount, "transaction amount", { userId: input.userId });
|
|
1978
|
+
assertRepresentableAmount(input.previousBalance, "transaction previousBalance", {
|
|
1979
|
+
userId: input.userId
|
|
1980
|
+
});
|
|
1981
|
+
assertRepresentableAmount(input.newBalance, "transaction newBalance", {
|
|
1982
|
+
userId: input.userId
|
|
1983
|
+
});
|
|
861
1984
|
const transaction = {
|
|
862
1985
|
id: generateId(),
|
|
863
1986
|
userId: input.userId,
|
|
@@ -869,23 +1992,24 @@ var InMemoryCreditRepository = class {
|
|
|
869
1992
|
newBalance: input.newBalance,
|
|
870
1993
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
871
1994
|
};
|
|
872
|
-
if (!this.transactions.has(input.userId)) {
|
|
873
|
-
this.transactions.set(input.userId, []);
|
|
1995
|
+
if (!this.store.transactions.has(input.userId)) {
|
|
1996
|
+
this.store.transactions.set(input.userId, []);
|
|
874
1997
|
}
|
|
875
|
-
this.transactions.get(input.userId).push(transaction);
|
|
876
|
-
return transaction;
|
|
1998
|
+
this.store.transactions.get(input.userId).push(transaction);
|
|
1999
|
+
return copyRecord(transaction);
|
|
877
2000
|
}
|
|
878
2001
|
async getTransactions(userId, limit = 50, offset = 0) {
|
|
879
|
-
const userTransactions = this.transactions.get(userId) ?? [];
|
|
2002
|
+
const userTransactions = this.store.transactions.get(userId) ?? [];
|
|
880
2003
|
const sorted = [...userTransactions].sort((a, b) => {
|
|
881
2004
|
const aDate = toDate2(a.createdAt).getTime();
|
|
882
2005
|
const bDate = toDate2(b.createdAt).getTime();
|
|
883
2006
|
return bDate - aDate;
|
|
884
2007
|
});
|
|
885
|
-
return sorted.slice(offset, offset + limit);
|
|
2008
|
+
return copyRecords(sorted.slice(offset, offset + limit));
|
|
886
2009
|
}
|
|
887
2010
|
// ==================== Usage Logs ====================
|
|
888
2011
|
async logUsage(input) {
|
|
2012
|
+
assertRepresentableAmount(input.creditsUsed, "creditsUsed", { userId: input.userId });
|
|
889
2013
|
const log = {
|
|
890
2014
|
id: generateId(),
|
|
891
2015
|
userId: input.userId,
|
|
@@ -897,14 +2021,14 @@ var InMemoryCreditRepository = class {
|
|
|
897
2021
|
resourceId: input.resourceId,
|
|
898
2022
|
resourceType: input.resourceType,
|
|
899
2023
|
requestId: input.requestId,
|
|
900
|
-
metadata: input.metadata,
|
|
2024
|
+
metadata: input.metadata ? { ...input.metadata } : input.metadata,
|
|
901
2025
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
902
2026
|
};
|
|
903
|
-
this.usageLogs.push(log);
|
|
904
|
-
return log;
|
|
2027
|
+
this.store.usageLogs.push(log);
|
|
2028
|
+
return copyRecord(log);
|
|
905
2029
|
}
|
|
906
2030
|
async getUsageLogs(query) {
|
|
907
|
-
let results = [...this.usageLogs];
|
|
2031
|
+
let results = [...this.store.usageLogs];
|
|
908
2032
|
if (query.userId) {
|
|
909
2033
|
results = results.filter((log) => log.userId === query.userId);
|
|
910
2034
|
}
|
|
@@ -931,7 +2055,7 @@ var InMemoryCreditRepository = class {
|
|
|
931
2055
|
});
|
|
932
2056
|
const offset = query.offset ?? 0;
|
|
933
2057
|
const limit = query.limit ?? 50;
|
|
934
|
-
return results.slice(offset, offset + limit);
|
|
2058
|
+
return copyRecords(results.slice(offset, offset + limit));
|
|
935
2059
|
}
|
|
936
2060
|
async getUsageLogsCount(query) {
|
|
937
2061
|
const results = await this.getUsageLogs({ ...query, limit: Infinity, offset: 0 });
|
|
@@ -939,6 +2063,29 @@ var InMemoryCreditRepository = class {
|
|
|
939
2063
|
}
|
|
940
2064
|
// ==================== Journal Entries ====================
|
|
941
2065
|
async createJournalEntry(input) {
|
|
2066
|
+
return this.recordJournalEntry(input);
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
|
|
2070
|
+
* write its journal line inside the same run-to-completion step as the
|
|
2071
|
+
* balance write — an `await` between the two is exactly the gap that loses
|
|
2072
|
+
* the line when the second half fails.
|
|
2073
|
+
*/
|
|
2074
|
+
recordJournalEntry(input) {
|
|
2075
|
+
assertRepresentableAmount(input.amount, "journal amount", { userId: input.userId });
|
|
2076
|
+
assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
|
|
2077
|
+
userId: input.userId
|
|
2078
|
+
});
|
|
2079
|
+
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
2080
|
+
assertPublicJournalKey(input.idempotencyKey, input.userId);
|
|
2081
|
+
const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
|
|
2082
|
+
if (key !== void 0 && this.store.journalKeys.has(key)) {
|
|
2083
|
+
throw new CreditError(
|
|
2084
|
+
`Journal idempotency key ${input.idempotencyKey} is already used for user ${input.userId}. The unique constraint refuses a second entry.`,
|
|
2085
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
2086
|
+
{ userId: input.userId, idempotencyKey: input.idempotencyKey }
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
942
2089
|
const entry = {
|
|
943
2090
|
id: generateId(),
|
|
944
2091
|
userId: input.userId,
|
|
@@ -949,17 +2096,19 @@ var InMemoryCreditRepository = class {
|
|
|
949
2096
|
referenceId: input.referenceId,
|
|
950
2097
|
referenceType: input.referenceType,
|
|
951
2098
|
description: input.description,
|
|
952
|
-
metadata: input.metadata,
|
|
2099
|
+
metadata: input.metadata ? { ...input.metadata } : input.metadata,
|
|
2100
|
+
idempotencyKey: input.idempotencyKey,
|
|
953
2101
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
954
2102
|
};
|
|
955
|
-
if (!this.journalEntries.has(input.userId)) {
|
|
956
|
-
this.journalEntries.set(input.userId, []);
|
|
2103
|
+
if (!this.store.journalEntries.has(input.userId)) {
|
|
2104
|
+
this.store.journalEntries.set(input.userId, []);
|
|
957
2105
|
}
|
|
958
|
-
this.journalEntries.get(input.userId).push(entry);
|
|
959
|
-
|
|
2106
|
+
this.store.journalEntries.get(input.userId).push(entry);
|
|
2107
|
+
if (key !== void 0) this.store.journalKeys.set(key, entry.id);
|
|
2108
|
+
return copyRecord(entry);
|
|
960
2109
|
}
|
|
961
2110
|
async getJournalEntries(query) {
|
|
962
|
-
let results = this.journalEntries.get(query.userId) ?? [];
|
|
2111
|
+
let results = this.store.journalEntries.get(query.userId) ?? [];
|
|
963
2112
|
if (query.source) {
|
|
964
2113
|
results = results.filter((entry) => entry.source === query.source);
|
|
965
2114
|
}
|
|
@@ -983,7 +2132,7 @@ var InMemoryCreditRepository = class {
|
|
|
983
2132
|
});
|
|
984
2133
|
const offset = query.offset ?? 0;
|
|
985
2134
|
const limit = query.limit ?? 50;
|
|
986
|
-
return results.slice(offset, offset + limit);
|
|
2135
|
+
return copyRecords(results.slice(offset, offset + limit));
|
|
987
2136
|
}
|
|
988
2137
|
async getJournalEntriesCount(query) {
|
|
989
2138
|
const results = await this.getJournalEntries({
|
|
@@ -994,53 +2143,91 @@ var InMemoryCreditRepository = class {
|
|
|
994
2143
|
return results.length;
|
|
995
2144
|
}
|
|
996
2145
|
// ==================== Cleanup Operations ====================
|
|
2146
|
+
/**
|
|
2147
|
+
* Expire every hold whose deadline has passed.
|
|
2148
|
+
*
|
|
2149
|
+
* Each candidate goes through the guarded {@link expireReservationV2}, which
|
|
2150
|
+
* re-checks the status and the deadline under the user's lock. The previous
|
|
2151
|
+
* implementation released the hold and then overwrote the status to
|
|
2152
|
+
* `expired`, so a commit landing in between got its credits handed back and
|
|
2153
|
+
* was then relabelled.
|
|
2154
|
+
*/
|
|
997
2155
|
async findAndExpireReservations(_batchSize = 100, _maxIterations = 100) {
|
|
998
|
-
const
|
|
2156
|
+
const asOf = /* @__PURE__ */ new Date();
|
|
999
2157
|
let expiredCount = 0;
|
|
1000
2158
|
let creditsReleased = 0;
|
|
1001
2159
|
const errors = [];
|
|
1002
|
-
|
|
2160
|
+
const candidates = [];
|
|
2161
|
+
for (const [userId, userReservations] of this.store.reservations) {
|
|
1003
2162
|
for (const [reservationId, reservation] of userReservations) {
|
|
1004
|
-
if (reservation.status === "reserved" && toDate2(reservation.expiresAt).getTime() <
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
2163
|
+
if (reservation.status === "reserved" && toDate2(reservation.expiresAt).getTime() < asOf.getTime()) {
|
|
2164
|
+
candidates.push({ userId, reservationId });
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
for (const candidate of candidates) {
|
|
2169
|
+
try {
|
|
2170
|
+
const outcome = await this.expireReservationV2(
|
|
2171
|
+
candidate.userId,
|
|
2172
|
+
candidate.reservationId,
|
|
2173
|
+
{ asOf }
|
|
2174
|
+
);
|
|
2175
|
+
if (outcome.outcome === "expired") {
|
|
2176
|
+
expiredCount++;
|
|
2177
|
+
creditsReleased = sumAmounts(creditsReleased, outcome.amount);
|
|
1016
2178
|
}
|
|
2179
|
+
} catch (error) {
|
|
2180
|
+
errors.push(
|
|
2181
|
+
`Failed to expire reservation ${candidate.reservationId}: ${error}`
|
|
2182
|
+
);
|
|
1017
2183
|
}
|
|
1018
2184
|
}
|
|
1019
2185
|
return { expiredCount, creditsReleased, errors };
|
|
1020
2186
|
}
|
|
1021
2187
|
// ==================== Atomic Monthly Reset ====================
|
|
1022
2188
|
async atomicMonthlyReset(userId, tier, expectedResetAt) {
|
|
1023
|
-
const credits = this.users.get(userId);
|
|
2189
|
+
const credits = this.store.users.get(userId);
|
|
1024
2190
|
if (!credits) {
|
|
1025
2191
|
throw new Error(`User ${userId} not found`);
|
|
1026
2192
|
}
|
|
1027
2193
|
const currentResetAt = toDate2(credits.monthlyResetAt).getTime();
|
|
1028
2194
|
const expected = toDate2(expectedResetAt).getTime();
|
|
1029
2195
|
if (currentResetAt !== expected) {
|
|
1030
|
-
return { wasReset: false, credits };
|
|
2196
|
+
return { wasReset: false, credits: { ...credits } };
|
|
1031
2197
|
}
|
|
1032
|
-
|
|
2198
|
+
assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
|
|
1033
2199
|
const nextReset = getNextMonthlyReset();
|
|
1034
|
-
|
|
2200
|
+
const target = monthlyResetBalance(tier);
|
|
2201
|
+
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
2202
|
+
const previousBalance = credits.balance;
|
|
2203
|
+
const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
|
|
2204
|
+
const change = sumAmounts(newBalance, -previousBalance);
|
|
2205
|
+
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2206
|
+
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
2207
|
+
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2208
|
+
credits.balance = newBalance;
|
|
1035
2209
|
credits.monthlyUsed = 0;
|
|
1036
2210
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
1037
2211
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1038
|
-
this.users.set(userId, credits);
|
|
1039
|
-
|
|
2212
|
+
this.store.users.set(userId, credits);
|
|
2213
|
+
if (change !== 0) {
|
|
2214
|
+
this.recordJournalEntry({
|
|
2215
|
+
userId,
|
|
2216
|
+
entryType: change > 0 ? "credit" : "debit",
|
|
2217
|
+
amount: Math.abs(change),
|
|
2218
|
+
balanceAfter,
|
|
2219
|
+
source: "monthly_reset",
|
|
2220
|
+
referenceId: `reset-${Date.now()}`,
|
|
2221
|
+
referenceType: "reset",
|
|
2222
|
+
description: `Monthly credit reset for ${tier} tier.`,
|
|
2223
|
+
metadata: { tier, previousBalance, newBalance }
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
return { wasReset: true, credits: { ...credits }, journaled: true };
|
|
1040
2227
|
}
|
|
1041
2228
|
// ==================== Subscription Expiry ====================
|
|
1042
2229
|
async checkAndHandleSubscriptionExpiry(userId, gracePeriodDays = 3) {
|
|
1043
|
-
const credits = this.users.get(userId);
|
|
2230
|
+
const credits = this.store.users.get(userId);
|
|
1044
2231
|
if (!credits) {
|
|
1045
2232
|
throw new Error(`User ${userId} not found`);
|
|
1046
2233
|
}
|
|
@@ -1049,7 +2236,7 @@ var InMemoryCreditRepository = class {
|
|
|
1049
2236
|
wasDowngraded: false,
|
|
1050
2237
|
inGracePeriod: false,
|
|
1051
2238
|
graceDaysRemaining: 0,
|
|
1052
|
-
credits
|
|
2239
|
+
credits: { ...credits }
|
|
1053
2240
|
};
|
|
1054
2241
|
}
|
|
1055
2242
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -1060,7 +2247,7 @@ var InMemoryCreditRepository = class {
|
|
|
1060
2247
|
wasDowngraded: false,
|
|
1061
2248
|
inGracePeriod: false,
|
|
1062
2249
|
graceDaysRemaining: 0,
|
|
1063
|
-
credits
|
|
2250
|
+
credits: { ...credits }
|
|
1064
2251
|
};
|
|
1065
2252
|
}
|
|
1066
2253
|
if (daysSinceExpiry <= gracePeriodDays) {
|
|
@@ -1068,22 +2255,48 @@ var InMemoryCreditRepository = class {
|
|
|
1068
2255
|
wasDowngraded: false,
|
|
1069
2256
|
inGracePeriod: true,
|
|
1070
2257
|
graceDaysRemaining: Math.ceil(gracePeriodDays - daysSinceExpiry),
|
|
1071
|
-
credits
|
|
2258
|
+
credits: { ...credits }
|
|
1072
2259
|
};
|
|
1073
2260
|
}
|
|
1074
2261
|
const defaultTier = getDefaultTier();
|
|
1075
|
-
const
|
|
2262
|
+
const configured = getConfigMonthlyLimit(defaultTier);
|
|
2263
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", {
|
|
2264
|
+
userId,
|
|
2265
|
+
tier: defaultTier
|
|
2266
|
+
});
|
|
2267
|
+
const limit = storedMonthlyLimit(configured);
|
|
2268
|
+
const previousTier = credits.tier;
|
|
2269
|
+
const previousBalance = credits.balance;
|
|
2270
|
+
const newBalance = Math.max(
|
|
2271
|
+
Math.min(credits.balance, limit),
|
|
2272
|
+
backedBalanceFloor(credits.reserved, credits.bonusCredits)
|
|
2273
|
+
);
|
|
2274
|
+
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2275
|
+
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
1076
2276
|
credits.tier = defaultTier;
|
|
1077
|
-
credits.monthlyLimit =
|
|
1078
|
-
credits.balance =
|
|
2277
|
+
credits.monthlyLimit = limit;
|
|
2278
|
+
credits.balance = newBalance;
|
|
1079
2279
|
credits.subscriptionExpiresAt = null;
|
|
1080
2280
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1081
|
-
this.users.set(userId, credits);
|
|
2281
|
+
this.store.users.set(userId, credits);
|
|
2282
|
+
this.recordJournalEntry({
|
|
2283
|
+
userId,
|
|
2284
|
+
entryType: "debit",
|
|
2285
|
+
amount: 0,
|
|
2286
|
+
// No credits deducted, just tier change
|
|
2287
|
+
balanceAfter,
|
|
2288
|
+
source: "subscription_downgrade",
|
|
2289
|
+
referenceId: `downgrade-${Date.now()}`,
|
|
2290
|
+
referenceType: "subscription",
|
|
2291
|
+
description: `Subscription expired. Downgraded from ${previousTier} to ${defaultTier} tier.`,
|
|
2292
|
+
metadata: { previousTier, previousBalance, newBalance }
|
|
2293
|
+
});
|
|
1082
2294
|
return {
|
|
1083
2295
|
wasDowngraded: true,
|
|
1084
2296
|
inGracePeriod: false,
|
|
1085
2297
|
graceDaysRemaining: 0,
|
|
1086
|
-
credits
|
|
2298
|
+
credits: { ...credits },
|
|
2299
|
+
journaled: true
|
|
1087
2300
|
};
|
|
1088
2301
|
}
|
|
1089
2302
|
// ==================== Testing Utilities ====================
|
|
@@ -1091,25 +2304,33 @@ var InMemoryCreditRepository = class {
|
|
|
1091
2304
|
* Clear all data (useful for testing)
|
|
1092
2305
|
*/
|
|
1093
2306
|
clear() {
|
|
1094
|
-
this.
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
2307
|
+
this.store.clear();
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Install a yield point inside every V2 critical section (testing only).
|
|
2311
|
+
*
|
|
2312
|
+
* Unset, the critical sections run start-to-finish synchronously, so
|
|
2313
|
+
* concurrent callers never interleave and a concurrency test would pass even
|
|
2314
|
+
* with the locking removed. Setting this to a real yield (a macrotask or a
|
|
2315
|
+
* barrier) makes callers genuinely overlap, so the tests exercise the lock
|
|
2316
|
+
* instead of the event loop. Never call this in production code.
|
|
2317
|
+
*/
|
|
2318
|
+
setSchedulingHook(hook) {
|
|
2319
|
+
this.store.schedulingHook = hook;
|
|
1099
2320
|
}
|
|
1100
2321
|
/**
|
|
1101
2322
|
* Get all users (useful for testing/debugging)
|
|
1102
2323
|
*/
|
|
1103
2324
|
getAllUsers() {
|
|
1104
|
-
return Array.from(this.users.values());
|
|
2325
|
+
return copyRecords(Array.from(this.store.users.values()));
|
|
1105
2326
|
}
|
|
1106
2327
|
/**
|
|
1107
2328
|
* Get all reservations for a user (useful for testing)
|
|
1108
2329
|
*/
|
|
1109
2330
|
getAllReservations(userId) {
|
|
1110
|
-
const userReservations = this.reservations.get(userId);
|
|
2331
|
+
const userReservations = this.store.reservations.get(userId);
|
|
1111
2332
|
if (!userReservations) return [];
|
|
1112
|
-
return Array.from(userReservations.values());
|
|
2333
|
+
return copyRecords(Array.from(userReservations.values()));
|
|
1113
2334
|
}
|
|
1114
2335
|
};
|
|
1115
2336
|
function createInMemoryCreditRepository() {
|
|
@@ -1220,28 +2441,38 @@ var CreditsService = class {
|
|
|
1220
2441
|
userId,
|
|
1221
2442
|
getConfig().subscriptionGracePeriodDays
|
|
1222
2443
|
);
|
|
1223
|
-
if (expiryResult.wasDowngraded) {
|
|
2444
|
+
if (expiryResult.wasDowngraded && !expiryResult.journaled) {
|
|
1224
2445
|
await this.repository.createJournalEntry({
|
|
1225
2446
|
userId,
|
|
1226
2447
|
entryType: "debit",
|
|
1227
2448
|
amount: 0,
|
|
1228
2449
|
// No credits deducted, just tier change
|
|
1229
|
-
|
|
2450
|
+
// The ledger balance, matching every other journal writer: the V2
|
|
2451
|
+
// transitions and add/deduct all record `balance + bonusCredits`, so
|
|
2452
|
+
// recording `balance` alone made the audit trail disagree with itself
|
|
2453
|
+
// for any user holding bonus credits.
|
|
2454
|
+
balanceAfter: sumAmounts(
|
|
2455
|
+
expiryResult.credits.balance,
|
|
2456
|
+
expiryResult.credits.bonusCredits
|
|
2457
|
+
),
|
|
1230
2458
|
source: "subscription_downgrade",
|
|
1231
2459
|
referenceId: `downgrade-${Date.now()}`,
|
|
1232
2460
|
referenceType: "subscription",
|
|
1233
|
-
|
|
2461
|
+
// The tier actually landed on, not a hard-coded "free": the downgrade
|
|
2462
|
+
// target is configurable, so naming `free` here could describe a
|
|
2463
|
+
// transition that never happened.
|
|
2464
|
+
description: `Subscription expired. Downgraded from ${data.tier} to ${expiryResult.credits.tier} tier.`,
|
|
1234
2465
|
metadata: {
|
|
1235
2466
|
previousTier: data.tier,
|
|
1236
2467
|
previousBalance: data.balance,
|
|
1237
2468
|
newBalance: expiryResult.credits.balance
|
|
1238
2469
|
}
|
|
1239
2470
|
});
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
}
|
|
2471
|
+
}
|
|
2472
|
+
if (expiryResult.wasDowngraded && this.subscriptionExpiredCallback) {
|
|
2473
|
+
this.subscriptionExpiredCallback(userId, true).catch((error) => {
|
|
2474
|
+
console.error("[Credits] Failed to send subscription expired notification:", error);
|
|
2475
|
+
});
|
|
1245
2476
|
}
|
|
1246
2477
|
data = expiryResult.credits;
|
|
1247
2478
|
}
|
|
@@ -1252,14 +2483,17 @@ var CreditsService = class {
|
|
|
1252
2483
|
data.tier,
|
|
1253
2484
|
expectedResetAt
|
|
1254
2485
|
);
|
|
1255
|
-
if (resetResult.wasReset) {
|
|
1256
|
-
const balanceChange = resetResult.credits.balance -
|
|
2486
|
+
if (resetResult.wasReset && !resetResult.journaled) {
|
|
2487
|
+
const balanceChange = sumAmounts(resetResult.credits.balance, -data.balance);
|
|
1257
2488
|
if (balanceChange !== 0) {
|
|
1258
2489
|
await this.repository.createJournalEntry({
|
|
1259
2490
|
userId,
|
|
1260
2491
|
entryType: balanceChange > 0 ? "credit" : "debit",
|
|
1261
2492
|
amount: Math.abs(balanceChange),
|
|
1262
|
-
balanceAfter:
|
|
2493
|
+
balanceAfter: sumAmounts(
|
|
2494
|
+
resetResult.credits.balance,
|
|
2495
|
+
resetResult.credits.bonusCredits
|
|
2496
|
+
),
|
|
1263
2497
|
source: "monthly_reset",
|
|
1264
2498
|
referenceId: `reset-${Date.now()}`,
|
|
1265
2499
|
referenceType: "reset",
|
|
@@ -1310,14 +2544,14 @@ var CreditsService = class {
|
|
|
1310
2544
|
*/
|
|
1311
2545
|
async checkCredits(userId, requiredCredits) {
|
|
1312
2546
|
const credits = await this.getOrCreateUserCredits(userId);
|
|
1313
|
-
const totalBalance = credits.balance
|
|
1314
|
-
const available = totalBalance -
|
|
2547
|
+
const totalBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
2548
|
+
const available = sumAmounts(totalBalance, -credits.reserved);
|
|
1315
2549
|
const hasCredits = available >= requiredCredits;
|
|
1316
2550
|
return {
|
|
1317
2551
|
hasCredits,
|
|
1318
2552
|
balance: totalBalance,
|
|
1319
2553
|
required: requiredCredits,
|
|
1320
|
-
shortfall: hasCredits ? 0 : requiredCredits -
|
|
2554
|
+
shortfall: hasCredits ? 0 : sumAmounts(requiredCredits, -available)
|
|
1321
2555
|
};
|
|
1322
2556
|
}
|
|
1323
2557
|
/**
|
|
@@ -1331,74 +2565,110 @@ var CreditsService = class {
|
|
|
1331
2565
|
* @throws Error if insufficient credits
|
|
1332
2566
|
*/
|
|
1333
2567
|
async reserveCredits(userId, amount, operationType, options) {
|
|
2568
|
+
const outcome = await this.reserveCreditsDetailed(userId, amount, operationType, options);
|
|
2569
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
2570
|
+
return outcome.reservation;
|
|
2571
|
+
}
|
|
2572
|
+
if (outcome.outcome === "insufficient") {
|
|
2573
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
2574
|
+
}
|
|
2575
|
+
throw createIdempotencyConflictError(outcome.idempotencyKey, {
|
|
2576
|
+
userId,
|
|
2577
|
+
requested: { amount, operationType },
|
|
2578
|
+
existing: {
|
|
2579
|
+
amount: outcome.existing.amount,
|
|
2580
|
+
operationType: outcome.existing.operationType
|
|
2581
|
+
}
|
|
2582
|
+
});
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* Reserve credits and get the typed outcome instead of an exception.
|
|
2586
|
+
*
|
|
2587
|
+
* Prefer this over {@link reserveCredits} in async callers: it distinguishes
|
|
2588
|
+
* a fresh hold from an idempotent replay, and reports insufficient funds as
|
|
2589
|
+
* a value rather than as control flow.
|
|
2590
|
+
*/
|
|
2591
|
+
async reserveCreditsDetailed(userId, amount, operationType, options) {
|
|
1334
2592
|
const ttlMs = options?.ttlMs ?? getConfig().reservationExpiryMs;
|
|
1335
2593
|
const expiresAt = new Date(Date.now() + ttlMs);
|
|
1336
|
-
return this.repository
|
|
2594
|
+
return reserveThroughRepository(this.repository, {
|
|
2595
|
+
userId,
|
|
2596
|
+
amount,
|
|
2597
|
+
operationType,
|
|
2598
|
+
expiresAt,
|
|
2599
|
+
idempotencyKey: options?.idempotencyKey
|
|
2600
|
+
});
|
|
1337
2601
|
}
|
|
1338
2602
|
/**
|
|
1339
|
-
* Commit a reservation (phase 2 of two-phase commit - success)
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
2603
|
+
* Commit a reservation (phase 2 of two-phase commit - success).
|
|
2604
|
+
*
|
|
2605
|
+
* Idempotent: re-delivering a commit for an already-committed reservation is
|
|
2606
|
+
* a no-op. Committing one that was released or expired throws, because the
|
|
2607
|
+
* credits are no longer held and pretending otherwise would hide a real bug.
|
|
1342
2608
|
*/
|
|
1343
|
-
async commitCredits(userId, reservationId) {
|
|
1344
|
-
const
|
|
1345
|
-
if (
|
|
1346
|
-
throw
|
|
2609
|
+
async commitCredits(userId, reservationId, options) {
|
|
2610
|
+
const outcome = await this.commitCreditsDetailed(userId, reservationId, options);
|
|
2611
|
+
if (outcome.outcome === "not_found") {
|
|
2612
|
+
throw createReservationNotFoundError(reservationId);
|
|
1347
2613
|
}
|
|
1348
|
-
if (
|
|
1349
|
-
|
|
2614
|
+
if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
|
|
2615
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
1350
2616
|
}
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
2617
|
+
}
|
|
2618
|
+
/**
|
|
2619
|
+
* Commit a reservation and get the typed outcome.
|
|
2620
|
+
*
|
|
2621
|
+
* Exactly one concurrent caller sees `committed`; that call moved the balance
|
|
2622
|
+
* and wrote the single journal entry. On a V2 repository the journal is
|
|
2623
|
+
* written inside the same transaction, so this method adds none of its own —
|
|
2624
|
+
* the duplicate entry the previous implementation wrote is gone.
|
|
2625
|
+
*
|
|
2626
|
+
* The low-balance notification fires only for the winner, and only after the
|
|
2627
|
+
* transaction has committed, so no callback runs inside a database
|
|
2628
|
+
* transaction.
|
|
2629
|
+
*/
|
|
2630
|
+
async commitCreditsDetailed(userId, reservationId, options) {
|
|
2631
|
+
const outcome = await commitThroughRepository(
|
|
2632
|
+
this.repository,
|
|
2633
|
+
userId,
|
|
2634
|
+
reservationId,
|
|
2635
|
+
options
|
|
2636
|
+
);
|
|
2637
|
+
if (outcome.outcome === "committed" && this.lowBalanceCallback) {
|
|
2638
|
+
this.lowBalanceCallback(userId, outcome.balanceAfter).catch((error) => {
|
|
2639
|
+
console.error("[Credits] Failed to send low balance notification:", error);
|
|
1366
2640
|
});
|
|
1367
|
-
if (this.lowBalanceCallback) {
|
|
1368
|
-
this.lowBalanceCallback(userId, credits.balance).catch((error) => {
|
|
1369
|
-
console.error("[Credits] Failed to send low balance notification:", error);
|
|
1370
|
-
});
|
|
1371
|
-
}
|
|
1372
2641
|
}
|
|
2642
|
+
return outcome;
|
|
1373
2643
|
}
|
|
1374
2644
|
/**
|
|
1375
|
-
* Release a reservation (phase 2 of two-phase commit - failure)
|
|
1376
|
-
*
|
|
2645
|
+
* Release a reservation (phase 2 of two-phase commit - failure).
|
|
2646
|
+
*
|
|
2647
|
+
* Throws `RESERVATION_NOT_FOUND` for an unknown reservation, matching the
|
|
2648
|
+
* pre-V2 behaviour — a release naming a reservation that does not exist is a
|
|
2649
|
+
* caller bug, and swallowing it hides the bug while the credits stay held.
|
|
2650
|
+
*
|
|
2651
|
+
* Releasing one that is already terminal — `released`, `expired`, or
|
|
2652
|
+
* `committed` — is a no-op, which is the behaviour this method has always
|
|
2653
|
+
* had. A release that loses a race to a concurrent commit is the common
|
|
2654
|
+
* case in a retry-heavy caller, and callers written against the pre-V2
|
|
2655
|
+
* contract handle it by not handling it.
|
|
2656
|
+
*
|
|
2657
|
+
* That does mean this method cannot tell you that the credits were spent
|
|
2658
|
+
* rather than returned. Use {@link releaseCreditsDetailed}, which reports
|
|
2659
|
+
* `already_terminal` with `terminalStatus: 'committed'`, when you need to
|
|
2660
|
+
* branch on it.
|
|
1377
2661
|
*/
|
|
1378
|
-
async releaseCredits(userId, reservationId) {
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
const credits = await this.repository.getUserCredits(userId);
|
|
1383
|
-
if (credits) {
|
|
1384
|
-
await this.repository.createJournalEntry({
|
|
1385
|
-
userId,
|
|
1386
|
-
entryType: "credit",
|
|
1387
|
-
amount: 0,
|
|
1388
|
-
// No actual credits returned (they were reserved, not spent)
|
|
1389
|
-
balanceAfter: credits.balance,
|
|
1390
|
-
source: "operation_release",
|
|
1391
|
-
referenceId: reservationId,
|
|
1392
|
-
referenceType: "reservation",
|
|
1393
|
-
description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1394
|
-
metadata: {
|
|
1395
|
-
operationType: reservation.operationType,
|
|
1396
|
-
amount: reservation.amount
|
|
1397
|
-
}
|
|
1398
|
-
});
|
|
1399
|
-
}
|
|
2662
|
+
async releaseCredits(userId, reservationId, options) {
|
|
2663
|
+
const outcome = await this.releaseCreditsDetailed(userId, reservationId, options);
|
|
2664
|
+
if (outcome.outcome === "not_found") {
|
|
2665
|
+
throw createReservationNotFoundError(reservationId);
|
|
1400
2666
|
}
|
|
1401
2667
|
}
|
|
2668
|
+
/** Release a reservation and get the typed outcome. */
|
|
2669
|
+
async releaseCreditsDetailed(userId, reservationId, options) {
|
|
2670
|
+
return releaseThroughRepository(this.repository, userId, reservationId, options);
|
|
2671
|
+
}
|
|
1402
2672
|
/**
|
|
1403
2673
|
* Log usage for audit trail
|
|
1404
2674
|
* @param log - Usage log data
|
|
@@ -1414,9 +2684,20 @@ var CreditsService = class {
|
|
|
1414
2684
|
* @param paymentRef - Optional payment reference
|
|
1415
2685
|
* @param options - Optional journal source / reference type / extra metadata
|
|
1416
2686
|
* for the credit journal entry (e.g. revenue-attribution fields).
|
|
2687
|
+
* @returns Whether this delivery credited the account (`created`), repeated an
|
|
2688
|
+
* earlier one (`replayed`), or reused the reference for a different credit
|
|
2689
|
+
* event (`conflict`, which credited nothing). On a repository without
|
|
2690
|
+
* `addCreditsV2` the result is always `created` and carries no
|
|
2691
|
+
* deduplication guarantee — see {@link addCreditsThroughRepository}.
|
|
1417
2692
|
*/
|
|
1418
2693
|
async addCredits(userId, amount, description, paymentRef, options) {
|
|
1419
|
-
return this.repository
|
|
2694
|
+
return addCreditsThroughRepository(this.repository, {
|
|
2695
|
+
userId,
|
|
2696
|
+
amount,
|
|
2697
|
+
description,
|
|
2698
|
+
paymentRef,
|
|
2699
|
+
options
|
|
2700
|
+
});
|
|
1420
2701
|
}
|
|
1421
2702
|
/**
|
|
1422
2703
|
* One-shot atomic "deduct-if-sufficient" credit charge.
|
|
@@ -1440,15 +2721,14 @@ var CreditsService = class {
|
|
|
1440
2721
|
* @throws Error if amount is not a positive number
|
|
1441
2722
|
*/
|
|
1442
2723
|
async deductCredits(userId, amount, options) {
|
|
1443
|
-
|
|
1444
|
-
throw new Error(`deductCredits amount must be positive (got ${amount})`);
|
|
1445
|
-
}
|
|
2724
|
+
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1446
2725
|
let deduction;
|
|
1447
2726
|
try {
|
|
1448
2727
|
deduction = await this.repository.deductCreditsAtomic(userId, amount);
|
|
1449
2728
|
} catch (error) {
|
|
2729
|
+
if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
|
|
1450
2730
|
const credits = await this.repository.getUserCredits(userId);
|
|
1451
|
-
const available = credits ? credits.balance
|
|
2731
|
+
const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
|
|
1452
2732
|
if (available >= amount) {
|
|
1453
2733
|
throw error;
|
|
1454
2734
|
}
|
|
@@ -1457,7 +2737,7 @@ var CreditsService = class {
|
|
|
1457
2737
|
reason: "insufficient",
|
|
1458
2738
|
available,
|
|
1459
2739
|
required: amount,
|
|
1460
|
-
shortfall: amount -
|
|
2740
|
+
shortfall: sumAmounts(amount, -available)
|
|
1461
2741
|
};
|
|
1462
2742
|
}
|
|
1463
2743
|
const operationType = options?.operationType;
|
|
@@ -1471,8 +2751,8 @@ var CreditsService = class {
|
|
|
1471
2751
|
referenceType: "adjustment",
|
|
1472
2752
|
description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
|
|
1473
2753
|
metadata: {
|
|
1474
|
-
...
|
|
1475
|
-
...
|
|
2754
|
+
...options?.metadata,
|
|
2755
|
+
...operationType && { operationType }
|
|
1476
2756
|
}
|
|
1477
2757
|
});
|
|
1478
2758
|
if (operationType) {
|
|
@@ -1507,8 +2787,15 @@ var CreditsService = class {
|
|
|
1507
2787
|
const free = isFreeTier(tier);
|
|
1508
2788
|
await this.repository.updateUserTier(userId, {
|
|
1509
2789
|
tier,
|
|
1510
|
-
//
|
|
1511
|
-
|
|
2790
|
+
// Through the shared contract, like every path that resolves the tier's
|
|
2791
|
+
// limit from configuration. (The repository's `updateUserCredits` and
|
|
2792
|
+
// `updateUserTier` still store a caller-supplied `monthlyLimit` verbatim;
|
|
2793
|
+
// they are given a value, not a tier, so there is no sentinel to map.)
|
|
2794
|
+
// This used to store 0 for an unlimited tier under a "0 means unlimited"
|
|
2795
|
+
// convention that nothing actually implemented — no read path anywhere in
|
|
2796
|
+
// the library branches on it — so upgrading a user to unlimited gave them
|
|
2797
|
+
// an allowance of zero.
|
|
2798
|
+
monthlyLimit: storedMonthlyLimit(monthlyLimit),
|
|
1512
2799
|
// Reset balance to new tier limit if upgrading
|
|
1513
2800
|
balance: !free ? unlimited ? getUnlimitedSentinelBalance() : monthlyLimit : void 0,
|
|
1514
2801
|
monthlyUsed: !free ? 0 : void 0,
|
|
@@ -1651,9 +2938,11 @@ function createGenericAdapter(config) {
|
|
|
1651
2938
|
return handler(user, data, dummyReservation);
|
|
1652
2939
|
}
|
|
1653
2940
|
let reservation;
|
|
2941
|
+
let isFree = false;
|
|
1654
2942
|
try {
|
|
1655
2943
|
const cost = options.customCost ?? getOperationCost2(options.operationType);
|
|
1656
|
-
|
|
2944
|
+
isFree = options.customCost === void 0 && cost === 0;
|
|
2945
|
+
reservation = isFree ? { ...createDummyReservation(user.id, options.operationType), id: "free-operation" } : await reserveCreditsForOperation(
|
|
1657
2946
|
repository,
|
|
1658
2947
|
user.id,
|
|
1659
2948
|
cost,
|
|
@@ -1676,7 +2965,7 @@ function createGenericAdapter(config) {
|
|
|
1676
2965
|
try {
|
|
1677
2966
|
const result = await handler(user, data, reservation);
|
|
1678
2967
|
if (result.success) {
|
|
1679
|
-
await commitReservationWithJournal(repository, user.id, reservation.id);
|
|
2968
|
+
if (!isFree) await commitReservationWithJournal(repository, user.id, reservation.id);
|
|
1680
2969
|
logUsage({
|
|
1681
2970
|
userId: user.id,
|
|
1682
2971
|
operationType: options.operationType,
|
|
@@ -1687,7 +2976,7 @@ function createGenericAdapter(config) {
|
|
|
1687
2976
|
requestId
|
|
1688
2977
|
});
|
|
1689
2978
|
} else {
|
|
1690
|
-
await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
2979
|
+
if (!isFree) await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
1691
2980
|
logUsage({
|
|
1692
2981
|
userId: user.id,
|
|
1693
2982
|
operationType: options.operationType,
|
|
@@ -1702,7 +2991,7 @@ function createGenericAdapter(config) {
|
|
|
1702
2991
|
return result;
|
|
1703
2992
|
} catch (error) {
|
|
1704
2993
|
const message = error instanceof Error ? error.message : "An unexpected error occurred";
|
|
1705
|
-
await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
2994
|
+
if (!isFree) await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
1706
2995
|
logUsage({
|
|
1707
2996
|
userId: user.id,
|
|
1708
2997
|
operationType: options.operationType,
|
|
@@ -2199,7 +3488,7 @@ var CreditsClient = class {
|
|
|
2199
3488
|
*/
|
|
2200
3489
|
async getAvailableCredits() {
|
|
2201
3490
|
const balance = await this.getBalance();
|
|
2202
|
-
return balance.balance
|
|
3491
|
+
return sumAmounts(balance.balance, balance.bonusCredits, -balance.reserved);
|
|
2203
3492
|
}
|
|
2204
3493
|
/**
|
|
2205
3494
|
* Execute an operation with automatic credit handling
|
|
@@ -2428,7 +3717,7 @@ function parseCreditError(error) {
|
|
|
2428
3717
|
return {
|
|
2429
3718
|
available,
|
|
2430
3719
|
required,
|
|
2431
|
-
shortfall: required -
|
|
3720
|
+
shortfall: sumAmounts(required, -available),
|
|
2432
3721
|
rawMessage: error
|
|
2433
3722
|
};
|
|
2434
3723
|
}
|
|
@@ -2439,7 +3728,7 @@ function isCreditErrorMessage(error) {
|
|
|
2439
3728
|
|
|
2440
3729
|
// src/utils/index.ts
|
|
2441
3730
|
function getTotalCredits(credits) {
|
|
2442
|
-
return credits.balance
|
|
3731
|
+
return sumAmounts(credits.balance, credits.bonusCredits);
|
|
2443
3732
|
}
|
|
2444
3733
|
function generateRequestId2() {
|
|
2445
3734
|
const timestamp = Date.now();
|
|
@@ -2466,6 +3755,8 @@ export {
|
|
|
2466
3755
|
ApiKeyCreditsProvider,
|
|
2467
3756
|
AuthenticationError,
|
|
2468
3757
|
AuthorizationError,
|
|
3758
|
+
CREDIT_AMOUNT_MAX,
|
|
3759
|
+
CREDIT_AMOUNT_SCALE,
|
|
2469
3760
|
ConsoleNotificationHandler,
|
|
2470
3761
|
CreditError,
|
|
2471
3762
|
CreditErrorCode,
|
|
@@ -2474,31 +3765,57 @@ export {
|
|
|
2474
3765
|
CreditsService,
|
|
2475
3766
|
InMemoryCreditRepository,
|
|
2476
3767
|
NetworkError,
|
|
3768
|
+
RESERVATION_STATUSES,
|
|
3769
|
+
RESERVED_JOURNAL_KEY_PREFIX,
|
|
2477
3770
|
SDKErrorCode,
|
|
2478
3771
|
InsufficientCreditsError as SDKInsufficientCreditsError,
|
|
2479
3772
|
ReservationExpiredError as SDKReservationExpiredError,
|
|
2480
3773
|
ReservationNotFoundError as SDKReservationNotFoundError,
|
|
2481
3774
|
ServerError,
|
|
3775
|
+
TERMINAL_RESERVATION_STATUSES,
|
|
2482
3776
|
UNLIMITED_BALANCE_SENTINEL,
|
|
2483
3777
|
ValidationError,
|
|
3778
|
+
addCreditsThroughRepository,
|
|
3779
|
+
assertDirectStatusWriteAllowed,
|
|
3780
|
+
assertHoldPlaced,
|
|
3781
|
+
assertKnownReservationStatus,
|
|
3782
|
+
assertPublicJournalKey,
|
|
3783
|
+
assertRepresentableAmount,
|
|
3784
|
+
assertRepresentableFields,
|
|
3785
|
+
assertRepresentableTierAmount,
|
|
3786
|
+
assertUnkeyedDirectReservation,
|
|
3787
|
+
assertUnreferencedDirectTransaction,
|
|
3788
|
+
assertValidCreditAmount,
|
|
3789
|
+
assertValidIdempotencyKey,
|
|
3790
|
+
assertValidStoredAmount,
|
|
3791
|
+
assertValidStoredAmountRaw,
|
|
3792
|
+
backedBalanceFloor,
|
|
2484
3793
|
calculateAvailableCredits,
|
|
2485
3794
|
checkAndNotifyLowBalance,
|
|
3795
|
+
classifyDatabaseError,
|
|
2486
3796
|
clearConfigProvider,
|
|
2487
3797
|
clearCooldownState,
|
|
2488
3798
|
clearNotificationHandlers,
|
|
2489
3799
|
commitReservationWithJournal,
|
|
3800
|
+
commitThroughRepository,
|
|
2490
3801
|
createApiKeyProvider,
|
|
2491
3802
|
createCreditsService,
|
|
2492
3803
|
createDeferredExecutor,
|
|
2493
3804
|
createDummyReservation2 as createDummyReservation,
|
|
2494
3805
|
createGenericAdapter,
|
|
3806
|
+
createIdempotencyConflictError,
|
|
2495
3807
|
createInMemoryCreditRepository,
|
|
2496
3808
|
createInsufficientCreditsError,
|
|
3809
|
+
createInvalidAmountError,
|
|
2497
3810
|
createInvalidOperationTypeError,
|
|
3811
|
+
createPaymentRefConflictError,
|
|
2498
3812
|
createReservationAlreadyProcessedError,
|
|
2499
3813
|
createReservationExpiredError,
|
|
2500
3814
|
createReservationNotFoundError,
|
|
3815
|
+
createTransientError,
|
|
3816
|
+
createUnsupportedOperationError,
|
|
2501
3817
|
createUserNotFoundError,
|
|
3818
|
+
describePaymentMismatch,
|
|
2502
3819
|
generateId,
|
|
2503
3820
|
generateRequestId2 as generateRequestId,
|
|
2504
3821
|
genericDeferred,
|
|
@@ -2517,35 +3834,63 @@ export {
|
|
|
2517
3834
|
getOperationLabel,
|
|
2518
3835
|
getOperationLabels,
|
|
2519
3836
|
getReservationExpiryMs,
|
|
3837
|
+
getSqlState,
|
|
2520
3838
|
getTierConfig,
|
|
2521
3839
|
getTotalCredits,
|
|
2522
3840
|
getUnlimitedSentinelBalance,
|
|
2523
3841
|
getValidOperationTypes,
|
|
2524
3842
|
getValidTiers,
|
|
3843
|
+
hasPlacedHold,
|
|
2525
3844
|
initializeConfig,
|
|
2526
3845
|
isCreditError,
|
|
2527
3846
|
isCreditErrorMessage,
|
|
3847
|
+
isCreditedOutcome,
|
|
2528
3848
|
isFeatureEnabled,
|
|
2529
3849
|
isFreeTier,
|
|
3850
|
+
isIdempotencyConflictError,
|
|
2530
3851
|
isInsufficientCreditsError,
|
|
2531
3852
|
isPreviewMode2 as isPreviewMode,
|
|
3853
|
+
isRepresentableAmount,
|
|
3854
|
+
isReservationStatus,
|
|
3855
|
+
isReservedJournalKey,
|
|
3856
|
+
isReservedOutcome,
|
|
3857
|
+
isTerminalReservationStatus,
|
|
3858
|
+
isTransientDatabaseError,
|
|
3859
|
+
isTransientError,
|
|
3860
|
+
isUniqueViolation,
|
|
2532
3861
|
isUnlimitedTier,
|
|
3862
|
+
isValidCreditAmount,
|
|
3863
|
+
isValidIdempotencyKey,
|
|
2533
3864
|
isValidOperationType,
|
|
2534
3865
|
isValidTier,
|
|
3866
|
+
isWinningOutcome,
|
|
2535
3867
|
loadConfigFromEnv,
|
|
3868
|
+
monthlyResetBalance,
|
|
2536
3869
|
noopDeferred,
|
|
3870
|
+
normalizePaymentRef,
|
|
2537
3871
|
notifySubscriptionExpired,
|
|
2538
3872
|
notifySubscriptionExpiring,
|
|
3873
|
+
numericToCents,
|
|
2539
3874
|
parseApiError,
|
|
2540
3875
|
parseCreditError,
|
|
2541
3876
|
parseTier,
|
|
2542
3877
|
registerConfigProvider,
|
|
2543
3878
|
registerNotificationHandler,
|
|
2544
3879
|
releaseReservationWithJournal,
|
|
3880
|
+
releaseThroughRepository,
|
|
3881
|
+
reservationJournalKey,
|
|
2545
3882
|
reserveCreditsForOperation,
|
|
3883
|
+
reserveThroughRepository,
|
|
2546
3884
|
resetConfig,
|
|
3885
|
+
sameAmount,
|
|
3886
|
+
storedMonthlyLimit,
|
|
3887
|
+
sumAmounts,
|
|
3888
|
+
supportsCreditsV2,
|
|
3889
|
+
supportsIdempotentCredit,
|
|
2547
3890
|
synchronousDeferred,
|
|
3891
|
+
terminalStatusOf,
|
|
2548
3892
|
tierSchema,
|
|
3893
|
+
toCents,
|
|
2549
3894
|
toClientUserCredits,
|
|
2550
3895
|
toDate,
|
|
2551
3896
|
toPortableTimestamp,
|