@nehorai/credits 1.6.0 → 2.0.0

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