@nehorai/credits 1.7.0 → 2.0.0

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