@nehorai/credits 1.7.0 → 2.1.0

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