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