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