@nehorai/credits 1.7.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +226 -2
  2. package/dist/adapters/index.cjs +298 -40
  3. package/dist/adapters/index.cjs.map +1 -1
  4. package/dist/adapters/index.d.cts +2 -2
  5. package/dist/adapters/index.d.ts +2 -2
  6. package/dist/adapters/index.js +298 -40
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/core/index.cjs +696 -74
  9. package/dist/core/index.cjs.map +1 -1
  10. package/dist/core/index.d.cts +583 -5
  11. package/dist/core/index.d.ts +583 -5
  12. package/dist/core/index.js +648 -74
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/index.cjs +1762 -365
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +41 -6
  17. package/dist/index.d.ts +41 -6
  18. package/dist/index.js +1706 -365
  19. package/dist/index.js.map +1 -1
  20. package/dist/repository/index.cjs +1399 -183
  21. package/dist/repository/index.cjs.map +1 -1
  22. package/dist/repository/index.d.cts +141 -15
  23. package/dist/repository/index.d.ts +141 -15
  24. package/dist/repository/index.js +1392 -183
  25. package/dist/repository/index.js.map +1 -1
  26. package/dist/sdk/index.cjs +23 -1
  27. package/dist/sdk/index.cjs.map +1 -1
  28. package/dist/sdk/index.d.cts +2 -2
  29. package/dist/sdk/index.d.ts +2 -2
  30. package/dist/sdk/index.js +23 -1
  31. package/dist/sdk/index.js.map +1 -1
  32. package/dist/service/index.cjs +480 -104
  33. package/dist/service/index.cjs.map +1 -1
  34. package/dist/service/index.d.cts +68 -10
  35. package/dist/service/index.d.ts +68 -10
  36. package/dist/service/index.js +480 -104
  37. package/dist/service/index.js.map +1 -1
  38. package/dist/types-BmG5Omzj.d.ts +646 -0
  39. package/dist/types-C8hZMe8a.d.cts +646 -0
  40. package/dist/{types-CjLj3GW5.d.cts → types-DJ8yqaVr.d.cts} +39 -1
  41. package/dist/{types-CjLj3GW5.d.ts → types-DJ8yqaVr.d.ts} +39 -1
  42. package/package.json +1 -1
  43. package/dist/types-DFe5BoJY.d.cts +0 -329
  44. package/dist/types-_bSybQnb.d.ts +0 -329
package/dist/index.cjs CHANGED
@@ -24,6 +24,8 @@ __export(src_exports, {
24
24
  ApiKeyCreditsProvider: () => ApiKeyCreditsProvider,
25
25
  AuthenticationError: () => AuthenticationError,
26
26
  AuthorizationError: () => AuthorizationError,
27
+ CREDIT_AMOUNT_MAX: () => CREDIT_AMOUNT_MAX,
28
+ CREDIT_AMOUNT_SCALE: () => CREDIT_AMOUNT_SCALE,
27
29
  ConsoleNotificationHandler: () => ConsoleNotificationHandler,
28
30
  CreditError: () => CreditError,
29
31
  CreditErrorCode: () => CreditErrorCode,
@@ -32,31 +34,57 @@ __export(src_exports, {
32
34
  CreditsService: () => CreditsService,
33
35
  InMemoryCreditRepository: () => InMemoryCreditRepository,
34
36
  NetworkError: () => NetworkError,
37
+ RESERVATION_STATUSES: () => RESERVATION_STATUSES,
38
+ RESERVED_JOURNAL_KEY_PREFIX: () => RESERVED_JOURNAL_KEY_PREFIX,
35
39
  SDKErrorCode: () => SDKErrorCode,
36
40
  SDKInsufficientCreditsError: () => InsufficientCreditsError,
37
41
  SDKReservationExpiredError: () => ReservationExpiredError,
38
42
  SDKReservationNotFoundError: () => ReservationNotFoundError,
39
43
  ServerError: () => ServerError,
44
+ TERMINAL_RESERVATION_STATUSES: () => TERMINAL_RESERVATION_STATUSES,
40
45
  UNLIMITED_BALANCE_SENTINEL: () => UNLIMITED_BALANCE_SENTINEL,
41
46
  ValidationError: () => ValidationError,
47
+ addCreditsThroughRepository: () => addCreditsThroughRepository,
48
+ assertDirectStatusWriteAllowed: () => assertDirectStatusWriteAllowed,
49
+ assertHoldPlaced: () => assertHoldPlaced,
50
+ assertKnownReservationStatus: () => assertKnownReservationStatus,
51
+ assertPublicJournalKey: () => assertPublicJournalKey,
52
+ assertRepresentableAmount: () => assertRepresentableAmount,
53
+ assertRepresentableFields: () => assertRepresentableFields,
54
+ assertRepresentableTierAmount: () => assertRepresentableTierAmount,
55
+ assertUnkeyedDirectReservation: () => assertUnkeyedDirectReservation,
56
+ assertUnreferencedDirectTransaction: () => assertUnreferencedDirectTransaction,
57
+ assertValidCreditAmount: () => assertValidCreditAmount,
58
+ assertValidIdempotencyKey: () => assertValidIdempotencyKey,
59
+ assertValidStoredAmount: () => assertValidStoredAmount,
60
+ assertValidStoredAmountRaw: () => assertValidStoredAmountRaw,
61
+ backedBalanceFloor: () => backedBalanceFloor,
42
62
  calculateAvailableCredits: () => calculateAvailableCredits,
43
63
  checkAndNotifyLowBalance: () => checkAndNotifyLowBalance,
64
+ classifyDatabaseError: () => classifyDatabaseError,
44
65
  clearConfigProvider: () => clearConfigProvider,
45
66
  clearCooldownState: () => clearCooldownState,
46
67
  clearNotificationHandlers: () => clearNotificationHandlers,
47
68
  commitReservationWithJournal: () => commitReservationWithJournal,
69
+ commitThroughRepository: () => commitThroughRepository,
48
70
  createApiKeyProvider: () => createApiKeyProvider,
49
71
  createCreditsService: () => createCreditsService,
50
72
  createDeferredExecutor: () => createDeferredExecutor,
51
73
  createDummyReservation: () => createDummyReservation2,
52
74
  createGenericAdapter: () => createGenericAdapter,
75
+ createIdempotencyConflictError: () => createIdempotencyConflictError,
53
76
  createInMemoryCreditRepository: () => createInMemoryCreditRepository,
54
77
  createInsufficientCreditsError: () => createInsufficientCreditsError,
78
+ createInvalidAmountError: () => createInvalidAmountError,
55
79
  createInvalidOperationTypeError: () => createInvalidOperationTypeError,
80
+ createPaymentRefConflictError: () => createPaymentRefConflictError,
56
81
  createReservationAlreadyProcessedError: () => createReservationAlreadyProcessedError,
57
82
  createReservationExpiredError: () => createReservationExpiredError,
58
83
  createReservationNotFoundError: () => createReservationNotFoundError,
84
+ createTransientError: () => createTransientError,
85
+ createUnsupportedOperationError: () => createUnsupportedOperationError,
59
86
  createUserNotFoundError: () => createUserNotFoundError,
87
+ describePaymentMismatch: () => describePaymentMismatch,
60
88
  generateId: () => generateId,
61
89
  generateRequestId: () => generateRequestId2,
62
90
  genericDeferred: () => genericDeferred,
@@ -75,35 +103,63 @@ __export(src_exports, {
75
103
  getOperationLabel: () => getOperationLabel,
76
104
  getOperationLabels: () => getOperationLabels,
77
105
  getReservationExpiryMs: () => getReservationExpiryMs,
106
+ getSqlState: () => getSqlState,
78
107
  getTierConfig: () => getTierConfig,
79
108
  getTotalCredits: () => getTotalCredits,
80
109
  getUnlimitedSentinelBalance: () => getUnlimitedSentinelBalance,
81
110
  getValidOperationTypes: () => getValidOperationTypes,
82
111
  getValidTiers: () => getValidTiers,
112
+ hasPlacedHold: () => hasPlacedHold,
83
113
  initializeConfig: () => initializeConfig,
84
114
  isCreditError: () => isCreditError,
85
115
  isCreditErrorMessage: () => isCreditErrorMessage,
116
+ isCreditedOutcome: () => isCreditedOutcome,
86
117
  isFeatureEnabled: () => isFeatureEnabled,
87
118
  isFreeTier: () => isFreeTier,
119
+ isIdempotencyConflictError: () => isIdempotencyConflictError,
88
120
  isInsufficientCreditsError: () => isInsufficientCreditsError,
89
121
  isPreviewMode: () => isPreviewMode2,
122
+ isRepresentableAmount: () => isRepresentableAmount,
123
+ isReservationStatus: () => isReservationStatus,
124
+ isReservedJournalKey: () => isReservedJournalKey,
125
+ isReservedOutcome: () => isReservedOutcome,
126
+ isTerminalReservationStatus: () => isTerminalReservationStatus,
127
+ isTransientDatabaseError: () => isTransientDatabaseError,
128
+ isTransientError: () => isTransientError,
129
+ isUniqueViolation: () => isUniqueViolation,
90
130
  isUnlimitedTier: () => isUnlimitedTier,
131
+ isValidCreditAmount: () => isValidCreditAmount,
132
+ isValidIdempotencyKey: () => isValidIdempotencyKey,
91
133
  isValidOperationType: () => isValidOperationType,
92
134
  isValidTier: () => isValidTier,
135
+ isWinningOutcome: () => isWinningOutcome,
93
136
  loadConfigFromEnv: () => loadConfigFromEnv,
137
+ monthlyResetBalance: () => monthlyResetBalance,
94
138
  noopDeferred: () => noopDeferred,
139
+ normalizePaymentRef: () => normalizePaymentRef,
95
140
  notifySubscriptionExpired: () => notifySubscriptionExpired,
96
141
  notifySubscriptionExpiring: () => notifySubscriptionExpiring,
142
+ numericToCents: () => numericToCents,
97
143
  parseApiError: () => parseApiError,
98
144
  parseCreditError: () => parseCreditError,
99
145
  parseTier: () => parseTier,
100
146
  registerConfigProvider: () => registerConfigProvider,
101
147
  registerNotificationHandler: () => registerNotificationHandler,
102
148
  releaseReservationWithJournal: () => releaseReservationWithJournal,
149
+ releaseThroughRepository: () => releaseThroughRepository,
150
+ reservationJournalKey: () => reservationJournalKey,
103
151
  reserveCreditsForOperation: () => reserveCreditsForOperation,
152
+ reserveThroughRepository: () => reserveThroughRepository,
104
153
  resetConfig: () => resetConfig,
154
+ sameAmount: () => sameAmount,
155
+ storedMonthlyLimit: () => storedMonthlyLimit,
156
+ sumAmounts: () => sumAmounts,
157
+ supportsCreditsV2: () => supportsCreditsV2,
158
+ supportsIdempotentCredit: () => supportsIdempotentCredit,
105
159
  synchronousDeferred: () => synchronousDeferred,
160
+ terminalStatusOf: () => terminalStatusOf,
106
161
  tierSchema: () => tierSchema,
162
+ toCents: () => toCents,
107
163
  toClientUserCredits: () => toClientUserCredits,
108
164
  toDate: () => toDate,
109
165
  toPortableTimestamp: () => toPortableTimestamp,
@@ -112,38 +168,6 @@ __export(src_exports, {
112
168
  });
113
169
  module.exports = __toCommonJS(src_exports);
114
170
 
115
- // src/core/types.ts
116
- function calculateAvailableCredits(balance, bonusCredits, reserved) {
117
- return balance + bonusCredits - reserved;
118
- }
119
- function toPortableTimestamp(value) {
120
- if (!value) {
121
- return (/* @__PURE__ */ new Date()).toISOString();
122
- }
123
- if (typeof value === "string") {
124
- return value;
125
- }
126
- if (value instanceof Date) {
127
- return value.toISOString();
128
- }
129
- if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
130
- return value.toDate().toISOString();
131
- }
132
- return (/* @__PURE__ */ new Date()).toISOString();
133
- }
134
- function toDate(value) {
135
- if (value instanceof Date) {
136
- return value;
137
- }
138
- if (typeof value === "string") {
139
- return new Date(value);
140
- }
141
- if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
142
- return value.toDate();
143
- }
144
- return /* @__PURE__ */ new Date();
145
- }
146
-
147
171
  // src/core/errors.ts
148
172
  var CreditErrorCode = {
149
173
  INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
@@ -153,7 +177,32 @@ var CreditErrorCode = {
153
177
  USER_NOT_FOUND: "USER_NOT_FOUND",
154
178
  INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
155
179
  CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
156
- DATABASE_ERROR: "DATABASE_ERROR"
180
+ DATABASE_ERROR: "DATABASE_ERROR",
181
+ /** Caller reused an idempotency key with a different immutable payload. */
182
+ IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT",
183
+ /**
184
+ * Retryable infrastructure failure — serialization/deadlock, lock timeout,
185
+ * connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
186
+ * a deterministic failure that will fail again identically on retry.
187
+ */
188
+ TRANSIENT_ERROR: "TRANSIENT_ERROR",
189
+ /** The configured repository adapter does not implement the requested V2 method. */
190
+ UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
191
+ /** Amount was not a finite positive number. */
192
+ INVALID_AMOUNT: "INVALID_AMOUNT",
193
+ /** Idempotency key was supplied but empty or whitespace-only. */
194
+ INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY",
195
+ /**
196
+ * A persisted reservation holds a status this library does not define, so no
197
+ * transition can reason about it. Quarantined rather than cast; nothing moved.
198
+ */
199
+ CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS",
200
+ /**
201
+ * A persisted reservation does not record that its hold was placed
202
+ * atomically, so the credits it claims are not known to be reserved. Moving
203
+ * it would spend another hold's coverage; nothing moved.
204
+ */
205
+ UNBACKED_RESERVATION: "UNBACKED_RESERVATION"
157
206
  };
158
207
  var CreditError = class _CreditError extends Error {
159
208
  code;
@@ -195,7 +244,7 @@ function createInsufficientCreditsError(required, available) {
195
244
  return new CreditError(
196
245
  `Insufficient credits: available ${available}, required ${required}`,
197
246
  CreditErrorCode.INSUFFICIENT_CREDITS,
198
- { required, available, shortfall: required - available }
247
+ { required, available, shortfall: sumAmounts(required, -available) }
199
248
  );
200
249
  }
201
250
  function createReservationNotFoundError(reservationId) {
@@ -233,6 +282,435 @@ function createInvalidOperationTypeError(operationType, validTypes) {
233
282
  { operationType, validTypes }
234
283
  );
235
284
  }
285
+ function createIdempotencyConflictError(idempotencyKey, details) {
286
+ return new CreditError(
287
+ `Idempotency key ${idempotencyKey} was reused with a different payload`,
288
+ CreditErrorCode.IDEMPOTENCY_CONFLICT,
289
+ { idempotencyKey, ...details }
290
+ );
291
+ }
292
+ function createTransientError(message, details) {
293
+ return new CreditError(message, CreditErrorCode.TRANSIENT_ERROR, details);
294
+ }
295
+ function createUnsupportedOperationError(operation, hint) {
296
+ return new CreditError(
297
+ hint ? `Repository does not support ${operation}. ${hint}` : `Repository does not support ${operation}`,
298
+ CreditErrorCode.UNSUPPORTED_OPERATION,
299
+ { operation, ...hint ? { hint } : {} }
300
+ );
301
+ }
302
+ function createInvalidAmountError(amount) {
303
+ return new CreditError(
304
+ `Credit amount must be a finite positive number (got ${String(amount)})`,
305
+ CreditErrorCode.INVALID_AMOUNT,
306
+ { amount }
307
+ );
308
+ }
309
+ function isIdempotencyConflictError(error) {
310
+ return isCreditError(error) && error.code === CreditErrorCode.IDEMPOTENCY_CONFLICT;
311
+ }
312
+ function isTransientError(error) {
313
+ return isCreditError(error) && error.code === CreditErrorCode.TRANSIENT_ERROR;
314
+ }
315
+
316
+ // src/core/amount.ts
317
+ var CREDIT_AMOUNT_SCALE = 2;
318
+ var CREDIT_AMOUNT_MAX = 999999999999e-2;
319
+ var MAX_CENTS = 999999999999;
320
+ function toCents(value) {
321
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
322
+ const cents = Math.round(value * 100);
323
+ if (!Number.isSafeInteger(cents)) return null;
324
+ return value === cents / 100 ? cents : null;
325
+ }
326
+ function sumAmounts(...values) {
327
+ let cents = 0;
328
+ for (const value of values) {
329
+ const valueCents = toCents(value);
330
+ if (valueCents === null) return floatSum(values);
331
+ cents += valueCents;
332
+ }
333
+ return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);
334
+ }
335
+ function floatSum(values) {
336
+ let total = 0;
337
+ for (const value of values) total += value;
338
+ return total;
339
+ }
340
+ function isValidCreditAmount(value) {
341
+ if (typeof value !== "number") return false;
342
+ const cents = toCents(value);
343
+ return cents !== null && cents > 0 && cents <= MAX_CENTS;
344
+ }
345
+ function assertValidCreditAmount(value, context) {
346
+ if (isValidCreditAmount(value)) return;
347
+ throw new CreditError(
348
+ creditAmountReason(value),
349
+ CreditErrorCode.INVALID_AMOUNT,
350
+ { amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
351
+ );
352
+ }
353
+ function creditAmountReason(value) {
354
+ if (typeof value !== "number") {
355
+ return `Credit amount must be a number (got ${typeof value})`;
356
+ }
357
+ if (!Number.isFinite(value)) {
358
+ return `Credit amount must be finite (got ${String(value)})`;
359
+ }
360
+ if (value <= 0) {
361
+ return `Credit amount must be greater than zero (got ${value})`;
362
+ }
363
+ if (value > CREDIT_AMOUNT_MAX) {
364
+ return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;
365
+ }
366
+ return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;
367
+ }
368
+ function isRepresentableAmount(value) {
369
+ if (typeof value !== "number") return false;
370
+ const cents = toCents(value);
371
+ return cents !== null && Math.abs(cents) <= MAX_CENTS;
372
+ }
373
+ function assertRepresentableAmount(value, field, context) {
374
+ if (isRepresentableAmount(value)) return;
375
+ throw new CreditError(
376
+ `${field} must be a finite number exact to ${CREDIT_AMOUNT_SCALE} decimal places within \xB1${CREDIT_AMOUNT_MAX} (got ${String(value)})`,
377
+ CreditErrorCode.INVALID_AMOUNT,
378
+ { field, value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
379
+ );
380
+ }
381
+ function assertRepresentableFields(fields, context) {
382
+ for (const [field, value] of Object.entries(fields)) {
383
+ if (value === void 0) continue;
384
+ assertRepresentableAmount(value, field, context);
385
+ }
386
+ }
387
+ function backedBalanceFloor(reserved, bonusCredits) {
388
+ const floor = Math.max(0, sumAmounts(reserved, -bonusCredits));
389
+ assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
390
+ return floor;
391
+ }
392
+ function assertRepresentableTierAmount(value, field, context) {
393
+ if (value === Number.POSITIVE_INFINITY) return;
394
+ assertRepresentableAmount(value, field, context);
395
+ }
396
+ function storedMonthlyLimit(limit) {
397
+ return Number.isFinite(limit) ? limit : CREDIT_AMOUNT_MAX;
398
+ }
399
+ function assertValidStoredAmountRaw(raw, context) {
400
+ const cents = numericToCents(raw);
401
+ if (cents !== null && cents > 0n && cents <= BigInt(MAX_CENTS)) {
402
+ return Number(cents) / 100;
403
+ }
404
+ throw new CreditError(
405
+ `Stored credit amount ${String(raw)} is not a valid amount, so this transition was refused before any state changed. The row must be repaired before it can be committed, released or expired.`,
406
+ CreditErrorCode.INVALID_AMOUNT,
407
+ {
408
+ amount: typeof raw === "string" ? raw : String(raw),
409
+ reason: "corrupt_stored_amount",
410
+ max: CREDIT_AMOUNT_MAX,
411
+ scale: CREDIT_AMOUNT_SCALE,
412
+ ...context
413
+ }
414
+ );
415
+ }
416
+ function assertValidStoredAmount(value, context) {
417
+ if (isValidCreditAmount(value)) return;
418
+ throw new CreditError(
419
+ `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.`,
420
+ CreditErrorCode.INVALID_AMOUNT,
421
+ { amount: value, reason: "corrupt_stored_amount", max: CREDIT_AMOUNT_MAX, ...context }
422
+ );
423
+ }
424
+ function numericToCents(value) {
425
+ if (typeof value === "number") {
426
+ const cents2 = toCents(value);
427
+ return cents2 === null ? null : BigInt(cents2);
428
+ }
429
+ if (typeof value !== "string") return null;
430
+ const match = /^([+-]?)(\d+)(?:\.(\d*))?$/.exec(value.trim());
431
+ if (!match) return null;
432
+ const [, sign, whole, fraction = ""] = match;
433
+ if (/[1-9]/.test(fraction.slice(CREDIT_AMOUNT_SCALE))) return null;
434
+ const cents = BigInt(whole) * 100n + BigInt(fraction.slice(0, CREDIT_AMOUNT_SCALE).padEnd(CREDIT_AMOUNT_SCALE, "0"));
435
+ return sign === "-" ? -cents : cents;
436
+ }
437
+ function sameAmount(a, b) {
438
+ const left = numericToCents(a);
439
+ const right = numericToCents(b);
440
+ return left !== null && right !== null && left === right;
441
+ }
442
+
443
+ // src/core/types.ts
444
+ function calculateAvailableCredits(balance, bonusCredits, reserved) {
445
+ return sumAmounts(balance, bonusCredits, -reserved);
446
+ }
447
+ function toPortableTimestamp(value) {
448
+ if (!value) {
449
+ return (/* @__PURE__ */ new Date()).toISOString();
450
+ }
451
+ if (typeof value === "string") {
452
+ return value;
453
+ }
454
+ if (value instanceof Date) {
455
+ return value.toISOString();
456
+ }
457
+ if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
458
+ return value.toDate().toISOString();
459
+ }
460
+ return (/* @__PURE__ */ new Date()).toISOString();
461
+ }
462
+ function toDate(value) {
463
+ if (value instanceof Date) {
464
+ return value;
465
+ }
466
+ if (typeof value === "string") {
467
+ return new Date(value);
468
+ }
469
+ if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
470
+ return value.toDate();
471
+ }
472
+ return /* @__PURE__ */ new Date();
473
+ }
474
+
475
+ // src/core/outcomes.ts
476
+ function isCreditedOutcome(outcome) {
477
+ return outcome.outcome === "created";
478
+ }
479
+ function isWinningOutcome(outcome) {
480
+ return outcome.outcome === "committed" || outcome.outcome === "released" || outcome.outcome === "expired";
481
+ }
482
+ function isReservedOutcome(outcome) {
483
+ return outcome.outcome === "created" || outcome.outcome === "replayed";
484
+ }
485
+
486
+ // src/core/reservation-integrity.ts
487
+ var RESERVATION_STATUSES = [
488
+ "reserved",
489
+ "committed",
490
+ "released",
491
+ "expired"
492
+ ];
493
+ var TERMINAL_RESERVATION_STATUSES = [
494
+ "committed",
495
+ "released",
496
+ "expired"
497
+ ];
498
+ function isReservationStatus(value) {
499
+ return RESERVATION_STATUSES.includes(value);
500
+ }
501
+ function isTerminalReservationStatus(value) {
502
+ return TERMINAL_RESERVATION_STATUSES.includes(value);
503
+ }
504
+ function assertKnownReservationStatus(reservation, transition) {
505
+ if (isReservationStatus(reservation.status)) return;
506
+ throw new CreditError(
507
+ `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.`,
508
+ CreditErrorCode.CORRUPT_RESERVATION_STATUS,
509
+ {
510
+ userId: reservation.userId,
511
+ reservationId: reservation.id,
512
+ transition,
513
+ status: typeof reservation.status === "string" ? reservation.status : String(reservation.status),
514
+ allowed: [...RESERVATION_STATUSES],
515
+ reason: "corrupt_reservation_status"
516
+ }
517
+ );
518
+ }
519
+ function terminalStatusOf(reservation, transition) {
520
+ assertKnownReservationStatus(reservation, transition);
521
+ if (isTerminalReservationStatus(reservation.status)) return reservation.status;
522
+ throw new CreditError(
523
+ `Reservation ${reservation.id} is ${reservation.status}, which is not a terminal status, but the ${transition} tried to report it as one.`,
524
+ CreditErrorCode.CORRUPT_RESERVATION_STATUS,
525
+ {
526
+ userId: reservation.userId,
527
+ reservationId: reservation.id,
528
+ transition,
529
+ status: reservation.status,
530
+ reason: "not_terminal"
531
+ }
532
+ );
533
+ }
534
+ function assertHoldPlaced(reservation, transition) {
535
+ if (reservation.holdPlacedAt) return;
536
+ throw new CreditError(
537
+ `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.`,
538
+ CreditErrorCode.UNBACKED_RESERVATION,
539
+ {
540
+ userId: reservation.userId,
541
+ reservationId: reservation.id,
542
+ transition,
543
+ idempotencyKey: reservation.idempotencyKey ?? void 0,
544
+ reason: "hold_not_placed"
545
+ }
546
+ );
547
+ }
548
+ function assertUnkeyedDirectReservation(input) {
549
+ if (input.idempotencyKey === void 0 || input.idempotencyKey === null) return;
550
+ throw new CreditError(
551
+ "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.",
552
+ CreditErrorCode.UNSUPPORTED_OPERATION,
553
+ {
554
+ userId: input.userId,
555
+ idempotencyKey: input.idempotencyKey,
556
+ operation: "createReservation",
557
+ reason: "keyed_direct_reservation"
558
+ }
559
+ );
560
+ }
561
+ function hasPlacedHold(reservation) {
562
+ return Boolean(reservation.holdPlacedAt);
563
+ }
564
+ function assertDirectStatusWriteAllowed(reservation, status) {
565
+ if (status === "reserved") {
566
+ throw new CreditError(
567
+ `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.`,
568
+ CreditErrorCode.UNSUPPORTED_OPERATION,
569
+ {
570
+ userId: reservation.userId,
571
+ reservationId: reservation.id,
572
+ status,
573
+ reason: "reopen_reservation"
574
+ }
575
+ );
576
+ }
577
+ if (!reservation.holdPlacedAt) return;
578
+ throw new CreditError(
579
+ `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.`,
580
+ CreditErrorCode.UNSUPPORTED_OPERATION,
581
+ {
582
+ userId: reservation.userId,
583
+ reservationId: reservation.id,
584
+ status,
585
+ reason: "direct_status_write_on_backed_hold"
586
+ }
587
+ );
588
+ }
589
+
590
+ // src/core/payment-ref.ts
591
+ function normalizePaymentRef(ref) {
592
+ if (typeof ref !== "string") return void 0;
593
+ const trimmed = ref.trim();
594
+ return trimmed.length > 0 ? trimmed : void 0;
595
+ }
596
+ function describePaymentMismatch(stored, expected) {
597
+ if (stored.userId !== expected.userId) return "userId";
598
+ if (!sameAmount(stored.amount, expected.amount)) return "amount";
599
+ if (stored.type !== expected.type) return "type";
600
+ if (stored.source !== void 0 && stored.source !== expected.source) return "source";
601
+ if (stored.referenceType !== void 0 && stored.referenceType !== expected.referenceType) {
602
+ return "referenceType";
603
+ }
604
+ return null;
605
+ }
606
+ function assertUnreferencedDirectTransaction(input) {
607
+ if (normalizePaymentRef(input.paymentRef) === void 0) return;
608
+ throw new CreditError(
609
+ "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.",
610
+ CreditErrorCode.UNSUPPORTED_OPERATION,
611
+ {
612
+ userId: input.userId,
613
+ paymentRef: input.paymentRef,
614
+ operation: "createTransaction",
615
+ reason: "referenced_direct_transaction"
616
+ }
617
+ );
618
+ }
619
+ function createPaymentRefConflictError(paymentRef, details) {
620
+ return new CreditError(
621
+ `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.`,
622
+ CreditErrorCode.IDEMPOTENCY_CONFLICT,
623
+ { paymentRef, reason: "payment_ref_conflict", ...details }
624
+ );
625
+ }
626
+
627
+ // src/core/idempotency.ts
628
+ function isValidIdempotencyKey(value) {
629
+ return typeof value === "string" && value.trim().length > 0;
630
+ }
631
+ function assertValidIdempotencyKey(value, context) {
632
+ if (value === void 0 || value === null) return;
633
+ if (isValidIdempotencyKey(value)) return;
634
+ throw new CreditError(
635
+ 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})`,
636
+ CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
637
+ { idempotencyKey: value, ...context }
638
+ );
639
+ }
640
+ var RESERVED_JOURNAL_KEY_PREFIX = "reservation:";
641
+ function isReservedJournalKey(key) {
642
+ return typeof key === "string" && key.startsWith(RESERVED_JOURNAL_KEY_PREFIX);
643
+ }
644
+ function assertPublicJournalKey(key, userId) {
645
+ if (!isReservedJournalKey(key)) return;
646
+ throw new CreditError(
647
+ `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.`,
648
+ CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
649
+ { userId, idempotencyKey: key, reason: "reserved_namespace" }
650
+ );
651
+ }
652
+
653
+ // src/core/error-classify.ts
654
+ var TRANSIENT_SQLSTATES = /* @__PURE__ */ new Set([
655
+ "40000",
656
+ "40001",
657
+ "40P01",
658
+ "55P03",
659
+ "57014",
660
+ "57P01",
661
+ "57P02",
662
+ "57P03",
663
+ "53200",
664
+ "53300",
665
+ "53400"
666
+ ]);
667
+ var TRANSIENT_DRIVER_CODES = /* @__PURE__ */ new Set([
668
+ "ECONNRESET",
669
+ "ECONNREFUSED",
670
+ "EPIPE",
671
+ "ETIMEDOUT",
672
+ "ENOTFOUND",
673
+ "EHOSTUNREACH"
674
+ ]);
675
+ var NUMERIC_OVERFLOW = "22003";
676
+ function getSqlState(error) {
677
+ const seen = /* @__PURE__ */ new Set();
678
+ let current = error;
679
+ for (let depth = 0; depth < 8; depth += 1) {
680
+ if (typeof current !== "object" || current === null || seen.has(current)) return void 0;
681
+ seen.add(current);
682
+ const code = current.code;
683
+ if (typeof code === "string") return code;
684
+ current = current.cause;
685
+ }
686
+ return void 0;
687
+ }
688
+ function isUniqueViolation(error) {
689
+ return getSqlState(error) === "23505";
690
+ }
691
+ function isTransientDatabaseError(error) {
692
+ const code = getSqlState(error);
693
+ if (!code) return false;
694
+ if (TRANSIENT_SQLSTATES.has(code)) return true;
695
+ if (TRANSIENT_DRIVER_CODES.has(code)) return true;
696
+ return code.startsWith("08");
697
+ }
698
+ function classifyDatabaseError(error, context) {
699
+ if (isCreditError(error)) return error;
700
+ const sqlState = getSqlState(error);
701
+ const message = error instanceof Error ? error.message : String(error);
702
+ const details = { ...context, ...sqlState ? { sqlState } : {} };
703
+ if (isTransientDatabaseError(error)) {
704
+ return new CreditError(message, CreditErrorCode.TRANSIENT_ERROR, details);
705
+ }
706
+ if (sqlState === NUMERIC_OVERFLOW) {
707
+ return new CreditError(message, CreditErrorCode.INVALID_AMOUNT, {
708
+ ...details,
709
+ reason: "amount_out_of_range"
710
+ });
711
+ }
712
+ return new CreditError(message, CreditErrorCode.DATABASE_ERROR, details);
713
+ }
236
714
 
237
715
  // src/core/deferred.ts
238
716
  var genericDeferred = {
@@ -553,6 +1031,12 @@ var UNLIMITED_BALANCE_SENTINEL = 999999;
553
1031
  function getUnlimitedSentinelBalance() {
554
1032
  return UNLIMITED_BALANCE_SENTINEL;
555
1033
  }
1034
+ function monthlyResetBalance(tier) {
1035
+ if (isUnlimitedTier(tier)) {
1036
+ return { kind: "atLeast", value: UNLIMITED_BALANCE_SENTINEL };
1037
+ }
1038
+ return { kind: "absolute", value: getConfigMonthlyLimit(tier) };
1039
+ }
556
1040
  function getConfigTierConfig(tier) {
557
1041
  const cfg = getConfig().tierConfigs[tier];
558
1042
  if (cfg) return cfg;
@@ -595,57 +1079,203 @@ function resetConfig() {
595
1079
  }
596
1080
  initializeConfig();
597
1081
 
598
- // src/core/operations.ts
599
- async function commitReservationWithJournal(repository, userId, reservationId) {
1082
+ // src/repository/types.ts
1083
+ function supportsCreditsV2(repository) {
1084
+ return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
1085
+ }
1086
+ function supportsIdempotentCredit(repository) {
1087
+ return typeof repository.addCreditsV2 === "function";
1088
+ }
1089
+ function toClientUserCredits(credits) {
1090
+ return {
1091
+ userId: credits.userId,
1092
+ balance: credits.balance,
1093
+ bonusCredits: credits.bonusCredits ?? 0,
1094
+ reserved: credits.reserved,
1095
+ tier: credits.tier,
1096
+ monthlyLimit: credits.monthlyLimit,
1097
+ monthlyUsed: credits.monthlyUsed,
1098
+ monthlyResetAt: toISOString(credits.monthlyResetAt),
1099
+ subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
1100
+ createdAt: toISOString(credits.createdAt),
1101
+ updatedAt: toISOString(credits.updatedAt)
1102
+ };
1103
+ }
1104
+ function toISOString(value) {
1105
+ if (!value) return (/* @__PURE__ */ new Date()).toISOString();
1106
+ if (typeof value === "string") return value;
1107
+ if (value instanceof Date) return value.toISOString();
1108
+ if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
1109
+ return value.toDate().toISOString();
1110
+ }
1111
+ return (/* @__PURE__ */ new Date()).toISOString();
1112
+ }
1113
+
1114
+ // src/repository/flow.ts
1115
+ async function reserveThroughRepository(repository, input) {
1116
+ const v2 = supportsCreditsV2(repository);
1117
+ if (!v2 && input.idempotencyKey !== void 0) {
1118
+ throw createUnsupportedOperationError(
1119
+ "reserveCredits with an idempotencyKey",
1120
+ "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."
1121
+ );
1122
+ }
1123
+ assertValidCreditAmount(input.amount, { userId: input.userId });
1124
+ if (v2) {
1125
+ return repository.reserveCreditsV2(input);
1126
+ }
1127
+ try {
1128
+ const reservation = await repository.reserveCreditsAtomic(
1129
+ input.userId,
1130
+ input.amount,
1131
+ input.operationType,
1132
+ input.expiresAt
1133
+ );
1134
+ return { outcome: "created", reservation };
1135
+ } catch (error) {
1136
+ if (!isInsufficientCreditsError(error)) throw error;
1137
+ const credits = await repository.getUserCredits(input.userId);
1138
+ const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
1139
+ return {
1140
+ outcome: "insufficient",
1141
+ available,
1142
+ required: input.amount,
1143
+ shortfall: Math.max(sumAmounts(input.amount, -available), 0)
1144
+ };
1145
+ }
1146
+ }
1147
+ async function commitThroughRepository(repository, userId, reservationId, options) {
1148
+ if (supportsCreditsV2(repository)) {
1149
+ return repository.commitReservationV2(userId, reservationId, options);
1150
+ }
600
1151
  const reservation = await repository.getReservation(userId, reservationId);
601
- if (!reservation) {
602
- throw new Error(`Reservation ${reservationId} not found`);
1152
+ if (!reservation) return { outcome: "not_found", reservationId };
1153
+ if (reservation.status !== "reserved") {
1154
+ return {
1155
+ outcome: "already_terminal",
1156
+ reservation,
1157
+ terminalStatus: reservation.status
1158
+ };
603
1159
  }
604
1160
  await repository.commitReservationAtomic(userId, reservationId);
605
1161
  const credits = await repository.getUserCredits(userId);
606
- if (credits) {
607
- await repository.createJournalEntry({
608
- userId,
609
- entryType: "debit",
610
- amount: reservation.amount,
611
- balanceAfter: credits.balance,
612
- source: "operation_commit",
613
- referenceId: reservationId,
614
- referenceType: "reservation",
615
- description: `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
616
- metadata: {
617
- operationType: reservation.operationType
618
- }
619
- });
620
- }
1162
+ const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
1163
+ const entry = await repository.createJournalEntry({
1164
+ userId,
1165
+ entryType: "debit",
1166
+ amount: reservation.amount,
1167
+ balanceAfter,
1168
+ source: "operation_commit",
1169
+ referenceId: reservationId,
1170
+ referenceType: "reservation",
1171
+ description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
1172
+ // Deterministic fields last: they carry the identity of the event, and a
1173
+ // caller who happens to send an `operationType` or an `amount` must not
1174
+ // relabel what actually moved.
1175
+ metadata: {
1176
+ ...options?.metadata,
1177
+ operationType: reservation.operationType,
1178
+ amount: reservation.amount
1179
+ }
1180
+ });
1181
+ return {
1182
+ outcome: "committed",
1183
+ reservation: { ...reservation, status: "committed" },
1184
+ amount: reservation.amount,
1185
+ balanceAfter,
1186
+ journalEntryId: entry.id
1187
+ };
621
1188
  }
622
- async function releaseReservationWithJournal(repository, userId, reservationId) {
1189
+ async function releaseThroughRepository(repository, userId, reservationId, options) {
1190
+ if (supportsCreditsV2(repository)) {
1191
+ return repository.releaseReservationV2(userId, reservationId, options);
1192
+ }
623
1193
  const reservation = await repository.getReservation(userId, reservationId);
1194
+ if (!reservation) {
1195
+ await repository.releaseReservationAtomic(userId, reservationId);
1196
+ return { outcome: "not_found", reservationId };
1197
+ }
1198
+ if (reservation.status !== "reserved") {
1199
+ await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
1200
+ return {
1201
+ outcome: "already_terminal",
1202
+ reservation,
1203
+ terminalStatus: reservation.status
1204
+ };
1205
+ }
624
1206
  await repository.releaseReservationAtomic(userId, reservationId);
625
- if (reservation?.status === "reserved") {
626
- const credits = await repository.getUserCredits(userId);
627
- if (credits) {
628
- await repository.createJournalEntry({
629
- userId,
630
- entryType: "credit",
631
- amount: 0,
632
- // No actual credits returned (they were reserved, not spent)
633
- balanceAfter: credits.balance,
634
- source: "operation_release",
635
- referenceId: reservationId,
636
- referenceType: "reservation",
637
- description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
638
- metadata: {
639
- operationType: reservation.operationType,
640
- amount: reservation.amount
641
- }
642
- });
1207
+ const credits = await repository.getUserCredits(userId);
1208
+ const entry = await repository.createJournalEntry({
1209
+ userId,
1210
+ entryType: "credit",
1211
+ // No credits changed hands — the hold just moved back into "available".
1212
+ amount: 0,
1213
+ balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
1214
+ source: "operation_release",
1215
+ referenceId: reservationId,
1216
+ referenceType: "reservation",
1217
+ description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1218
+ metadata: {
1219
+ ...options?.metadata,
1220
+ operationType: reservation.operationType,
1221
+ amount: reservation.amount
643
1222
  }
1223
+ });
1224
+ return {
1225
+ outcome: "released",
1226
+ reservation: { ...reservation, status: "released" },
1227
+ amount: reservation.amount,
1228
+ journalEntryId: entry.id
1229
+ };
1230
+ }
1231
+ async function addCreditsThroughRepository(repository, input) {
1232
+ assertValidCreditAmount(input.amount, { userId: input.userId, operation: "addCredits" });
1233
+ const paymentRef = normalizePaymentRef(input.paymentRef);
1234
+ if (supportsIdempotentCredit(repository)) {
1235
+ return repository.addCreditsV2({ ...input, paymentRef });
1236
+ }
1237
+ await repository.addCreditsAtomic(
1238
+ input.userId,
1239
+ input.amount,
1240
+ input.description,
1241
+ paymentRef,
1242
+ input.options
1243
+ );
1244
+ return paymentRef ? { outcome: "created", paymentRef } : { outcome: "created" };
1245
+ }
1246
+
1247
+ // src/core/operations.ts
1248
+ async function commitReservationWithJournal(repository, userId, reservationId) {
1249
+ const outcome = await commitThroughRepository(repository, userId, reservationId);
1250
+ if (outcome.outcome === "not_found") {
1251
+ throw createReservationNotFoundError(reservationId);
644
1252
  }
1253
+ if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
1254
+ throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
1255
+ }
1256
+ }
1257
+ async function releaseReservationWithJournal(repository, userId, reservationId) {
1258
+ await releaseThroughRepository(repository, userId, reservationId);
645
1259
  }
646
- async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3) {
1260
+ async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3, idempotencyKey) {
647
1261
  const expiresAt = new Date(Date.now() + expiryMs);
648
- return repository.reserveCreditsAtomic(userId, amount, operationType, expiresAt);
1262
+ const outcome = await reserveThroughRepository(repository, {
1263
+ userId,
1264
+ amount,
1265
+ operationType,
1266
+ expiresAt,
1267
+ idempotencyKey
1268
+ });
1269
+ if (outcome.outcome === "created" || outcome.outcome === "replayed") {
1270
+ return outcome.reservation;
1271
+ }
1272
+ if (outcome.outcome === "insufficient") {
1273
+ throw createInsufficientCreditsError(outcome.required, outcome.available);
1274
+ }
1275
+ throw createReservationAlreadyProcessedError(
1276
+ outcome.existing.id,
1277
+ outcome.existing.status
1278
+ );
649
1279
  }
650
1280
 
651
1281
  // src/config/costs.ts
@@ -680,64 +1310,493 @@ function getOperationCostsWithLabels() {
680
1310
  return result;
681
1311
  }
682
1312
 
683
- // src/repository/types.ts
684
- function toClientUserCredits(credits) {
685
- return {
686
- userId: credits.userId,
687
- balance: credits.balance,
688
- bonusCredits: credits.bonusCredits ?? 0,
689
- reserved: credits.reserved,
690
- tier: credits.tier,
691
- monthlyLimit: credits.monthlyLimit,
692
- monthlyUsed: credits.monthlyUsed,
693
- monthlyResetAt: toISOString(credits.monthlyResetAt),
694
- subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
695
- createdAt: toISOString(credits.createdAt),
696
- updatedAt: toISOString(credits.updatedAt)
1313
+ // src/repository/v2-types.ts
1314
+ function reservationJournalKey(reservationId, transition) {
1315
+ return `${RESERVED_JOURNAL_KEY_PREFIX}${reservationId}:${transition}`;
1316
+ }
1317
+
1318
+ // src/repository/utils.ts
1319
+ function generateId() {
1320
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
1321
+ }
1322
+ function toDate2(value) {
1323
+ if (value instanceof Date) return value;
1324
+ if (typeof value === "string") return new Date(value);
1325
+ if (typeof value === "object" && value !== null && "toDate" in value) {
1326
+ return value.toDate();
1327
+ }
1328
+ return /* @__PURE__ */ new Date();
1329
+ }
1330
+ function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
1331
+ const next = new Date(from);
1332
+ next.setMonth(next.getMonth() + 1);
1333
+ next.setDate(1);
1334
+ next.setHours(0, 0, 0, 0);
1335
+ return next;
1336
+ }
1337
+
1338
+ // src/repository/memory/snapshot.ts
1339
+ function copyRecord(record) {
1340
+ if (!record) return void 0;
1341
+ const copy = { ...record };
1342
+ const metadata = record.metadata;
1343
+ if (metadata && typeof metadata === "object") {
1344
+ copy.metadata = { ...metadata };
1345
+ }
1346
+ return copy;
1347
+ }
1348
+ function copyRecords(records) {
1349
+ return records.map((record) => copyRecord(record));
1350
+ }
1351
+
1352
+ // src/repository/memory/mutex.ts
1353
+ var KeyedMutex = class {
1354
+ tails = /* @__PURE__ */ new Map();
1355
+ /**
1356
+ * Run `fn` with exclusive access to `key`.
1357
+ *
1358
+ * Callers queue in arrival order. The stored tail settles either way, so one
1359
+ * caller throwing does not wedge the queue behind it.
1360
+ */
1361
+ async run(key, fn) {
1362
+ const previous = this.tails.get(key) ?? Promise.resolve();
1363
+ const result = previous.then(fn, fn);
1364
+ const tail = result.then(
1365
+ () => void 0,
1366
+ () => void 0
1367
+ );
1368
+ this.tails.set(key, tail);
1369
+ try {
1370
+ return await result;
1371
+ } finally {
1372
+ if (this.tails.get(key) === tail) {
1373
+ this.tails.delete(key);
1374
+ }
1375
+ }
1376
+ }
1377
+ };
1378
+
1379
+ // src/repository/memory/store.ts
1380
+ var MemoryStore = class {
1381
+ users = /* @__PURE__ */ new Map();
1382
+ reservations = /* @__PURE__ */ new Map();
1383
+ transactions = /* @__PURE__ */ new Map();
1384
+ usageLogs = [];
1385
+ journalEntries = /* @__PURE__ */ new Map();
1386
+ /** Stands in for the reservations table's partial unique index. */
1387
+ reservationKeys = /* @__PURE__ */ new Map();
1388
+ /** Stands in for the journal table's partial unique index. */
1389
+ journalKeys = /* @__PURE__ */ new Map();
1390
+ /** Stands in for the database's per-user row locks. */
1391
+ locks = new KeyedMutex();
1392
+ /**
1393
+ * Test-only yield point, awaited between reading state and mutating it.
1394
+ *
1395
+ * Unset in production, so the critical sections stay synchronous and this
1396
+ * costs nothing. Tests set it to a real macrotask yield, which forces
1397
+ * concurrent callers to interleave at exactly the spot a database would have
1398
+ * them contend — turning the lock above from decoration into the thing the
1399
+ * concurrency tests actually depend on.
1400
+ */
1401
+ schedulingHook;
1402
+ clear() {
1403
+ this.users.clear();
1404
+ this.reservations.clear();
1405
+ this.transactions.clear();
1406
+ this.usageLogs = [];
1407
+ this.journalEntries.clear();
1408
+ this.reservationKeys.clear();
1409
+ this.journalKeys.clear();
1410
+ }
1411
+ };
1412
+ function scopedKey(userId, key) {
1413
+ return `${userId}
1414
+ ${key}`;
1415
+ }
1416
+
1417
+ // src/repository/memory/transition-journal.ts
1418
+ function describeJournalMismatch(row, input) {
1419
+ if (row.userId !== input.userId) return "user_id";
1420
+ if (row.entryType !== input.entryType) return "entry_type";
1421
+ if (!sameAmount(row.amount, input.amount)) return "amount";
1422
+ if (!sameAmount(row.balanceAfter, input.balanceAfter)) return "balance_after";
1423
+ if (row.source !== input.source) return "source";
1424
+ if (row.referenceId !== input.reservationId) return "reference_id";
1425
+ if (row.referenceType !== "reservation") return "reference_type";
1426
+ const existingMeta = row.metadata ?? {};
1427
+ const expectedMeta = input.metadata ?? {};
1428
+ if (existingMeta.operationType !== expectedMeta.operationType) {
1429
+ return "metadata.operationType";
1430
+ }
1431
+ const hadAmount = existingMeta.amount !== void 0;
1432
+ const wantsAmount = expectedMeta.amount !== void 0;
1433
+ if (hadAmount !== wantsAmount) return "metadata.amount";
1434
+ if (wantsAmount && !sameAmount(existingMeta.amount, expectedMeta.amount)) {
1435
+ return "metadata.amount";
1436
+ }
1437
+ return null;
1438
+ }
1439
+ function findEntry(store, userId, entryId) {
1440
+ return store.journalEntries.get(userId)?.find((entry) => entry.id === entryId);
1441
+ }
1442
+ function planTransitionJournal(store, input) {
1443
+ assertRepresentableFields(
1444
+ { amount: input.amount, "journal balanceAfter": input.balanceAfter },
1445
+ { userId: input.userId, reservationId: input.reservationId, operation: input.operation }
1446
+ );
1447
+ const existingId = store.journalKeys.get(scopedKey(input.userId, input.idempotencyKey));
1448
+ if (!existingId) return void 0;
1449
+ const row = findEntry(store, input.userId, existingId);
1450
+ const mismatch = row ? describeJournalMismatch(row, input) : "the key is registered but its entry is missing";
1451
+ if (mismatch) {
1452
+ throw new CreditError(
1453
+ `Journal key ${input.idempotencyKey} is already used by a different entry (${mismatch}). The transition was refused rather than reported as applied.`,
1454
+ CreditErrorCode.DATABASE_ERROR,
1455
+ {
1456
+ idempotencyKey: input.idempotencyKey,
1457
+ reservationId: input.reservationId,
1458
+ mismatch
1459
+ }
1460
+ );
1461
+ }
1462
+ return existingId;
1463
+ }
1464
+ function commitTransitionJournal(store, input, existingId) {
1465
+ if (existingId) return existingId;
1466
+ const entry = {
1467
+ id: generateId(),
1468
+ userId: input.userId,
1469
+ entryType: input.entryType,
1470
+ amount: input.amount,
1471
+ balanceAfter: input.balanceAfter,
1472
+ source: input.source,
1473
+ referenceId: input.reservationId,
1474
+ referenceType: "reservation",
1475
+ description: input.description,
1476
+ metadata: input.metadata,
1477
+ idempotencyKey: input.idempotencyKey,
1478
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
697
1479
  };
1480
+ if (!store.journalEntries.has(input.userId)) {
1481
+ store.journalEntries.set(input.userId, []);
1482
+ }
1483
+ store.journalEntries.get(input.userId).push(entry);
1484
+ store.journalKeys.set(scopedKey(input.userId, input.idempotencyKey), entry.id);
1485
+ return entry.id;
1486
+ }
1487
+
1488
+ // src/repository/memory/v2.ts
1489
+ async function yieldPoint(store) {
1490
+ if (store.schedulingHook) await store.schedulingHook();
1491
+ }
1492
+ function assertReservedCoversHold(credits, userId, reservationId, amount) {
1493
+ if (credits.reserved >= amount) return;
1494
+ throw new CreditError(
1495
+ `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.`,
1496
+ CreditErrorCode.DATABASE_ERROR,
1497
+ { userId, reservationId, amount, reserved: credits.reserved }
1498
+ );
1499
+ }
1500
+ function assertLockedAmount(reservation, transition) {
1501
+ assertValidStoredAmount(reservation.amount, {
1502
+ userId: reservation.userId,
1503
+ reservationId: reservation.id,
1504
+ transition
1505
+ });
1506
+ }
1507
+ function terminalStatusOf2(reservation, transition) {
1508
+ return terminalStatusOf(reservation, transition);
1509
+ }
1510
+ function assertTrustworthy(reservation, transition) {
1511
+ assertKnownReservationStatus(reservation, transition);
1512
+ assertLockedAmount(reservation, transition);
1513
+ assertHoldPlaced(reservation, transition);
1514
+ }
1515
+ function getReservation(store, userId, reservationId) {
1516
+ return store.reservations.get(userId)?.get(reservationId);
1517
+ }
1518
+ function requireUser(store, userId) {
1519
+ const credits = store.users.get(userId);
1520
+ if (!credits) {
1521
+ throw new CreditError(
1522
+ `User credits not found for user ${userId}`,
1523
+ CreditErrorCode.USER_NOT_FOUND,
1524
+ { userId }
1525
+ );
1526
+ }
1527
+ return credits;
1528
+ }
1529
+ async function reserveCreditsV2(store, input) {
1530
+ assertValidCreditAmount(input.amount, { userId: input.userId });
1531
+ assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
1532
+ return store.locks.run(input.userId, async () => {
1533
+ if (input.idempotencyKey) {
1534
+ const existingId = store.reservationKeys.get(
1535
+ scopedKey(input.userId, input.idempotencyKey)
1536
+ );
1537
+ if (existingId) {
1538
+ const existing = getReservation(store, input.userId, existingId);
1539
+ if (existing) {
1540
+ assertHoldPlaced(existing, "reserve replay");
1541
+ const samePayload = existing.amount === input.amount && existing.operationType === input.operationType;
1542
+ return samePayload ? { outcome: "replayed", reservation: copyRecord(existing) } : {
1543
+ outcome: "idempotency_conflict",
1544
+ idempotencyKey: input.idempotencyKey,
1545
+ existing: copyRecord(existing)
1546
+ };
1547
+ }
1548
+ }
1549
+ }
1550
+ const credits = requireUser(store, input.userId);
1551
+ const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
1552
+ await yieldPoint(store);
1553
+ if (available < input.amount) {
1554
+ return {
1555
+ outcome: "insufficient",
1556
+ available,
1557
+ required: input.amount,
1558
+ shortfall: sumAmounts(input.amount, -available)
1559
+ };
1560
+ }
1561
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1562
+ const reservation = {
1563
+ id: generateId(),
1564
+ userId: input.userId,
1565
+ amount: input.amount,
1566
+ operationType: input.operationType,
1567
+ status: "reserved",
1568
+ createdAt: now,
1569
+ expiresAt: input.expiresAt.toISOString(),
1570
+ idempotencyKey: input.idempotencyKey,
1571
+ // The hold-origin fact. Written here and nowhere else, in the same
1572
+ // critical section that raises `reserved` below, so the row and the hold
1573
+ // become visible together. `createReservation` cannot set it.
1574
+ holdPlacedAt: now
1575
+ };
1576
+ if (!store.reservations.has(input.userId)) {
1577
+ store.reservations.set(input.userId, /* @__PURE__ */ new Map());
1578
+ }
1579
+ const nextReserved = sumAmounts(credits.reserved, input.amount);
1580
+ assertRepresentableFields(
1581
+ { reserved: nextReserved },
1582
+ { userId: input.userId, operation: "reserveCredits" }
1583
+ );
1584
+ store.reservations.get(input.userId).set(reservation.id, reservation);
1585
+ if (input.idempotencyKey) {
1586
+ store.reservationKeys.set(
1587
+ scopedKey(input.userId, input.idempotencyKey),
1588
+ reservation.id
1589
+ );
1590
+ }
1591
+ credits.reserved = nextReserved;
1592
+ credits.updatedAt = now;
1593
+ return { outcome: "created", reservation: copyRecord(reservation) };
1594
+ });
698
1595
  }
699
- function toISOString(value) {
700
- if (!value) return (/* @__PURE__ */ new Date()).toISOString();
701
- if (typeof value === "string") return value;
702
- if (value instanceof Date) return value.toISOString();
703
- if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
704
- return value.toDate().toISOString();
705
- }
706
- return (/* @__PURE__ */ new Date()).toISOString();
1596
+ async function commitReservationV2(store, userId, reservationId, options) {
1597
+ return store.locks.run(userId, async () => {
1598
+ const reservation = getReservation(store, userId, reservationId);
1599
+ if (!reservation) return { outcome: "not_found", reservationId };
1600
+ assertTrustworthy(reservation, "commit");
1601
+ if (reservation.status !== "reserved") {
1602
+ return {
1603
+ outcome: "already_terminal",
1604
+ reservation: copyRecord(reservation),
1605
+ terminalStatus: terminalStatusOf2(reservation, "commit")
1606
+ };
1607
+ }
1608
+ const credits = requireUser(store, userId);
1609
+ const amount = reservation.amount;
1610
+ await yieldPoint(store);
1611
+ assertReservedCoversHold(credits, userId, reservationId, amount);
1612
+ if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
1613
+ throw new CreditError(
1614
+ `Insufficient credits to commit reservation ${reservationId}`,
1615
+ CreditErrorCode.INSUFFICIENT_CREDITS,
1616
+ { userId, reservationId, required: amount }
1617
+ );
1618
+ }
1619
+ const fromBalance = Math.min(credits.balance, amount);
1620
+ const nextBalance = sumAmounts(credits.balance, -fromBalance);
1621
+ const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
1622
+ const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
1623
+ const journal = {
1624
+ userId,
1625
+ operation: "commitReservation",
1626
+ entryType: "debit",
1627
+ amount,
1628
+ balanceAfter,
1629
+ source: "operation_commit",
1630
+ reservationId,
1631
+ description: options?.description ?? `Committed ${amount} credits for ${getOperationLabel(reservation.operationType)}`,
1632
+ // Caller metadata first: the deterministic fields carry the identity of
1633
+ // the event that the collision check compares on.
1634
+ metadata: { ...options?.metadata, operationType: reservation.operationType, amount },
1635
+ idempotencyKey: reservationJournalKey(reservationId, "commit")
1636
+ };
1637
+ const nextReserved = sumAmounts(credits.reserved, -amount);
1638
+ const nextMonthlyUsed = sumAmounts(credits.monthlyUsed, amount);
1639
+ assertRepresentableFields(
1640
+ {
1641
+ balance: nextBalance,
1642
+ bonusCredits: nextBonusCredits,
1643
+ reserved: nextReserved,
1644
+ monthlyUsed: nextMonthlyUsed
1645
+ },
1646
+ { userId, operation: "commitReservation" }
1647
+ );
1648
+ const existingEntryId = planTransitionJournal(store, journal);
1649
+ reservation.status = "committed";
1650
+ reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1651
+ credits.balance = nextBalance;
1652
+ credits.bonusCredits = nextBonusCredits;
1653
+ credits.reserved = nextReserved;
1654
+ credits.monthlyUsed = nextMonthlyUsed;
1655
+ credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1656
+ const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
1657
+ return {
1658
+ outcome: "committed",
1659
+ reservation: copyRecord(reservation),
1660
+ amount,
1661
+ balanceAfter,
1662
+ journalEntryId
1663
+ };
1664
+ });
707
1665
  }
708
-
709
- // src/repository/utils.ts
710
- function generateId() {
711
- return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
1666
+ async function releaseReservationV2(store, userId, reservationId, options) {
1667
+ return store.locks.run(userId, async () => {
1668
+ const reservation = getReservation(store, userId, reservationId);
1669
+ if (!reservation) return { outcome: "not_found", reservationId };
1670
+ assertTrustworthy(reservation, "release");
1671
+ if (reservation.status !== "reserved") {
1672
+ return {
1673
+ outcome: "already_terminal",
1674
+ reservation: copyRecord(reservation),
1675
+ terminalStatus: terminalStatusOf2(reservation, "release")
1676
+ };
1677
+ }
1678
+ await yieldPoint(store);
1679
+ const journalEntryId = finishUnspent(store, reservation, {
1680
+ status: "released",
1681
+ source: "operation_release",
1682
+ transition: "release",
1683
+ verb: "Released",
1684
+ options
1685
+ });
1686
+ return {
1687
+ outcome: "released",
1688
+ reservation: copyRecord(reservation),
1689
+ amount: reservation.amount,
1690
+ journalEntryId
1691
+ };
1692
+ });
712
1693
  }
713
- function toDate2(value) {
714
- if (value instanceof Date) return value;
715
- if (typeof value === "string") return new Date(value);
716
- if (typeof value === "object" && value !== null && "toDate" in value) {
717
- return value.toDate();
718
- }
719
- return /* @__PURE__ */ new Date();
1694
+ async function expireReservationV2(store, userId, reservationId, options) {
1695
+ const asOf = options?.asOf ?? /* @__PURE__ */ new Date();
1696
+ return store.locks.run(userId, async () => {
1697
+ const reservation = getReservation(store, userId, reservationId);
1698
+ if (!reservation) return { outcome: "not_found", reservationId };
1699
+ assertTrustworthy(reservation, "expire");
1700
+ if (reservation.status !== "reserved") {
1701
+ return {
1702
+ outcome: "already_terminal",
1703
+ reservation: copyRecord(reservation),
1704
+ terminalStatus: terminalStatusOf2(reservation, "expire")
1705
+ };
1706
+ }
1707
+ if (new Date(reservation.expiresAt).getTime() > asOf.getTime()) {
1708
+ return { outcome: "not_due", reservation: copyRecord(reservation) };
1709
+ }
1710
+ await yieldPoint(store);
1711
+ const journalEntryId = finishUnspent(store, reservation, {
1712
+ status: "expired",
1713
+ source: "reservation_expired",
1714
+ transition: "expire",
1715
+ verb: "Expired",
1716
+ options
1717
+ });
1718
+ return {
1719
+ outcome: "expired",
1720
+ reservation: copyRecord(reservation),
1721
+ amount: reservation.amount,
1722
+ journalEntryId
1723
+ };
1724
+ });
720
1725
  }
721
- function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
722
- const next = new Date(from);
723
- next.setMonth(next.getMonth() + 1);
724
- next.setDate(1);
725
- next.setHours(0, 0, 0, 0);
726
- return next;
1726
+ function finishUnspent(store, reservation, transition) {
1727
+ const credits = requireUser(store, reservation.userId);
1728
+ assertReservedCoversHold(
1729
+ credits,
1730
+ reservation.userId,
1731
+ reservation.id,
1732
+ reservation.amount
1733
+ );
1734
+ const journal = {
1735
+ userId: reservation.userId,
1736
+ operation: `${transition.transition}Reservation`,
1737
+ entryType: "credit",
1738
+ amount: 0,
1739
+ balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
1740
+ source: transition.source,
1741
+ reservationId: reservation.id,
1742
+ description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1743
+ metadata: {
1744
+ ...transition.options?.metadata,
1745
+ operationType: reservation.operationType,
1746
+ amount: reservation.amount
1747
+ },
1748
+ idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
1749
+ };
1750
+ const nextReserved = sumAmounts(credits.reserved, -reservation.amount);
1751
+ assertRepresentableFields(
1752
+ { reserved: nextReserved },
1753
+ { userId: reservation.userId, operation: `${transition.transition}Reservation` }
1754
+ );
1755
+ const existingEntryId = planTransitionJournal(store, journal);
1756
+ reservation.status = transition.status;
1757
+ reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1758
+ credits.reserved = nextReserved;
1759
+ credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1760
+ return commitTransitionJournal(store, journal, existingEntryId);
727
1761
  }
728
1762
 
729
1763
  // src/repository/memory/index.ts
1764
+ function isoOf(value) {
1765
+ return value instanceof Date ? value.toISOString() : value;
1766
+ }
730
1767
  var InMemoryCreditRepository = class {
731
- users = /* @__PURE__ */ new Map();
732
- reservations = /* @__PURE__ */ new Map();
733
- transactions = /* @__PURE__ */ new Map();
734
- usageLogs = [];
735
- journalEntries = /* @__PURE__ */ new Map();
1768
+ store = new MemoryStore();
736
1769
  // ==================== User Credits ====================
1770
+ /**
1771
+ * A copy, never the live record.
1772
+ *
1773
+ * Handing out the stored object made this adapter behave unlike any real
1774
+ * one: a caller holding a "before" snapshot saw it mutate under them. The
1775
+ * service's monthly reset compared `resetResult.credits.balance` against the
1776
+ * balance it had read moments earlier, got zero because both were the same
1777
+ * object, and silently skipped the reset's journal entry — while the SQL
1778
+ * adapter, which maps each row into a fresh object, wrote it.
1779
+ */
737
1780
  async getUserCredits(userId) {
738
- return this.users.get(userId) ?? null;
1781
+ const credits = this.store.users.get(userId);
1782
+ return copyRecord(credits) ?? null;
739
1783
  }
740
1784
  async initializeUserCredits(userId, tier, initialBalance) {
1785
+ return copyRecord(this.seedUser(userId, tier, initialBalance));
1786
+ }
1787
+ /**
1788
+ * The synchronous body of `initializeUserCredits`.
1789
+ *
1790
+ * Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
1791
+ * its critical section, and an `await` between the payment-reference check
1792
+ * and the transaction write opens a gap another caller can run through — two
1793
+ * simultaneous first deliveries of the same reference would then both credit.
1794
+ */
1795
+ seedUser(userId, tier, initialBalance) {
1796
+ assertRepresentableAmount(initialBalance, "initialBalance", { userId });
1797
+ const configured = getConfigMonthlyLimit(tier);
1798
+ assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
1799
+ const monthlyLimit = storedMonthlyLimit(configured);
741
1800
  const now = (/* @__PURE__ */ new Date()).toISOString();
742
1801
  const credits = {
743
1802
  userId,
@@ -745,61 +1804,93 @@ var InMemoryCreditRepository = class {
745
1804
  bonusCredits: 0,
746
1805
  reserved: 0,
747
1806
  tier,
748
- monthlyLimit: getConfigMonthlyLimit(tier),
1807
+ monthlyLimit,
749
1808
  monthlyUsed: 0,
750
1809
  monthlyResetAt: getNextMonthlyReset().toISOString(),
751
1810
  subscriptionExpiresAt: null,
752
1811
  createdAt: now,
753
1812
  updatedAt: now
754
1813
  };
755
- this.users.set(userId, credits);
1814
+ this.store.users.set(userId, credits);
756
1815
  return credits;
757
1816
  }
758
1817
  async updateUserCredits(userId, updates) {
759
- const credits = this.users.get(userId);
1818
+ assertRepresentableFields(
1819
+ {
1820
+ balance: updates.balance,
1821
+ bonusCredits: updates.bonusCredits,
1822
+ reserved: updates.reserved,
1823
+ monthlyLimit: updates.monthlyLimit,
1824
+ monthlyUsed: updates.monthlyUsed,
1825
+ balanceIncrement: updates.balanceIncrement,
1826
+ bonusCreditsIncrement: updates.bonusCreditsIncrement,
1827
+ reservedIncrement: updates.reservedIncrement,
1828
+ monthlyUsedIncrement: updates.monthlyUsedIncrement
1829
+ },
1830
+ { userId, operation: "updateUserCredits" }
1831
+ );
1832
+ const credits = this.store.users.get(userId);
760
1833
  if (!credits) {
761
1834
  throw new Error(`User ${userId} not found`);
762
1835
  }
763
- const now = (/* @__PURE__ */ new Date()).toISOString();
764
- if (updates.balance !== void 0) credits.balance = updates.balance;
765
- if (updates.bonusCredits !== void 0) credits.bonusCredits = updates.bonusCredits;
766
- if (updates.reserved !== void 0) credits.reserved = updates.reserved;
767
- if (updates.tier !== void 0) credits.tier = updates.tier;
768
- if (updates.monthlyLimit !== void 0) credits.monthlyLimit = updates.monthlyLimit;
769
- if (updates.monthlyUsed !== void 0) credits.monthlyUsed = updates.monthlyUsed;
1836
+ const next = { ...credits };
1837
+ if (updates.balance !== void 0) next.balance = updates.balance;
1838
+ if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
1839
+ if (updates.reserved !== void 0) next.reserved = updates.reserved;
1840
+ if (updates.tier !== void 0) next.tier = updates.tier;
1841
+ if (updates.monthlyLimit !== void 0) next.monthlyLimit = updates.monthlyLimit;
1842
+ if (updates.monthlyUsed !== void 0) next.monthlyUsed = updates.monthlyUsed;
770
1843
  if (updates.monthlyResetAt !== void 0) {
771
- credits.monthlyResetAt = updates.monthlyResetAt instanceof Date ? updates.monthlyResetAt.toISOString() : updates.monthlyResetAt;
1844
+ next.monthlyResetAt = isoOf(updates.monthlyResetAt);
772
1845
  }
773
1846
  if (updates.subscriptionExpiresAt !== void 0) {
774
- if (updates.subscriptionExpiresAt === null) {
775
- credits.subscriptionExpiresAt = null;
776
- } else {
777
- credits.subscriptionExpiresAt = updates.subscriptionExpiresAt instanceof Date ? updates.subscriptionExpiresAt.toISOString() : updates.subscriptionExpiresAt;
778
- }
1847
+ next.subscriptionExpiresAt = updates.subscriptionExpiresAt === null ? null : isoOf(updates.subscriptionExpiresAt);
779
1848
  }
780
1849
  if (updates.balanceIncrement !== void 0) {
781
- credits.balance += updates.balanceIncrement;
1850
+ next.balance = sumAmounts(next.balance, updates.balanceIncrement);
782
1851
  }
783
1852
  if (updates.bonusCreditsIncrement !== void 0) {
784
- credits.bonusCredits += updates.bonusCreditsIncrement;
1853
+ next.bonusCredits = sumAmounts(next.bonusCredits, updates.bonusCreditsIncrement);
785
1854
  }
786
1855
  if (updates.reservedIncrement !== void 0) {
787
- credits.reserved += updates.reservedIncrement;
1856
+ next.reserved = sumAmounts(next.reserved, updates.reservedIncrement);
788
1857
  }
789
1858
  if (updates.monthlyUsedIncrement !== void 0) {
790
- credits.monthlyUsed += updates.monthlyUsedIncrement;
1859
+ next.monthlyUsed = sumAmounts(next.monthlyUsed, updates.monthlyUsedIncrement);
791
1860
  }
792
- credits.updatedAt = now;
793
- this.users.set(userId, credits);
1861
+ assertRepresentableFields(
1862
+ {
1863
+ balance: next.balance,
1864
+ bonusCredits: next.bonusCredits,
1865
+ reserved: next.reserved,
1866
+ monthlyUsed: next.monthlyUsed
1867
+ },
1868
+ { userId, operation: "updateUserCredits" }
1869
+ );
1870
+ assertRepresentableTierAmount(next.monthlyLimit, "monthlyLimit", {
1871
+ userId,
1872
+ operation: "updateUserCredits"
1873
+ });
1874
+ next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1875
+ this.store.users.set(userId, next);
794
1876
  }
795
1877
  async updateUserTier(userId, input) {
796
- const credits = this.users.get(userId);
1878
+ assertRepresentableFields(
1879
+ { monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
1880
+ { userId, operation: "updateUserTier" }
1881
+ );
1882
+ const credits = this.store.users.get(userId);
797
1883
  if (!credits) {
798
1884
  throw new Error(`User ${userId} not found`);
799
1885
  }
800
1886
  credits.tier = input.tier;
801
1887
  credits.monthlyLimit = input.monthlyLimit;
802
- if (input.balance !== void 0) credits.balance = input.balance;
1888
+ if (input.balance !== void 0) {
1889
+ credits.balance = Math.max(
1890
+ input.balance,
1891
+ backedBalanceFloor(credits.reserved, credits.bonusCredits)
1892
+ );
1893
+ }
803
1894
  if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
804
1895
  if (input.subscriptionExpiresAt !== void 0) {
805
1896
  if (input.subscriptionExpiresAt === null) {
@@ -809,10 +1900,12 @@ var InMemoryCreditRepository = class {
809
1900
  }
810
1901
  }
811
1902
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
812
- this.users.set(userId, credits);
1903
+ this.store.users.set(userId, credits);
813
1904
  }
814
1905
  // ==================== Reservations ====================
815
1906
  async createReservation(input) {
1907
+ assertValidCreditAmount(input.amount, { userId: input.userId, operation: "createReservation" });
1908
+ assertUnkeyedDirectReservation(input);
816
1909
  const now = (/* @__PURE__ */ new Date()).toISOString();
817
1910
  const reservation = {
818
1911
  id: generateId(),
@@ -823,19 +1916,19 @@ var InMemoryCreditRepository = class {
823
1916
  createdAt: now,
824
1917
  expiresAt: input.expiresAt.toISOString()
825
1918
  };
826
- if (!this.reservations.has(input.userId)) {
827
- this.reservations.set(input.userId, /* @__PURE__ */ new Map());
1919
+ if (!this.store.reservations.has(input.userId)) {
1920
+ this.store.reservations.set(input.userId, /* @__PURE__ */ new Map());
828
1921
  }
829
- this.reservations.get(input.userId).set(reservation.id, reservation);
830
- return reservation;
1922
+ this.store.reservations.get(input.userId).set(reservation.id, reservation);
1923
+ return copyRecord(reservation);
831
1924
  }
832
1925
  async getReservation(userId, reservationId) {
833
- const userReservations = this.reservations.get(userId);
1926
+ const userReservations = this.store.reservations.get(userId);
834
1927
  if (!userReservations) return null;
835
- return userReservations.get(reservationId) ?? null;
1928
+ return copyRecord(userReservations.get(reservationId)) ?? null;
836
1929
  }
837
1930
  async updateReservationStatus(userId, reservationId, status, completedAt) {
838
- const userReservations = this.reservations.get(userId);
1931
+ const userReservations = this.store.reservations.get(userId);
839
1932
  if (!userReservations) {
840
1933
  throw new Error(`No reservations found for user ${userId}`);
841
1934
  }
@@ -843,86 +1936,125 @@ var InMemoryCreditRepository = class {
843
1936
  if (!reservation) {
844
1937
  throw new Error(`Reservation ${reservationId} not found`);
845
1938
  }
1939
+ assertDirectStatusWriteAllowed(reservation, status);
846
1940
  reservation.status = status;
847
1941
  if (completedAt) reservation.completedAt = completedAt.toISOString();
848
1942
  userReservations.set(reservationId, reservation);
849
1943
  }
850
1944
  // ==================== Atomic Operations ====================
1945
+ // ==================== V2 boundary ====================
1946
+ //
1947
+ // As in the Drizzle adapter, V2 is the real implementation and the legacy
1948
+ // `*Atomic` methods are adapters over it, so both APIs share one set of
1949
+ // guarantees.
1950
+ async reserveCreditsV2(input) {
1951
+ return reserveCreditsV2(this.store, input);
1952
+ }
1953
+ async commitReservationV2(userId, reservationId, options) {
1954
+ return commitReservationV2(this.store, userId, reservationId, options);
1955
+ }
1956
+ async releaseReservationV2(userId, reservationId, options) {
1957
+ return releaseReservationV2(this.store, userId, reservationId, options);
1958
+ }
1959
+ async expireReservationV2(userId, reservationId, options) {
1960
+ return expireReservationV2(this.store, userId, reservationId, options);
1961
+ }
1962
+ // ==================== Legacy atomic operations ====================
851
1963
  async reserveCreditsAtomic(userId, amount, operationType, expiresAt) {
852
- const credits = this.users.get(userId);
853
- if (!credits) {
854
- throw new Error(`User ${userId} not found`);
1964
+ const outcome = await this.reserveCreditsV2({ userId, amount, operationType, expiresAt });
1965
+ if (outcome.outcome === "created" || outcome.outcome === "replayed") {
1966
+ return outcome.reservation;
855
1967
  }
856
- const available = credits.balance + credits.bonusCredits - credits.reserved;
857
- if (available < amount) {
858
- throw new Error(
859
- `Insufficient credits. Available: ${available}, Required: ${amount}`
860
- );
1968
+ if (outcome.outcome === "insufficient") {
1969
+ throw createInsufficientCreditsError(outcome.required, outcome.available);
861
1970
  }
862
- const reservation = await this.createReservation({
863
- userId,
864
- amount,
865
- operationType,
866
- expiresAt
867
- });
868
- credits.reserved += amount;
869
- credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
870
- this.users.set(userId, credits);
871
- return reservation;
1971
+ throw createReservationAlreadyProcessedError(outcome.existing.id, outcome.existing.status);
872
1972
  }
873
1973
  async commitReservationAtomic(userId, reservationId) {
874
- const credits = this.users.get(userId);
875
- if (!credits) {
876
- throw new Error(`User ${userId} not found`);
877
- }
878
- const reservation = await this.getReservation(userId, reservationId);
879
- if (!reservation) {
880
- throw new Error(`Reservation ${reservationId} not found`);
881
- }
882
- if (reservation.status === "committed") {
883
- return;
884
- }
885
- if (reservation.status !== "reserved") {
886
- throw new Error(
887
- `Cannot commit reservation in ${reservation.status} state`
888
- );
889
- }
890
- const amount = reservation.amount;
891
- credits.balance -= amount;
892
- credits.reserved -= amount;
893
- credits.monthlyUsed += amount;
894
- credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
895
- this.users.set(userId, credits);
896
- await this.updateReservationStatus(userId, reservationId, "committed", /* @__PURE__ */ new Date());
1974
+ const outcome = await this.commitReservationV2(userId, reservationId);
1975
+ if (outcome.outcome === "committed") return;
1976
+ if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
1977
+ if (outcome.terminalStatus === "committed") return;
1978
+ throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
897
1979
  }
898
1980
  async releaseReservationAtomic(userId, reservationId) {
899
- const credits = this.users.get(userId);
900
- if (!credits) {
901
- throw new Error(`User ${userId} not found`);
902
- }
903
- const reservation = await this.getReservation(userId, reservationId);
904
- if (!reservation) {
905
- throw new Error(`Reservation ${reservationId} not found`);
906
- }
907
- if (reservation.status !== "reserved") {
908
- return;
1981
+ const outcome = await this.releaseReservationV2(userId, reservationId);
1982
+ if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
1983
+ }
1984
+ /**
1985
+ * The stored transaction carrying this reference, in *any* user's ledger.
1986
+ *
1987
+ * Deliberately not scoped to one user. The SQL adapter's unique index covers
1988
+ * `payment_ref` alone, so searching only the crediting user's own history made
1989
+ * the same reference credit two different accounts here while the SQL adapter
1990
+ * treated the second delivery as a duplicate. See `core/payment-ref.ts`.
1991
+ */
1992
+ findByPaymentRef(paymentRef) {
1993
+ for (const recorded of this.store.transactions.values()) {
1994
+ const hit = recorded.find((transaction) => transaction.paymentRef === paymentRef);
1995
+ if (hit) return hit;
909
1996
  }
910
- credits.reserved -= reservation.amount;
911
- credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
912
- this.users.set(userId, credits);
913
- await this.updateReservationStatus(userId, reservationId, "released", /* @__PURE__ */ new Date());
1997
+ return void 0;
914
1998
  }
915
- async addCreditsAtomic(userId, amount, description, paymentRef, options) {
916
- const credits = this.users.get(userId);
917
- if (!credits) {
918
- throw new Error(`User ${userId} not found`);
1999
+ /** The journal entry written alongside a stored purchase, if there is one. */
2000
+ journalForTransaction(transaction) {
2001
+ return this.store.journalEntries.get(transaction.userId)?.find((entry) => entry.referenceId === transaction.id);
2002
+ }
2003
+ /**
2004
+ * Credit an account, resolving `paymentRef` to created / replayed / conflict.
2005
+ *
2006
+ * The reference is checked and the transaction is recorded with no `await`
2007
+ * between them. `createTransaction` is `async` but its body is synchronous, so
2008
+ * under JavaScript's run-to-completion semantics no other caller can observe
2009
+ * the gap — which is what stands in for the SQL adapter's unique index.
2010
+ *
2011
+ * A `replayed` or `conflict` outcome writes nothing at all: the check runs
2012
+ * before the balance moves, not after.
2013
+ */
2014
+ async addCreditsV2(input) {
2015
+ const { userId, amount, description, options } = input;
2016
+ assertValidCreditAmount(amount, { userId, operation: "addCredits" });
2017
+ const paymentRef = normalizePaymentRef(input.paymentRef);
2018
+ const payload = {
2019
+ userId,
2020
+ amount,
2021
+ type: "purchase",
2022
+ source: options?.source ?? "purchase",
2023
+ referenceType: options?.referenceType ?? "transaction"
2024
+ };
2025
+ if (paymentRef) {
2026
+ const existing = this.findByPaymentRef(paymentRef);
2027
+ if (existing) {
2028
+ const journal = this.journalForTransaction(existing);
2029
+ const mismatch = describePaymentMismatch(
2030
+ {
2031
+ userId: existing.userId,
2032
+ amount: existing.amount,
2033
+ type: existing.type,
2034
+ source: journal?.source,
2035
+ referenceType: journal?.referenceType
2036
+ },
2037
+ payload
2038
+ );
2039
+ return mismatch === null ? { outcome: "replayed", paymentRef, transaction: copyRecord(existing) } : { outcome: "conflict", paymentRef, existing: copyRecord(existing), mismatch };
2040
+ }
919
2041
  }
920
- const previousTotal = credits.balance + credits.bonusCredits;
921
- credits.bonusCredits += amount;
2042
+ if (!this.store.users.has(userId)) {
2043
+ const tier = getDefaultTier();
2044
+ this.seedUser(userId, tier, storedMonthlyLimit(getConfigMonthlyLimit(tier)));
2045
+ }
2046
+ const credits = this.store.users.get(userId);
2047
+ const previousTotal = sumAmounts(credits.balance, credits.bonusCredits);
2048
+ const nextBonusCredits = sumAmounts(credits.bonusCredits, amount);
2049
+ const newTotal = sumAmounts(credits.balance, nextBonusCredits);
2050
+ assertRepresentableFields(
2051
+ { previousBalance: previousTotal, bonusCredits: nextBonusCredits, newBalance: newTotal },
2052
+ { userId, operation: "addCredits" }
2053
+ );
2054
+ credits.bonusCredits = nextBonusCredits;
922
2055
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
923
- this.users.set(userId, credits);
924
- const newTotal = credits.balance + credits.bonusCredits;
925
- const transaction = await this.createTransaction({
2056
+ this.store.users.set(userId, credits);
2057
+ const transaction = this.recordTransaction({
926
2058
  userId,
927
2059
  type: "purchase",
928
2060
  amount,
@@ -935,43 +2067,88 @@ var InMemoryCreditRepository = class {
935
2067
  ...paymentRef ? { paymentRef } : {},
936
2068
  ...options?.metadata ?? {}
937
2069
  };
938
- await this.createJournalEntry({
2070
+ const entry = await this.createJournalEntry({
939
2071
  userId,
940
2072
  entryType: "credit",
941
2073
  amount,
942
2074
  balanceAfter: newTotal,
943
- source: options?.source ?? "purchase",
2075
+ source: payload.source,
944
2076
  referenceId: transaction.id ?? paymentRef ?? "unknown",
945
- referenceType: options?.referenceType ?? "transaction",
2077
+ referenceType: payload.referenceType,
946
2078
  description,
947
2079
  metadata: Object.keys(journalMetadata).length > 0 ? journalMetadata : void 0
948
2080
  });
2081
+ return paymentRef ? { outcome: "created", paymentRef, transaction, journalEntryId: entry.id } : { outcome: "created", transaction, journalEntryId: entry.id };
949
2082
  }
950
- async deductCreditsAtomic(userId, amount) {
951
- if (amount <= 0) {
952
- throw new Error(`deductCreditsAtomic amount must be positive (got ${amount})`);
2083
+ /**
2084
+ * The legacy signature, on top of {@link addCreditsV2}.
2085
+ *
2086
+ * `Promise<void>` has nowhere to report a conflict, and silently returning is
2087
+ * indistinguishable from "credited" — the exact failure this round is closing
2088
+ * — so a conflict throws. A genuine replay still returns quietly, because
2089
+ * that *is* the idempotent no-op the caller asked for.
2090
+ */
2091
+ async addCreditsAtomic(userId, amount, description, paymentRef, options) {
2092
+ const outcome = await this.addCreditsV2({
2093
+ userId,
2094
+ amount,
2095
+ description,
2096
+ paymentRef,
2097
+ options
2098
+ });
2099
+ if (outcome.outcome === "conflict") {
2100
+ throw createPaymentRefConflictError(outcome.paymentRef, {
2101
+ userId,
2102
+ mismatch: outcome.mismatch,
2103
+ existingUserId: outcome.existing.userId
2104
+ });
953
2105
  }
954
- const credits = this.users.get(userId);
2106
+ }
2107
+ async deductCreditsAtomic(userId, amount) {
2108
+ assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
2109
+ const credits = this.store.users.get(userId);
955
2110
  if (!credits) {
956
2111
  throw new Error(`User credits not found for userId: ${userId}`);
957
2112
  }
958
- const available = credits.balance + credits.bonusCredits - credits.reserved;
2113
+ const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
959
2114
  if (available < amount) {
960
2115
  throw new Error(
961
2116
  `Insufficient credits. Available: ${available}, requested: ${amount}`
962
2117
  );
963
2118
  }
964
2119
  const balanceDeduction = Math.min(credits.balance, amount);
965
- const bonusDeduction = amount - balanceDeduction;
966
- const previousBalance = credits.balance + credits.bonusCredits;
967
- credits.balance -= balanceDeduction;
968
- credits.bonusCredits -= bonusDeduction;
2120
+ const bonusDeduction = sumAmounts(amount, -balanceDeduction);
2121
+ const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
2122
+ const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
2123
+ const nextBonusCredits = sumAmounts(credits.bonusCredits, -bonusDeduction);
2124
+ const newBalance = sumAmounts(previousBalance, -amount);
2125
+ assertRepresentableFields(
2126
+ { previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
2127
+ { userId, operation: "deductCredits" }
2128
+ );
2129
+ credits.balance = nextBalance;
2130
+ credits.bonusCredits = nextBonusCredits;
969
2131
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
970
- this.users.set(userId, credits);
971
- return { previousBalance, newBalance: previousBalance - amount };
2132
+ this.store.users.set(userId, credits);
2133
+ return { previousBalance, newBalance };
972
2134
  }
973
2135
  // ==================== Transactions ====================
974
2136
  async createTransaction(input) {
2137
+ assertUnreferencedDirectTransaction(input);
2138
+ return this.recordTransaction({
2139
+ ...input,
2140
+ paymentRef: normalizePaymentRef(input.paymentRef)
2141
+ });
2142
+ }
2143
+ /** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
2144
+ recordTransaction(input) {
2145
+ assertRepresentableAmount(input.amount, "transaction amount", { userId: input.userId });
2146
+ assertRepresentableAmount(input.previousBalance, "transaction previousBalance", {
2147
+ userId: input.userId
2148
+ });
2149
+ assertRepresentableAmount(input.newBalance, "transaction newBalance", {
2150
+ userId: input.userId
2151
+ });
975
2152
  const transaction = {
976
2153
  id: generateId(),
977
2154
  userId: input.userId,
@@ -983,23 +2160,24 @@ var InMemoryCreditRepository = class {
983
2160
  newBalance: input.newBalance,
984
2161
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
985
2162
  };
986
- if (!this.transactions.has(input.userId)) {
987
- this.transactions.set(input.userId, []);
2163
+ if (!this.store.transactions.has(input.userId)) {
2164
+ this.store.transactions.set(input.userId, []);
988
2165
  }
989
- this.transactions.get(input.userId).push(transaction);
990
- return transaction;
2166
+ this.store.transactions.get(input.userId).push(transaction);
2167
+ return copyRecord(transaction);
991
2168
  }
992
2169
  async getTransactions(userId, limit = 50, offset = 0) {
993
- const userTransactions = this.transactions.get(userId) ?? [];
2170
+ const userTransactions = this.store.transactions.get(userId) ?? [];
994
2171
  const sorted = [...userTransactions].sort((a, b) => {
995
2172
  const aDate = toDate2(a.createdAt).getTime();
996
2173
  const bDate = toDate2(b.createdAt).getTime();
997
2174
  return bDate - aDate;
998
2175
  });
999
- return sorted.slice(offset, offset + limit);
2176
+ return copyRecords(sorted.slice(offset, offset + limit));
1000
2177
  }
1001
2178
  // ==================== Usage Logs ====================
1002
2179
  async logUsage(input) {
2180
+ assertRepresentableAmount(input.creditsUsed, "creditsUsed", { userId: input.userId });
1003
2181
  const log = {
1004
2182
  id: generateId(),
1005
2183
  userId: input.userId,
@@ -1011,14 +2189,14 @@ var InMemoryCreditRepository = class {
1011
2189
  resourceId: input.resourceId,
1012
2190
  resourceType: input.resourceType,
1013
2191
  requestId: input.requestId,
1014
- metadata: input.metadata,
2192
+ metadata: input.metadata ? { ...input.metadata } : input.metadata,
1015
2193
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1016
2194
  };
1017
- this.usageLogs.push(log);
1018
- return log;
2195
+ this.store.usageLogs.push(log);
2196
+ return copyRecord(log);
1019
2197
  }
1020
2198
  async getUsageLogs(query) {
1021
- let results = [...this.usageLogs];
2199
+ let results = [...this.store.usageLogs];
1022
2200
  if (query.userId) {
1023
2201
  results = results.filter((log) => log.userId === query.userId);
1024
2202
  }
@@ -1045,7 +2223,7 @@ var InMemoryCreditRepository = class {
1045
2223
  });
1046
2224
  const offset = query.offset ?? 0;
1047
2225
  const limit = query.limit ?? 50;
1048
- return results.slice(offset, offset + limit);
2226
+ return copyRecords(results.slice(offset, offset + limit));
1049
2227
  }
1050
2228
  async getUsageLogsCount(query) {
1051
2229
  const results = await this.getUsageLogs({ ...query, limit: Infinity, offset: 0 });
@@ -1053,6 +2231,29 @@ var InMemoryCreditRepository = class {
1053
2231
  }
1054
2232
  // ==================== Journal Entries ====================
1055
2233
  async createJournalEntry(input) {
2234
+ return this.recordJournalEntry(input);
2235
+ }
2236
+ /**
2237
+ * The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
2238
+ * write its journal line inside the same run-to-completion step as the
2239
+ * balance write — an `await` between the two is exactly the gap that loses
2240
+ * the line when the second half fails.
2241
+ */
2242
+ recordJournalEntry(input) {
2243
+ assertRepresentableAmount(input.amount, "journal amount", { userId: input.userId });
2244
+ assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
2245
+ userId: input.userId
2246
+ });
2247
+ assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
2248
+ assertPublicJournalKey(input.idempotencyKey, input.userId);
2249
+ const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
2250
+ if (key !== void 0 && this.store.journalKeys.has(key)) {
2251
+ throw new CreditError(
2252
+ `Journal idempotency key ${input.idempotencyKey} is already used for user ${input.userId}. The unique constraint refuses a second entry.`,
2253
+ CreditErrorCode.DATABASE_ERROR,
2254
+ { userId: input.userId, idempotencyKey: input.idempotencyKey }
2255
+ );
2256
+ }
1056
2257
  const entry = {
1057
2258
  id: generateId(),
1058
2259
  userId: input.userId,
@@ -1063,17 +2264,19 @@ var InMemoryCreditRepository = class {
1063
2264
  referenceId: input.referenceId,
1064
2265
  referenceType: input.referenceType,
1065
2266
  description: input.description,
1066
- metadata: input.metadata,
2267
+ metadata: input.metadata ? { ...input.metadata } : input.metadata,
2268
+ idempotencyKey: input.idempotencyKey,
1067
2269
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1068
2270
  };
1069
- if (!this.journalEntries.has(input.userId)) {
1070
- this.journalEntries.set(input.userId, []);
2271
+ if (!this.store.journalEntries.has(input.userId)) {
2272
+ this.store.journalEntries.set(input.userId, []);
1071
2273
  }
1072
- this.journalEntries.get(input.userId).push(entry);
1073
- return entry;
2274
+ this.store.journalEntries.get(input.userId).push(entry);
2275
+ if (key !== void 0) this.store.journalKeys.set(key, entry.id);
2276
+ return copyRecord(entry);
1074
2277
  }
1075
2278
  async getJournalEntries(query) {
1076
- let results = this.journalEntries.get(query.userId) ?? [];
2279
+ let results = this.store.journalEntries.get(query.userId) ?? [];
1077
2280
  if (query.source) {
1078
2281
  results = results.filter((entry) => entry.source === query.source);
1079
2282
  }
@@ -1097,7 +2300,7 @@ var InMemoryCreditRepository = class {
1097
2300
  });
1098
2301
  const offset = query.offset ?? 0;
1099
2302
  const limit = query.limit ?? 50;
1100
- return results.slice(offset, offset + limit);
2303
+ return copyRecords(results.slice(offset, offset + limit));
1101
2304
  }
1102
2305
  async getJournalEntriesCount(query) {
1103
2306
  const results = await this.getJournalEntries({
@@ -1108,53 +2311,91 @@ var InMemoryCreditRepository = class {
1108
2311
  return results.length;
1109
2312
  }
1110
2313
  // ==================== Cleanup Operations ====================
2314
+ /**
2315
+ * Expire every hold whose deadline has passed.
2316
+ *
2317
+ * Each candidate goes through the guarded {@link expireReservationV2}, which
2318
+ * re-checks the status and the deadline under the user's lock. The previous
2319
+ * implementation released the hold and then overwrote the status to
2320
+ * `expired`, so a commit landing in between got its credits handed back and
2321
+ * was then relabelled.
2322
+ */
1111
2323
  async findAndExpireReservations(_batchSize = 100, _maxIterations = 100) {
1112
- const now = /* @__PURE__ */ new Date();
2324
+ const asOf = /* @__PURE__ */ new Date();
1113
2325
  let expiredCount = 0;
1114
2326
  let creditsReleased = 0;
1115
2327
  const errors = [];
1116
- for (const [userId, userReservations] of this.reservations) {
2328
+ const candidates = [];
2329
+ for (const [userId, userReservations] of this.store.reservations) {
1117
2330
  for (const [reservationId, reservation] of userReservations) {
1118
- if (reservation.status === "reserved" && toDate2(reservation.expiresAt).getTime() < now.getTime()) {
1119
- try {
1120
- await this.releaseReservationAtomic(userId, reservationId);
1121
- reservation.status = "expired";
1122
- userReservations.set(reservationId, reservation);
1123
- expiredCount++;
1124
- creditsReleased += reservation.amount;
1125
- } catch (error) {
1126
- errors.push(
1127
- `Failed to expire reservation ${reservationId}: ${error}`
1128
- );
1129
- }
2331
+ if (reservation.status === "reserved" && toDate2(reservation.expiresAt).getTime() < asOf.getTime()) {
2332
+ candidates.push({ userId, reservationId });
2333
+ }
2334
+ }
2335
+ }
2336
+ for (const candidate of candidates) {
2337
+ try {
2338
+ const outcome = await this.expireReservationV2(
2339
+ candidate.userId,
2340
+ candidate.reservationId,
2341
+ { asOf }
2342
+ );
2343
+ if (outcome.outcome === "expired") {
2344
+ expiredCount++;
2345
+ creditsReleased = sumAmounts(creditsReleased, outcome.amount);
1130
2346
  }
2347
+ } catch (error) {
2348
+ errors.push(
2349
+ `Failed to expire reservation ${candidate.reservationId}: ${error}`
2350
+ );
1131
2351
  }
1132
2352
  }
1133
2353
  return { expiredCount, creditsReleased, errors };
1134
2354
  }
1135
2355
  // ==================== Atomic Monthly Reset ====================
1136
2356
  async atomicMonthlyReset(userId, tier, expectedResetAt) {
1137
- const credits = this.users.get(userId);
2357
+ const credits = this.store.users.get(userId);
1138
2358
  if (!credits) {
1139
2359
  throw new Error(`User ${userId} not found`);
1140
2360
  }
1141
2361
  const currentResetAt = toDate2(credits.monthlyResetAt).getTime();
1142
2362
  const expected = toDate2(expectedResetAt).getTime();
1143
2363
  if (currentResetAt !== expected) {
1144
- return { wasReset: false, credits };
2364
+ return { wasReset: false, credits: { ...credits } };
1145
2365
  }
1146
- const newBalance = getConfigMonthlyLimit(tier);
2366
+ assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
1147
2367
  const nextReset = getNextMonthlyReset();
1148
- credits.balance = newBalance === Infinity ? credits.balance : newBalance;
2368
+ const target = monthlyResetBalance(tier);
2369
+ const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
2370
+ const previousBalance = credits.balance;
2371
+ const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
2372
+ const change = sumAmounts(newBalance, -previousBalance);
2373
+ const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2374
+ assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
2375
+ assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
2376
+ credits.balance = newBalance;
1149
2377
  credits.monthlyUsed = 0;
1150
2378
  credits.monthlyResetAt = nextReset.toISOString();
1151
2379
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1152
- this.users.set(userId, credits);
1153
- return { wasReset: true, credits };
2380
+ this.store.users.set(userId, credits);
2381
+ if (change !== 0) {
2382
+ this.recordJournalEntry({
2383
+ userId,
2384
+ entryType: change > 0 ? "credit" : "debit",
2385
+ amount: Math.abs(change),
2386
+ balanceAfter,
2387
+ source: "monthly_reset",
2388
+ referenceId: `reset-${Date.now()}`,
2389
+ referenceType: "reset",
2390
+ description: `Monthly credit reset for ${tier} tier.`,
2391
+ metadata: { tier, previousBalance, newBalance }
2392
+ });
2393
+ }
2394
+ return { wasReset: true, credits: { ...credits }, journaled: true };
1154
2395
  }
1155
2396
  // ==================== Subscription Expiry ====================
1156
2397
  async checkAndHandleSubscriptionExpiry(userId, gracePeriodDays = 3) {
1157
- const credits = this.users.get(userId);
2398
+ const credits = this.store.users.get(userId);
1158
2399
  if (!credits) {
1159
2400
  throw new Error(`User ${userId} not found`);
1160
2401
  }
@@ -1163,7 +2404,7 @@ var InMemoryCreditRepository = class {
1163
2404
  wasDowngraded: false,
1164
2405
  inGracePeriod: false,
1165
2406
  graceDaysRemaining: 0,
1166
- credits
2407
+ credits: { ...credits }
1167
2408
  };
1168
2409
  }
1169
2410
  const now = /* @__PURE__ */ new Date();
@@ -1174,7 +2415,7 @@ var InMemoryCreditRepository = class {
1174
2415
  wasDowngraded: false,
1175
2416
  inGracePeriod: false,
1176
2417
  graceDaysRemaining: 0,
1177
- credits
2418
+ credits: { ...credits }
1178
2419
  };
1179
2420
  }
1180
2421
  if (daysSinceExpiry <= gracePeriodDays) {
@@ -1182,22 +2423,48 @@ var InMemoryCreditRepository = class {
1182
2423
  wasDowngraded: false,
1183
2424
  inGracePeriod: true,
1184
2425
  graceDaysRemaining: Math.ceil(gracePeriodDays - daysSinceExpiry),
1185
- credits
2426
+ credits: { ...credits }
1186
2427
  };
1187
2428
  }
1188
2429
  const defaultTier = getDefaultTier();
1189
- const defaultTierConfig = getConfigTierConfig(defaultTier);
2430
+ const configured = getConfigMonthlyLimit(defaultTier);
2431
+ assertRepresentableTierAmount(configured, "monthlyLimit", {
2432
+ userId,
2433
+ tier: defaultTier
2434
+ });
2435
+ const limit = storedMonthlyLimit(configured);
2436
+ const previousTier = credits.tier;
2437
+ const previousBalance = credits.balance;
2438
+ const newBalance = Math.max(
2439
+ Math.min(credits.balance, limit),
2440
+ backedBalanceFloor(credits.reserved, credits.bonusCredits)
2441
+ );
2442
+ const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
2443
+ assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
1190
2444
  credits.tier = defaultTier;
1191
- credits.monthlyLimit = defaultTierConfig.monthlyCredits;
1192
- credits.balance = Math.min(credits.balance, defaultTierConfig.monthlyCredits);
2445
+ credits.monthlyLimit = limit;
2446
+ credits.balance = newBalance;
1193
2447
  credits.subscriptionExpiresAt = null;
1194
2448
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1195
- this.users.set(userId, credits);
2449
+ this.store.users.set(userId, credits);
2450
+ this.recordJournalEntry({
2451
+ userId,
2452
+ entryType: "debit",
2453
+ amount: 0,
2454
+ // No credits deducted, just tier change
2455
+ balanceAfter,
2456
+ source: "subscription_downgrade",
2457
+ referenceId: `downgrade-${Date.now()}`,
2458
+ referenceType: "subscription",
2459
+ description: `Subscription expired. Downgraded from ${previousTier} to ${defaultTier} tier.`,
2460
+ metadata: { previousTier, previousBalance, newBalance }
2461
+ });
1196
2462
  return {
1197
2463
  wasDowngraded: true,
1198
2464
  inGracePeriod: false,
1199
2465
  graceDaysRemaining: 0,
1200
- credits
2466
+ credits: { ...credits },
2467
+ journaled: true
1201
2468
  };
1202
2469
  }
1203
2470
  // ==================== Testing Utilities ====================
@@ -1205,25 +2472,33 @@ var InMemoryCreditRepository = class {
1205
2472
  * Clear all data (useful for testing)
1206
2473
  */
1207
2474
  clear() {
1208
- this.users.clear();
1209
- this.reservations.clear();
1210
- this.transactions.clear();
1211
- this.usageLogs = [];
1212
- this.journalEntries.clear();
2475
+ this.store.clear();
2476
+ }
2477
+ /**
2478
+ * Install a yield point inside every V2 critical section (testing only).
2479
+ *
2480
+ * Unset, the critical sections run start-to-finish synchronously, so
2481
+ * concurrent callers never interleave and a concurrency test would pass even
2482
+ * with the locking removed. Setting this to a real yield (a macrotask or a
2483
+ * barrier) makes callers genuinely overlap, so the tests exercise the lock
2484
+ * instead of the event loop. Never call this in production code.
2485
+ */
2486
+ setSchedulingHook(hook) {
2487
+ this.store.schedulingHook = hook;
1213
2488
  }
1214
2489
  /**
1215
2490
  * Get all users (useful for testing/debugging)
1216
2491
  */
1217
2492
  getAllUsers() {
1218
- return Array.from(this.users.values());
2493
+ return copyRecords(Array.from(this.store.users.values()));
1219
2494
  }
1220
2495
  /**
1221
2496
  * Get all reservations for a user (useful for testing)
1222
2497
  */
1223
2498
  getAllReservations(userId) {
1224
- const userReservations = this.reservations.get(userId);
2499
+ const userReservations = this.store.reservations.get(userId);
1225
2500
  if (!userReservations) return [];
1226
- return Array.from(userReservations.values());
2501
+ return copyRecords(Array.from(userReservations.values()));
1227
2502
  }
1228
2503
  };
1229
2504
  function createInMemoryCreditRepository() {
@@ -1334,28 +2609,38 @@ var CreditsService = class {
1334
2609
  userId,
1335
2610
  getConfig().subscriptionGracePeriodDays
1336
2611
  );
1337
- if (expiryResult.wasDowngraded) {
2612
+ if (expiryResult.wasDowngraded && !expiryResult.journaled) {
1338
2613
  await this.repository.createJournalEntry({
1339
2614
  userId,
1340
2615
  entryType: "debit",
1341
2616
  amount: 0,
1342
2617
  // No credits deducted, just tier change
1343
- balanceAfter: expiryResult.credits.balance,
2618
+ // The ledger balance, matching every other journal writer: the V2
2619
+ // transitions and add/deduct all record `balance + bonusCredits`, so
2620
+ // recording `balance` alone made the audit trail disagree with itself
2621
+ // for any user holding bonus credits.
2622
+ balanceAfter: sumAmounts(
2623
+ expiryResult.credits.balance,
2624
+ expiryResult.credits.bonusCredits
2625
+ ),
1344
2626
  source: "subscription_downgrade",
1345
2627
  referenceId: `downgrade-${Date.now()}`,
1346
2628
  referenceType: "subscription",
1347
- description: `Subscription expired. Downgraded from ${data.tier} to free tier.`,
2629
+ // The tier actually landed on, not a hard-coded "free": the downgrade
2630
+ // target is configurable, so naming `free` here could describe a
2631
+ // transition that never happened.
2632
+ description: `Subscription expired. Downgraded from ${data.tier} to ${expiryResult.credits.tier} tier.`,
1348
2633
  metadata: {
1349
2634
  previousTier: data.tier,
1350
2635
  previousBalance: data.balance,
1351
2636
  newBalance: expiryResult.credits.balance
1352
2637
  }
1353
2638
  });
1354
- if (this.subscriptionExpiredCallback) {
1355
- this.subscriptionExpiredCallback(userId, true).catch((error) => {
1356
- console.error("[Credits] Failed to send subscription expired notification:", error);
1357
- });
1358
- }
2639
+ }
2640
+ if (expiryResult.wasDowngraded && this.subscriptionExpiredCallback) {
2641
+ this.subscriptionExpiredCallback(userId, true).catch((error) => {
2642
+ console.error("[Credits] Failed to send subscription expired notification:", error);
2643
+ });
1359
2644
  }
1360
2645
  data = expiryResult.credits;
1361
2646
  }
@@ -1366,14 +2651,17 @@ var CreditsService = class {
1366
2651
  data.tier,
1367
2652
  expectedResetAt
1368
2653
  );
1369
- if (resetResult.wasReset) {
1370
- const balanceChange = resetResult.credits.balance - data.balance;
2654
+ if (resetResult.wasReset && !resetResult.journaled) {
2655
+ const balanceChange = sumAmounts(resetResult.credits.balance, -data.balance);
1371
2656
  if (balanceChange !== 0) {
1372
2657
  await this.repository.createJournalEntry({
1373
2658
  userId,
1374
2659
  entryType: balanceChange > 0 ? "credit" : "debit",
1375
2660
  amount: Math.abs(balanceChange),
1376
- balanceAfter: resetResult.credits.balance,
2661
+ balanceAfter: sumAmounts(
2662
+ resetResult.credits.balance,
2663
+ resetResult.credits.bonusCredits
2664
+ ),
1377
2665
  source: "monthly_reset",
1378
2666
  referenceId: `reset-${Date.now()}`,
1379
2667
  referenceType: "reset",
@@ -1424,14 +2712,14 @@ var CreditsService = class {
1424
2712
  */
1425
2713
  async checkCredits(userId, requiredCredits) {
1426
2714
  const credits = await this.getOrCreateUserCredits(userId);
1427
- const totalBalance = credits.balance + credits.bonusCredits;
1428
- const available = totalBalance - credits.reserved;
2715
+ const totalBalance = sumAmounts(credits.balance, credits.bonusCredits);
2716
+ const available = sumAmounts(totalBalance, -credits.reserved);
1429
2717
  const hasCredits = available >= requiredCredits;
1430
2718
  return {
1431
2719
  hasCredits,
1432
2720
  balance: totalBalance,
1433
2721
  required: requiredCredits,
1434
- shortfall: hasCredits ? 0 : requiredCredits - available
2722
+ shortfall: hasCredits ? 0 : sumAmounts(requiredCredits, -available)
1435
2723
  };
1436
2724
  }
1437
2725
  /**
@@ -1445,74 +2733,110 @@ var CreditsService = class {
1445
2733
  * @throws Error if insufficient credits
1446
2734
  */
1447
2735
  async reserveCredits(userId, amount, operationType, options) {
2736
+ const outcome = await this.reserveCreditsDetailed(userId, amount, operationType, options);
2737
+ if (outcome.outcome === "created" || outcome.outcome === "replayed") {
2738
+ return outcome.reservation;
2739
+ }
2740
+ if (outcome.outcome === "insufficient") {
2741
+ throw createInsufficientCreditsError(outcome.required, outcome.available);
2742
+ }
2743
+ throw createIdempotencyConflictError(outcome.idempotencyKey, {
2744
+ userId,
2745
+ requested: { amount, operationType },
2746
+ existing: {
2747
+ amount: outcome.existing.amount,
2748
+ operationType: outcome.existing.operationType
2749
+ }
2750
+ });
2751
+ }
2752
+ /**
2753
+ * Reserve credits and get the typed outcome instead of an exception.
2754
+ *
2755
+ * Prefer this over {@link reserveCredits} in async callers: it distinguishes
2756
+ * a fresh hold from an idempotent replay, and reports insufficient funds as
2757
+ * a value rather than as control flow.
2758
+ */
2759
+ async reserveCreditsDetailed(userId, amount, operationType, options) {
1448
2760
  const ttlMs = options?.ttlMs ?? getConfig().reservationExpiryMs;
1449
2761
  const expiresAt = new Date(Date.now() + ttlMs);
1450
- return this.repository.reserveCreditsAtomic(userId, amount, operationType, expiresAt);
2762
+ return reserveThroughRepository(this.repository, {
2763
+ userId,
2764
+ amount,
2765
+ operationType,
2766
+ expiresAt,
2767
+ idempotencyKey: options?.idempotencyKey
2768
+ });
1451
2769
  }
1452
2770
  /**
1453
- * Commit a reservation (phase 2 of two-phase commit - success)
1454
- * Deducts credits and marks reservation as committed
1455
- * Also triggers low balance notifications if balance drops below threshold
2771
+ * Commit a reservation (phase 2 of two-phase commit - success).
2772
+ *
2773
+ * Idempotent: re-delivering a commit for an already-committed reservation is
2774
+ * a no-op. Committing one that was released or expired throws, because the
2775
+ * credits are no longer held and pretending otherwise would hide a real bug.
1456
2776
  */
1457
- async commitCredits(userId, reservationId) {
1458
- const reservation = await this.repository.getReservation(userId, reservationId);
1459
- if (!reservation) {
1460
- throw new Error(`Reservation ${reservationId} not found`);
2777
+ async commitCredits(userId, reservationId, options) {
2778
+ const outcome = await this.commitCreditsDetailed(userId, reservationId, options);
2779
+ if (outcome.outcome === "not_found") {
2780
+ throw createReservationNotFoundError(reservationId);
1461
2781
  }
1462
- if (reservation.status === "committed") {
1463
- return;
2782
+ if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
2783
+ throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
1464
2784
  }
1465
- await this.repository.commitReservationAtomic(userId, reservationId);
1466
- const credits = await this.repository.getUserCredits(userId);
1467
- if (credits) {
1468
- await this.repository.createJournalEntry({
1469
- userId,
1470
- entryType: "debit",
1471
- amount: reservation.amount,
1472
- balanceAfter: credits.balance,
1473
- source: "operation_commit",
1474
- referenceId: reservationId,
1475
- referenceType: "reservation",
1476
- description: `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
1477
- metadata: {
1478
- operationType: reservation.operationType
1479
- }
2785
+ }
2786
+ /**
2787
+ * Commit a reservation and get the typed outcome.
2788
+ *
2789
+ * Exactly one concurrent caller sees `committed`; that call moved the balance
2790
+ * and wrote the single journal entry. On a V2 repository the journal is
2791
+ * written inside the same transaction, so this method adds none of its own —
2792
+ * the duplicate entry the previous implementation wrote is gone.
2793
+ *
2794
+ * The low-balance notification fires only for the winner, and only after the
2795
+ * transaction has committed, so no callback runs inside a database
2796
+ * transaction.
2797
+ */
2798
+ async commitCreditsDetailed(userId, reservationId, options) {
2799
+ const outcome = await commitThroughRepository(
2800
+ this.repository,
2801
+ userId,
2802
+ reservationId,
2803
+ options
2804
+ );
2805
+ if (outcome.outcome === "committed" && this.lowBalanceCallback) {
2806
+ this.lowBalanceCallback(userId, outcome.balanceAfter).catch((error) => {
2807
+ console.error("[Credits] Failed to send low balance notification:", error);
1480
2808
  });
1481
- if (this.lowBalanceCallback) {
1482
- this.lowBalanceCallback(userId, credits.balance).catch((error) => {
1483
- console.error("[Credits] Failed to send low balance notification:", error);
1484
- });
1485
- }
1486
2809
  }
2810
+ return outcome;
1487
2811
  }
1488
2812
  /**
1489
- * Release a reservation (phase 2 of two-phase commit - failure)
1490
- * Returns reserved credits and marks reservation as released
2813
+ * Release a reservation (phase 2 of two-phase commit - failure).
2814
+ *
2815
+ * Throws `RESERVATION_NOT_FOUND` for an unknown reservation, matching the
2816
+ * pre-V2 behaviour — a release naming a reservation that does not exist is a
2817
+ * caller bug, and swallowing it hides the bug while the credits stay held.
2818
+ *
2819
+ * Releasing one that is already terminal — `released`, `expired`, or
2820
+ * `committed` — is a no-op, which is the behaviour this method has always
2821
+ * had. A release that loses a race to a concurrent commit is the common
2822
+ * case in a retry-heavy caller, and callers written against the pre-V2
2823
+ * contract handle it by not handling it.
2824
+ *
2825
+ * That does mean this method cannot tell you that the credits were spent
2826
+ * rather than returned. Use {@link releaseCreditsDetailed}, which reports
2827
+ * `already_terminal` with `terminalStatus: 'committed'`, when you need to
2828
+ * branch on it.
1491
2829
  */
1492
- async releaseCredits(userId, reservationId) {
1493
- const reservation = await this.repository.getReservation(userId, reservationId);
1494
- await this.repository.releaseReservationAtomic(userId, reservationId);
1495
- if (reservation?.status === "reserved") {
1496
- const credits = await this.repository.getUserCredits(userId);
1497
- if (credits) {
1498
- await this.repository.createJournalEntry({
1499
- userId,
1500
- entryType: "credit",
1501
- amount: 0,
1502
- // No actual credits returned (they were reserved, not spent)
1503
- balanceAfter: credits.balance,
1504
- source: "operation_release",
1505
- referenceId: reservationId,
1506
- referenceType: "reservation",
1507
- description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1508
- metadata: {
1509
- operationType: reservation.operationType,
1510
- amount: reservation.amount
1511
- }
1512
- });
1513
- }
2830
+ async releaseCredits(userId, reservationId, options) {
2831
+ const outcome = await this.releaseCreditsDetailed(userId, reservationId, options);
2832
+ if (outcome.outcome === "not_found") {
2833
+ throw createReservationNotFoundError(reservationId);
1514
2834
  }
1515
2835
  }
2836
+ /** Release a reservation and get the typed outcome. */
2837
+ async releaseCreditsDetailed(userId, reservationId, options) {
2838
+ return releaseThroughRepository(this.repository, userId, reservationId, options);
2839
+ }
1516
2840
  /**
1517
2841
  * Log usage for audit trail
1518
2842
  * @param log - Usage log data
@@ -1528,9 +2852,20 @@ var CreditsService = class {
1528
2852
  * @param paymentRef - Optional payment reference
1529
2853
  * @param options - Optional journal source / reference type / extra metadata
1530
2854
  * for the credit journal entry (e.g. revenue-attribution fields).
2855
+ * @returns Whether this delivery credited the account (`created`), repeated an
2856
+ * earlier one (`replayed`), or reused the reference for a different credit
2857
+ * event (`conflict`, which credited nothing). On a repository without
2858
+ * `addCreditsV2` the result is always `created` and carries no
2859
+ * deduplication guarantee — see {@link addCreditsThroughRepository}.
1531
2860
  */
1532
2861
  async addCredits(userId, amount, description, paymentRef, options) {
1533
- return this.repository.addCreditsAtomic(userId, amount, description, paymentRef, options);
2862
+ return addCreditsThroughRepository(this.repository, {
2863
+ userId,
2864
+ amount,
2865
+ description,
2866
+ paymentRef,
2867
+ options
2868
+ });
1534
2869
  }
1535
2870
  /**
1536
2871
  * One-shot atomic "deduct-if-sufficient" credit charge.
@@ -1554,15 +2889,14 @@ var CreditsService = class {
1554
2889
  * @throws Error if amount is not a positive number
1555
2890
  */
1556
2891
  async deductCredits(userId, amount, options) {
1557
- if (!(amount > 0)) {
1558
- throw new Error(`deductCredits amount must be positive (got ${amount})`);
1559
- }
2892
+ assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
1560
2893
  let deduction;
1561
2894
  try {
1562
2895
  deduction = await this.repository.deductCreditsAtomic(userId, amount);
1563
2896
  } catch (error) {
2897
+ if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
1564
2898
  const credits = await this.repository.getUserCredits(userId);
1565
- const available = credits ? credits.balance + credits.bonusCredits - credits.reserved : 0;
2899
+ const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
1566
2900
  if (available >= amount) {
1567
2901
  throw error;
1568
2902
  }
@@ -1571,7 +2905,7 @@ var CreditsService = class {
1571
2905
  reason: "insufficient",
1572
2906
  available,
1573
2907
  required: amount,
1574
- shortfall: amount - available
2908
+ shortfall: sumAmounts(amount, -available)
1575
2909
  };
1576
2910
  }
1577
2911
  const operationType = options?.operationType;
@@ -1585,8 +2919,8 @@ var CreditsService = class {
1585
2919
  referenceType: "adjustment",
1586
2920
  description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
1587
2921
  metadata: {
1588
- ...operationType && { operationType },
1589
- ...options?.metadata
2922
+ ...options?.metadata,
2923
+ ...operationType && { operationType }
1590
2924
  }
1591
2925
  });
1592
2926
  if (operationType) {
@@ -1621,8 +2955,15 @@ var CreditsService = class {
1621
2955
  const free = isFreeTier(tier);
1622
2956
  await this.repository.updateUserTier(userId, {
1623
2957
  tier,
1624
- // Stored monthlyLimit uses the 0-means-unlimited convention.
1625
- monthlyLimit: unlimited ? 0 : monthlyLimit,
2958
+ // Through the shared contract, like every path that resolves the tier's
2959
+ // limit from configuration. (The repository's `updateUserCredits` and
2960
+ // `updateUserTier` still store a caller-supplied `monthlyLimit` verbatim;
2961
+ // they are given a value, not a tier, so there is no sentinel to map.)
2962
+ // This used to store 0 for an unlimited tier under a "0 means unlimited"
2963
+ // convention that nothing actually implemented — no read path anywhere in
2964
+ // the library branches on it — so upgrading a user to unlimited gave them
2965
+ // an allowance of zero.
2966
+ monthlyLimit: storedMonthlyLimit(monthlyLimit),
1626
2967
  // Reset balance to new tier limit if upgrading
1627
2968
  balance: !free ? unlimited ? getUnlimitedSentinelBalance() : monthlyLimit : void 0,
1628
2969
  monthlyUsed: !free ? 0 : void 0,
@@ -2313,7 +3654,7 @@ var CreditsClient = class {
2313
3654
  */
2314
3655
  async getAvailableCredits() {
2315
3656
  const balance = await this.getBalance();
2316
- return balance.balance + balance.bonusCredits - balance.reserved;
3657
+ return sumAmounts(balance.balance, balance.bonusCredits, -balance.reserved);
2317
3658
  }
2318
3659
  /**
2319
3660
  * Execute an operation with automatic credit handling
@@ -2542,7 +3883,7 @@ function parseCreditError(error) {
2542
3883
  return {
2543
3884
  available,
2544
3885
  required,
2545
- shortfall: required - available,
3886
+ shortfall: sumAmounts(required, -available),
2546
3887
  rawMessage: error
2547
3888
  };
2548
3889
  }
@@ -2553,7 +3894,7 @@ function isCreditErrorMessage(error) {
2553
3894
 
2554
3895
  // src/utils/index.ts
2555
3896
  function getTotalCredits(credits) {
2556
- return credits.balance + credits.bonusCredits;
3897
+ return sumAmounts(credits.balance, credits.bonusCredits);
2557
3898
  }
2558
3899
  function generateRequestId2() {
2559
3900
  const timestamp = Date.now();
@@ -2581,6 +3922,8 @@ function createDummyReservation2(userId, operationType) {
2581
3922
  ApiKeyCreditsProvider,
2582
3923
  AuthenticationError,
2583
3924
  AuthorizationError,
3925
+ CREDIT_AMOUNT_MAX,
3926
+ CREDIT_AMOUNT_SCALE,
2584
3927
  ConsoleNotificationHandler,
2585
3928
  CreditError,
2586
3929
  CreditErrorCode,
@@ -2589,31 +3932,57 @@ function createDummyReservation2(userId, operationType) {
2589
3932
  CreditsService,
2590
3933
  InMemoryCreditRepository,
2591
3934
  NetworkError,
3935
+ RESERVATION_STATUSES,
3936
+ RESERVED_JOURNAL_KEY_PREFIX,
2592
3937
  SDKErrorCode,
2593
3938
  SDKInsufficientCreditsError,
2594
3939
  SDKReservationExpiredError,
2595
3940
  SDKReservationNotFoundError,
2596
3941
  ServerError,
3942
+ TERMINAL_RESERVATION_STATUSES,
2597
3943
  UNLIMITED_BALANCE_SENTINEL,
2598
3944
  ValidationError,
3945
+ addCreditsThroughRepository,
3946
+ assertDirectStatusWriteAllowed,
3947
+ assertHoldPlaced,
3948
+ assertKnownReservationStatus,
3949
+ assertPublicJournalKey,
3950
+ assertRepresentableAmount,
3951
+ assertRepresentableFields,
3952
+ assertRepresentableTierAmount,
3953
+ assertUnkeyedDirectReservation,
3954
+ assertUnreferencedDirectTransaction,
3955
+ assertValidCreditAmount,
3956
+ assertValidIdempotencyKey,
3957
+ assertValidStoredAmount,
3958
+ assertValidStoredAmountRaw,
3959
+ backedBalanceFloor,
2599
3960
  calculateAvailableCredits,
2600
3961
  checkAndNotifyLowBalance,
3962
+ classifyDatabaseError,
2601
3963
  clearConfigProvider,
2602
3964
  clearCooldownState,
2603
3965
  clearNotificationHandlers,
2604
3966
  commitReservationWithJournal,
3967
+ commitThroughRepository,
2605
3968
  createApiKeyProvider,
2606
3969
  createCreditsService,
2607
3970
  createDeferredExecutor,
2608
3971
  createDummyReservation,
2609
3972
  createGenericAdapter,
3973
+ createIdempotencyConflictError,
2610
3974
  createInMemoryCreditRepository,
2611
3975
  createInsufficientCreditsError,
3976
+ createInvalidAmountError,
2612
3977
  createInvalidOperationTypeError,
3978
+ createPaymentRefConflictError,
2613
3979
  createReservationAlreadyProcessedError,
2614
3980
  createReservationExpiredError,
2615
3981
  createReservationNotFoundError,
3982
+ createTransientError,
3983
+ createUnsupportedOperationError,
2616
3984
  createUserNotFoundError,
3985
+ describePaymentMismatch,
2617
3986
  generateId,
2618
3987
  generateRequestId,
2619
3988
  genericDeferred,
@@ -2632,35 +4001,63 @@ function createDummyReservation2(userId, operationType) {
2632
4001
  getOperationLabel,
2633
4002
  getOperationLabels,
2634
4003
  getReservationExpiryMs,
4004
+ getSqlState,
2635
4005
  getTierConfig,
2636
4006
  getTotalCredits,
2637
4007
  getUnlimitedSentinelBalance,
2638
4008
  getValidOperationTypes,
2639
4009
  getValidTiers,
4010
+ hasPlacedHold,
2640
4011
  initializeConfig,
2641
4012
  isCreditError,
2642
4013
  isCreditErrorMessage,
4014
+ isCreditedOutcome,
2643
4015
  isFeatureEnabled,
2644
4016
  isFreeTier,
4017
+ isIdempotencyConflictError,
2645
4018
  isInsufficientCreditsError,
2646
4019
  isPreviewMode,
4020
+ isRepresentableAmount,
4021
+ isReservationStatus,
4022
+ isReservedJournalKey,
4023
+ isReservedOutcome,
4024
+ isTerminalReservationStatus,
4025
+ isTransientDatabaseError,
4026
+ isTransientError,
4027
+ isUniqueViolation,
2647
4028
  isUnlimitedTier,
4029
+ isValidCreditAmount,
4030
+ isValidIdempotencyKey,
2648
4031
  isValidOperationType,
2649
4032
  isValidTier,
4033
+ isWinningOutcome,
2650
4034
  loadConfigFromEnv,
4035
+ monthlyResetBalance,
2651
4036
  noopDeferred,
4037
+ normalizePaymentRef,
2652
4038
  notifySubscriptionExpired,
2653
4039
  notifySubscriptionExpiring,
4040
+ numericToCents,
2654
4041
  parseApiError,
2655
4042
  parseCreditError,
2656
4043
  parseTier,
2657
4044
  registerConfigProvider,
2658
4045
  registerNotificationHandler,
2659
4046
  releaseReservationWithJournal,
4047
+ releaseThroughRepository,
4048
+ reservationJournalKey,
2660
4049
  reserveCreditsForOperation,
4050
+ reserveThroughRepository,
2661
4051
  resetConfig,
4052
+ sameAmount,
4053
+ storedMonthlyLimit,
4054
+ sumAmounts,
4055
+ supportsCreditsV2,
4056
+ supportsIdempotentCredit,
2662
4057
  synchronousDeferred,
4058
+ terminalStatusOf,
2663
4059
  tierSchema,
4060
+ toCents,
2664
4061
  toClientUserCredits,
2665
4062
  toDate,
2666
4063
  toPortableTimestamp,