@nehorai/credits 1.7.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +384 -153
  2. package/dist/adapters/index.cjs +308 -46
  3. package/dist/adapters/index.cjs.map +1 -1
  4. package/dist/adapters/index.d.cts +3 -2
  5. package/dist/adapters/index.d.ts +3 -2
  6. package/dist/adapters/index.js +308 -46
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/auth/index.cjs.map +1 -1
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/core/index.cjs +700 -76
  11. package/dist/core/index.cjs.map +1 -1
  12. package/dist/core/index.d.cts +583 -5
  13. package/dist/core/index.d.ts +583 -5
  14. package/dist/core/index.js +652 -76
  15. package/dist/core/index.js.map +1 -1
  16. package/dist/index.cjs +1772 -371
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +44 -7
  19. package/dist/index.d.ts +44 -7
  20. package/dist/index.js +1716 -371
  21. package/dist/index.js.map +1 -1
  22. package/dist/repository/index.cjs +1403 -185
  23. package/dist/repository/index.cjs.map +1 -1
  24. package/dist/repository/index.d.cts +141 -15
  25. package/dist/repository/index.d.ts +141 -15
  26. package/dist/repository/index.js +1396 -185
  27. package/dist/repository/index.js.map +1 -1
  28. package/dist/sdk/index.cjs +23 -1
  29. package/dist/sdk/index.cjs.map +1 -1
  30. package/dist/sdk/index.d.cts +2 -2
  31. package/dist/sdk/index.d.ts +2 -2
  32. package/dist/sdk/index.js +23 -1
  33. package/dist/sdk/index.js.map +1 -1
  34. package/dist/service/index.cjs +484 -106
  35. package/dist/service/index.cjs.map +1 -1
  36. package/dist/service/index.d.cts +68 -10
  37. package/dist/service/index.d.ts +68 -10
  38. package/dist/service/index.js +484 -106
  39. package/dist/service/index.js.map +1 -1
  40. package/dist/types-BhCdep_t.d.ts +646 -0
  41. package/dist/{types-CjLj3GW5.d.cts → types-Dzc443vy.d.cts} +44 -2
  42. package/dist/{types-CjLj3GW5.d.ts → types-Dzc443vy.d.ts} +44 -2
  43. package/dist/types-wtksMa4n.d.cts +646 -0
  44. package/package.json +71 -70
  45. package/LICENSE +0 -21
  46. package/dist/types-DFe5BoJY.d.cts +0 -329
  47. package/dist/types-_bSybQnb.d.ts +0 -329
@@ -25,6 +25,170 @@ __export(service_exports, {
25
25
  });
26
26
  module.exports = __toCommonJS(service_exports);
27
27
 
28
+ // src/core/errors.ts
29
+ var CreditErrorCode = {
30
+ INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
31
+ RESERVATION_NOT_FOUND: "RESERVATION_NOT_FOUND",
32
+ RESERVATION_EXPIRED: "RESERVATION_EXPIRED",
33
+ RESERVATION_ALREADY_PROCESSED: "RESERVATION_ALREADY_PROCESSED",
34
+ USER_NOT_FOUND: "USER_NOT_FOUND",
35
+ INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
36
+ CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
37
+ DATABASE_ERROR: "DATABASE_ERROR",
38
+ /** Caller reused an idempotency key with a different immutable payload. */
39
+ IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT",
40
+ /**
41
+ * Retryable infrastructure failure — serialization/deadlock, lock timeout,
42
+ * connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
43
+ * a deterministic failure that will fail again identically on retry.
44
+ */
45
+ TRANSIENT_ERROR: "TRANSIENT_ERROR",
46
+ /** The configured repository adapter does not implement the requested V2 method. */
47
+ UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
48
+ /** Amount was not a finite positive number. */
49
+ INVALID_AMOUNT: "INVALID_AMOUNT",
50
+ /** Idempotency key was supplied but empty or whitespace-only. */
51
+ INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY",
52
+ /**
53
+ * A persisted reservation holds a status this library does not define, so no
54
+ * transition can reason about it. Quarantined rather than cast; nothing moved.
55
+ */
56
+ CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS",
57
+ /**
58
+ * A persisted reservation does not record that its hold was placed
59
+ * atomically, so the credits it claims are not known to be reserved. Moving
60
+ * it would spend another hold's coverage; nothing moved.
61
+ */
62
+ UNBACKED_RESERVATION: "UNBACKED_RESERVATION"
63
+ };
64
+ var CreditError = class _CreditError extends Error {
65
+ code;
66
+ details;
67
+ constructor(message, code, details) {
68
+ super(message);
69
+ this.name = "CreditError";
70
+ this.code = code;
71
+ this.details = details;
72
+ if (Error.captureStackTrace) {
73
+ Error.captureStackTrace(this, _CreditError);
74
+ }
75
+ }
76
+ /**
77
+ * Convert to JSON-serializable object
78
+ */
79
+ toJSON() {
80
+ return {
81
+ name: this.name,
82
+ code: this.code,
83
+ message: this.message,
84
+ ...this.details && { details: this.details }
85
+ };
86
+ }
87
+ };
88
+ function isCreditError(error) {
89
+ return error instanceof CreditError;
90
+ }
91
+ function isInsufficientCreditsError(error) {
92
+ if (isCreditError(error)) {
93
+ return error.code === CreditErrorCode.INSUFFICIENT_CREDITS;
94
+ }
95
+ if (error instanceof Error) {
96
+ return error.message.toLowerCase().includes("insufficient credits");
97
+ }
98
+ return false;
99
+ }
100
+ function createInsufficientCreditsError(required, available) {
101
+ return new CreditError(
102
+ `Insufficient credits: available ${available}, required ${required}`,
103
+ CreditErrorCode.INSUFFICIENT_CREDITS,
104
+ { required, available, shortfall: sumAmounts(required, -available) }
105
+ );
106
+ }
107
+ function createReservationNotFoundError(reservationId) {
108
+ return new CreditError(
109
+ `Reservation ${reservationId} not found`,
110
+ CreditErrorCode.RESERVATION_NOT_FOUND,
111
+ { reservationId }
112
+ );
113
+ }
114
+ function createReservationAlreadyProcessedError(reservationId, status) {
115
+ return new CreditError(
116
+ `Reservation ${reservationId} has already been ${status}`,
117
+ CreditErrorCode.RESERVATION_ALREADY_PROCESSED,
118
+ { reservationId, status }
119
+ );
120
+ }
121
+ function createIdempotencyConflictError(idempotencyKey, details) {
122
+ return new CreditError(
123
+ `Idempotency key ${idempotencyKey} was reused with a different payload`,
124
+ CreditErrorCode.IDEMPOTENCY_CONFLICT,
125
+ { idempotencyKey, ...details }
126
+ );
127
+ }
128
+ function createUnsupportedOperationError(operation, hint) {
129
+ return new CreditError(
130
+ hint ? `Repository does not support ${operation}. ${hint}` : `Repository does not support ${operation}`,
131
+ CreditErrorCode.UNSUPPORTED_OPERATION,
132
+ { operation, ...hint ? { hint } : {} }
133
+ );
134
+ }
135
+
136
+ // src/core/amount.ts
137
+ var CREDIT_AMOUNT_SCALE = 2;
138
+ var CREDIT_AMOUNT_MAX = 999999999999e-2;
139
+ var MAX_CENTS = 999999999999;
140
+ function toCents(value) {
141
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
142
+ const cents = Math.round(value * 100);
143
+ if (!Number.isSafeInteger(cents)) return null;
144
+ return value === cents / 100 ? cents : null;
145
+ }
146
+ function sumAmounts(...values) {
147
+ let cents = 0;
148
+ for (const value of values) {
149
+ const valueCents = toCents(value);
150
+ if (valueCents === null) return floatSum(values);
151
+ cents += valueCents;
152
+ }
153
+ return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);
154
+ }
155
+ function floatSum(values) {
156
+ let total = 0;
157
+ for (const value of values) total += value;
158
+ return total;
159
+ }
160
+ function isValidCreditAmount(value) {
161
+ if (typeof value !== "number") return false;
162
+ const cents = toCents(value);
163
+ return cents !== null && cents > 0 && cents <= MAX_CENTS;
164
+ }
165
+ function assertValidCreditAmount(value, context) {
166
+ if (isValidCreditAmount(value)) return;
167
+ throw new CreditError(
168
+ creditAmountReason(value),
169
+ CreditErrorCode.INVALID_AMOUNT,
170
+ { amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
171
+ );
172
+ }
173
+ function creditAmountReason(value) {
174
+ if (typeof value !== "number") {
175
+ return `Credit amount must be a number (got ${typeof value})`;
176
+ }
177
+ if (!Number.isFinite(value)) {
178
+ return `Credit amount must be finite (got ${String(value)})`;
179
+ }
180
+ if (value <= 0) {
181
+ return `Credit amount must be greater than zero (got ${value})`;
182
+ }
183
+ if (value > CREDIT_AMOUNT_MAX) {
184
+ return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;
185
+ }
186
+ return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;
187
+ }
188
+ function storedMonthlyLimit(limit) {
189
+ return Number.isFinite(limit) ? limit : CREDIT_AMOUNT_MAX;
190
+ }
191
+
28
192
  // src/core/types.ts
29
193
  function toDate(value) {
30
194
  if (value instanceof Date) {
@@ -39,32 +203,6 @@ function toDate(value) {
39
203
  return /* @__PURE__ */ new Date();
40
204
  }
41
205
 
42
- // src/repository/types.ts
43
- function toClientUserCredits(credits) {
44
- return {
45
- userId: credits.userId,
46
- balance: credits.balance,
47
- bonusCredits: credits.bonusCredits ?? 0,
48
- reserved: credits.reserved,
49
- tier: credits.tier,
50
- monthlyLimit: credits.monthlyLimit,
51
- monthlyUsed: credits.monthlyUsed,
52
- monthlyResetAt: toISOString(credits.monthlyResetAt),
53
- subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
54
- createdAt: toISOString(credits.createdAt),
55
- updatedAt: toISOString(credits.updatedAt)
56
- };
57
- }
58
- function toISOString(value) {
59
- if (!value) return (/* @__PURE__ */ new Date()).toISOString();
60
- if (typeof value === "string") return value;
61
- if (value instanceof Date) return value.toISOString();
62
- if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
63
- return value.toDate().toISOString();
64
- }
65
- return (/* @__PURE__ */ new Date()).toISOString();
66
- }
67
-
68
206
  // src/config/index.ts
69
207
  var import_zod = require("zod");
70
208
 
@@ -88,11 +226,13 @@ var creditSystemConfigSchema = import_zod.z.object({
88
226
  /**
89
227
  * Operation costs in credits
90
228
  * Keys are operation type names (any non-empty string)
91
- * Values are positive numbers representing credit cost
229
+ * Values are non-negative numbers representing credit cost. 0 marks a free
230
+ * operation: `withCredits` runs it without touching the ledger. Amounts that
231
+ * move credits stay strictly positive, so `reserveCredits` refuses a 0.
92
232
  */
93
233
  operationCosts: import_zod.z.record(
94
234
  import_zod.z.string().min(1),
95
- import_zod.z.number().positive()
235
+ import_zod.z.number().nonnegative()
96
236
  ),
97
237
  /**
98
238
  * Display labels for operation types (English canonical labels).
@@ -360,6 +500,178 @@ function getOperationLabel(operationType) {
360
500
  }
361
501
  initializeConfig();
362
502
 
503
+ // src/core/payment-ref.ts
504
+ function normalizePaymentRef(ref) {
505
+ if (typeof ref !== "string") return void 0;
506
+ const trimmed = ref.trim();
507
+ return trimmed.length > 0 ? trimmed : void 0;
508
+ }
509
+
510
+ // src/repository/types.ts
511
+ function supportsCreditsV2(repository) {
512
+ return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
513
+ }
514
+ function supportsIdempotentCredit(repository) {
515
+ return typeof repository.addCreditsV2 === "function";
516
+ }
517
+ function toClientUserCredits(credits) {
518
+ return {
519
+ userId: credits.userId,
520
+ balance: credits.balance,
521
+ bonusCredits: credits.bonusCredits ?? 0,
522
+ reserved: credits.reserved,
523
+ tier: credits.tier,
524
+ monthlyLimit: credits.monthlyLimit,
525
+ monthlyUsed: credits.monthlyUsed,
526
+ monthlyResetAt: toISOString(credits.monthlyResetAt),
527
+ subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
528
+ createdAt: toISOString(credits.createdAt),
529
+ updatedAt: toISOString(credits.updatedAt)
530
+ };
531
+ }
532
+ function toISOString(value) {
533
+ if (!value) return (/* @__PURE__ */ new Date()).toISOString();
534
+ if (typeof value === "string") return value;
535
+ if (value instanceof Date) return value.toISOString();
536
+ if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
537
+ return value.toDate().toISOString();
538
+ }
539
+ return (/* @__PURE__ */ new Date()).toISOString();
540
+ }
541
+
542
+ // src/repository/flow.ts
543
+ async function reserveThroughRepository(repository, input) {
544
+ const v2 = supportsCreditsV2(repository);
545
+ if (!v2 && input.idempotencyKey !== void 0) {
546
+ throw createUnsupportedOperationError(
547
+ "reserveCredits with an idempotencyKey",
548
+ "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."
549
+ );
550
+ }
551
+ assertValidCreditAmount(input.amount, { userId: input.userId });
552
+ if (v2) {
553
+ return repository.reserveCreditsV2(input);
554
+ }
555
+ try {
556
+ const reservation = await repository.reserveCreditsAtomic(
557
+ input.userId,
558
+ input.amount,
559
+ input.operationType,
560
+ input.expiresAt
561
+ );
562
+ return { outcome: "created", reservation };
563
+ } catch (error) {
564
+ if (!isInsufficientCreditsError(error)) throw error;
565
+ const credits = await repository.getUserCredits(input.userId);
566
+ const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
567
+ return {
568
+ outcome: "insufficient",
569
+ available,
570
+ required: input.amount,
571
+ shortfall: Math.max(sumAmounts(input.amount, -available), 0)
572
+ };
573
+ }
574
+ }
575
+ async function commitThroughRepository(repository, userId, reservationId, options) {
576
+ if (supportsCreditsV2(repository)) {
577
+ return repository.commitReservationV2(userId, reservationId, options);
578
+ }
579
+ const reservation = await repository.getReservation(userId, reservationId);
580
+ if (!reservation) return { outcome: "not_found", reservationId };
581
+ if (reservation.status !== "reserved") {
582
+ return {
583
+ outcome: "already_terminal",
584
+ reservation,
585
+ terminalStatus: reservation.status
586
+ };
587
+ }
588
+ await repository.commitReservationAtomic(userId, reservationId);
589
+ const credits = await repository.getUserCredits(userId);
590
+ const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
591
+ const entry = await repository.createJournalEntry({
592
+ userId,
593
+ entryType: "debit",
594
+ amount: reservation.amount,
595
+ balanceAfter,
596
+ source: "operation_commit",
597
+ referenceId: reservationId,
598
+ referenceType: "reservation",
599
+ description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
600
+ // Deterministic fields last: they carry the identity of the event, and a
601
+ // caller who happens to send an `operationType` or an `amount` must not
602
+ // relabel what actually moved.
603
+ metadata: {
604
+ ...options?.metadata,
605
+ operationType: reservation.operationType,
606
+ amount: reservation.amount
607
+ }
608
+ });
609
+ return {
610
+ outcome: "committed",
611
+ reservation: { ...reservation, status: "committed" },
612
+ amount: reservation.amount,
613
+ balanceAfter,
614
+ journalEntryId: entry.id
615
+ };
616
+ }
617
+ async function releaseThroughRepository(repository, userId, reservationId, options) {
618
+ if (supportsCreditsV2(repository)) {
619
+ return repository.releaseReservationV2(userId, reservationId, options);
620
+ }
621
+ const reservation = await repository.getReservation(userId, reservationId);
622
+ if (!reservation) {
623
+ await repository.releaseReservationAtomic(userId, reservationId);
624
+ return { outcome: "not_found", reservationId };
625
+ }
626
+ if (reservation.status !== "reserved") {
627
+ await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
628
+ return {
629
+ outcome: "already_terminal",
630
+ reservation,
631
+ terminalStatus: reservation.status
632
+ };
633
+ }
634
+ await repository.releaseReservationAtomic(userId, reservationId);
635
+ const credits = await repository.getUserCredits(userId);
636
+ const entry = await repository.createJournalEntry({
637
+ userId,
638
+ entryType: "credit",
639
+ // No credits changed hands — the hold just moved back into "available".
640
+ amount: 0,
641
+ balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
642
+ source: "operation_release",
643
+ referenceId: reservationId,
644
+ referenceType: "reservation",
645
+ description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
646
+ metadata: {
647
+ ...options?.metadata,
648
+ operationType: reservation.operationType,
649
+ amount: reservation.amount
650
+ }
651
+ });
652
+ return {
653
+ outcome: "released",
654
+ reservation: { ...reservation, status: "released" },
655
+ amount: reservation.amount,
656
+ journalEntryId: entry.id
657
+ };
658
+ }
659
+ async function addCreditsThroughRepository(repository, input) {
660
+ assertValidCreditAmount(input.amount, { userId: input.userId, operation: "addCredits" });
661
+ const paymentRef = normalizePaymentRef(input.paymentRef);
662
+ if (supportsIdempotentCredit(repository)) {
663
+ return repository.addCreditsV2({ ...input, paymentRef });
664
+ }
665
+ await repository.addCreditsAtomic(
666
+ input.userId,
667
+ input.amount,
668
+ input.description,
669
+ paymentRef,
670
+ input.options
671
+ );
672
+ return paymentRef ? { outcome: "created", paymentRef } : { outcome: "created" };
673
+ }
674
+
363
675
  // src/service/credits-service.ts
364
676
  function isPastMonthlyReset(resetAt) {
365
677
  if (!resetAt) return false;
@@ -404,28 +716,38 @@ var CreditsService = class {
404
716
  userId,
405
717
  getConfig().subscriptionGracePeriodDays
406
718
  );
407
- if (expiryResult.wasDowngraded) {
719
+ if (expiryResult.wasDowngraded && !expiryResult.journaled) {
408
720
  await this.repository.createJournalEntry({
409
721
  userId,
410
722
  entryType: "debit",
411
723
  amount: 0,
412
724
  // No credits deducted, just tier change
413
- balanceAfter: expiryResult.credits.balance,
725
+ // The ledger balance, matching every other journal writer: the V2
726
+ // transitions and add/deduct all record `balance + bonusCredits`, so
727
+ // recording `balance` alone made the audit trail disagree with itself
728
+ // for any user holding bonus credits.
729
+ balanceAfter: sumAmounts(
730
+ expiryResult.credits.balance,
731
+ expiryResult.credits.bonusCredits
732
+ ),
414
733
  source: "subscription_downgrade",
415
734
  referenceId: `downgrade-${Date.now()}`,
416
735
  referenceType: "subscription",
417
- description: `Subscription expired. Downgraded from ${data.tier} to free tier.`,
736
+ // The tier actually landed on, not a hard-coded "free": the downgrade
737
+ // target is configurable, so naming `free` here could describe a
738
+ // transition that never happened.
739
+ description: `Subscription expired. Downgraded from ${data.tier} to ${expiryResult.credits.tier} tier.`,
418
740
  metadata: {
419
741
  previousTier: data.tier,
420
742
  previousBalance: data.balance,
421
743
  newBalance: expiryResult.credits.balance
422
744
  }
423
745
  });
424
- if (this.subscriptionExpiredCallback) {
425
- this.subscriptionExpiredCallback(userId, true).catch((error) => {
426
- console.error("[Credits] Failed to send subscription expired notification:", error);
427
- });
428
- }
746
+ }
747
+ if (expiryResult.wasDowngraded && this.subscriptionExpiredCallback) {
748
+ this.subscriptionExpiredCallback(userId, true).catch((error) => {
749
+ console.error("[Credits] Failed to send subscription expired notification:", error);
750
+ });
429
751
  }
430
752
  data = expiryResult.credits;
431
753
  }
@@ -436,14 +758,17 @@ var CreditsService = class {
436
758
  data.tier,
437
759
  expectedResetAt
438
760
  );
439
- if (resetResult.wasReset) {
440
- const balanceChange = resetResult.credits.balance - data.balance;
761
+ if (resetResult.wasReset && !resetResult.journaled) {
762
+ const balanceChange = sumAmounts(resetResult.credits.balance, -data.balance);
441
763
  if (balanceChange !== 0) {
442
764
  await this.repository.createJournalEntry({
443
765
  userId,
444
766
  entryType: balanceChange > 0 ? "credit" : "debit",
445
767
  amount: Math.abs(balanceChange),
446
- balanceAfter: resetResult.credits.balance,
768
+ balanceAfter: sumAmounts(
769
+ resetResult.credits.balance,
770
+ resetResult.credits.bonusCredits
771
+ ),
447
772
  source: "monthly_reset",
448
773
  referenceId: `reset-${Date.now()}`,
449
774
  referenceType: "reset",
@@ -494,14 +819,14 @@ var CreditsService = class {
494
819
  */
495
820
  async checkCredits(userId, requiredCredits) {
496
821
  const credits = await this.getOrCreateUserCredits(userId);
497
- const totalBalance = credits.balance + credits.bonusCredits;
498
- const available = totalBalance - credits.reserved;
822
+ const totalBalance = sumAmounts(credits.balance, credits.bonusCredits);
823
+ const available = sumAmounts(totalBalance, -credits.reserved);
499
824
  const hasCredits = available >= requiredCredits;
500
825
  return {
501
826
  hasCredits,
502
827
  balance: totalBalance,
503
828
  required: requiredCredits,
504
- shortfall: hasCredits ? 0 : requiredCredits - available
829
+ shortfall: hasCredits ? 0 : sumAmounts(requiredCredits, -available)
505
830
  };
506
831
  }
507
832
  /**
@@ -515,74 +840,110 @@ var CreditsService = class {
515
840
  * @throws Error if insufficient credits
516
841
  */
517
842
  async reserveCredits(userId, amount, operationType, options) {
843
+ const outcome = await this.reserveCreditsDetailed(userId, amount, operationType, options);
844
+ if (outcome.outcome === "created" || outcome.outcome === "replayed") {
845
+ return outcome.reservation;
846
+ }
847
+ if (outcome.outcome === "insufficient") {
848
+ throw createInsufficientCreditsError(outcome.required, outcome.available);
849
+ }
850
+ throw createIdempotencyConflictError(outcome.idempotencyKey, {
851
+ userId,
852
+ requested: { amount, operationType },
853
+ existing: {
854
+ amount: outcome.existing.amount,
855
+ operationType: outcome.existing.operationType
856
+ }
857
+ });
858
+ }
859
+ /**
860
+ * Reserve credits and get the typed outcome instead of an exception.
861
+ *
862
+ * Prefer this over {@link reserveCredits} in async callers: it distinguishes
863
+ * a fresh hold from an idempotent replay, and reports insufficient funds as
864
+ * a value rather than as control flow.
865
+ */
866
+ async reserveCreditsDetailed(userId, amount, operationType, options) {
518
867
  const ttlMs = options?.ttlMs ?? getConfig().reservationExpiryMs;
519
868
  const expiresAt = new Date(Date.now() + ttlMs);
520
- return this.repository.reserveCreditsAtomic(userId, amount, operationType, expiresAt);
869
+ return reserveThroughRepository(this.repository, {
870
+ userId,
871
+ amount,
872
+ operationType,
873
+ expiresAt,
874
+ idempotencyKey: options?.idempotencyKey
875
+ });
521
876
  }
522
877
  /**
523
- * Commit a reservation (phase 2 of two-phase commit - success)
524
- * Deducts credits and marks reservation as committed
525
- * Also triggers low balance notifications if balance drops below threshold
878
+ * Commit a reservation (phase 2 of two-phase commit - success).
879
+ *
880
+ * Idempotent: re-delivering a commit for an already-committed reservation is
881
+ * a no-op. Committing one that was released or expired throws, because the
882
+ * credits are no longer held and pretending otherwise would hide a real bug.
526
883
  */
527
- async commitCredits(userId, reservationId) {
528
- const reservation = await this.repository.getReservation(userId, reservationId);
529
- if (!reservation) {
530
- throw new Error(`Reservation ${reservationId} not found`);
884
+ async commitCredits(userId, reservationId, options) {
885
+ const outcome = await this.commitCreditsDetailed(userId, reservationId, options);
886
+ if (outcome.outcome === "not_found") {
887
+ throw createReservationNotFoundError(reservationId);
531
888
  }
532
- if (reservation.status === "committed") {
533
- return;
889
+ if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
890
+ throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
534
891
  }
535
- await this.repository.commitReservationAtomic(userId, reservationId);
536
- const credits = await this.repository.getUserCredits(userId);
537
- if (credits) {
538
- await this.repository.createJournalEntry({
539
- userId,
540
- entryType: "debit",
541
- amount: reservation.amount,
542
- balanceAfter: credits.balance,
543
- source: "operation_commit",
544
- referenceId: reservationId,
545
- referenceType: "reservation",
546
- description: `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
547
- metadata: {
548
- operationType: reservation.operationType
549
- }
892
+ }
893
+ /**
894
+ * Commit a reservation and get the typed outcome.
895
+ *
896
+ * Exactly one concurrent caller sees `committed`; that call moved the balance
897
+ * and wrote the single journal entry. On a V2 repository the journal is
898
+ * written inside the same transaction, so this method adds none of its own —
899
+ * the duplicate entry the previous implementation wrote is gone.
900
+ *
901
+ * The low-balance notification fires only for the winner, and only after the
902
+ * transaction has committed, so no callback runs inside a database
903
+ * transaction.
904
+ */
905
+ async commitCreditsDetailed(userId, reservationId, options) {
906
+ const outcome = await commitThroughRepository(
907
+ this.repository,
908
+ userId,
909
+ reservationId,
910
+ options
911
+ );
912
+ if (outcome.outcome === "committed" && this.lowBalanceCallback) {
913
+ this.lowBalanceCallback(userId, outcome.balanceAfter).catch((error) => {
914
+ console.error("[Credits] Failed to send low balance notification:", error);
550
915
  });
551
- if (this.lowBalanceCallback) {
552
- this.lowBalanceCallback(userId, credits.balance).catch((error) => {
553
- console.error("[Credits] Failed to send low balance notification:", error);
554
- });
555
- }
556
916
  }
917
+ return outcome;
557
918
  }
558
919
  /**
559
- * Release a reservation (phase 2 of two-phase commit - failure)
560
- * Returns reserved credits and marks reservation as released
920
+ * Release a reservation (phase 2 of two-phase commit - failure).
921
+ *
922
+ * Throws `RESERVATION_NOT_FOUND` for an unknown reservation, matching the
923
+ * pre-V2 behaviour — a release naming a reservation that does not exist is a
924
+ * caller bug, and swallowing it hides the bug while the credits stay held.
925
+ *
926
+ * Releasing one that is already terminal — `released`, `expired`, or
927
+ * `committed` — is a no-op, which is the behaviour this method has always
928
+ * had. A release that loses a race to a concurrent commit is the common
929
+ * case in a retry-heavy caller, and callers written against the pre-V2
930
+ * contract handle it by not handling it.
931
+ *
932
+ * That does mean this method cannot tell you that the credits were spent
933
+ * rather than returned. Use {@link releaseCreditsDetailed}, which reports
934
+ * `already_terminal` with `terminalStatus: 'committed'`, when you need to
935
+ * branch on it.
561
936
  */
562
- async releaseCredits(userId, reservationId) {
563
- const reservation = await this.repository.getReservation(userId, reservationId);
564
- await this.repository.releaseReservationAtomic(userId, reservationId);
565
- if (reservation?.status === "reserved") {
566
- const credits = await this.repository.getUserCredits(userId);
567
- if (credits) {
568
- await this.repository.createJournalEntry({
569
- userId,
570
- entryType: "credit",
571
- amount: 0,
572
- // No actual credits returned (they were reserved, not spent)
573
- balanceAfter: credits.balance,
574
- source: "operation_release",
575
- referenceId: reservationId,
576
- referenceType: "reservation",
577
- description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
578
- metadata: {
579
- operationType: reservation.operationType,
580
- amount: reservation.amount
581
- }
582
- });
583
- }
937
+ async releaseCredits(userId, reservationId, options) {
938
+ const outcome = await this.releaseCreditsDetailed(userId, reservationId, options);
939
+ if (outcome.outcome === "not_found") {
940
+ throw createReservationNotFoundError(reservationId);
584
941
  }
585
942
  }
943
+ /** Release a reservation and get the typed outcome. */
944
+ async releaseCreditsDetailed(userId, reservationId, options) {
945
+ return releaseThroughRepository(this.repository, userId, reservationId, options);
946
+ }
586
947
  /**
587
948
  * Log usage for audit trail
588
949
  * @param log - Usage log data
@@ -598,9 +959,20 @@ var CreditsService = class {
598
959
  * @param paymentRef - Optional payment reference
599
960
  * @param options - Optional journal source / reference type / extra metadata
600
961
  * for the credit journal entry (e.g. revenue-attribution fields).
962
+ * @returns Whether this delivery credited the account (`created`), repeated an
963
+ * earlier one (`replayed`), or reused the reference for a different credit
964
+ * event (`conflict`, which credited nothing). On a repository without
965
+ * `addCreditsV2` the result is always `created` and carries no
966
+ * deduplication guarantee — see {@link addCreditsThroughRepository}.
601
967
  */
602
968
  async addCredits(userId, amount, description, paymentRef, options) {
603
- return this.repository.addCreditsAtomic(userId, amount, description, paymentRef, options);
969
+ return addCreditsThroughRepository(this.repository, {
970
+ userId,
971
+ amount,
972
+ description,
973
+ paymentRef,
974
+ options
975
+ });
604
976
  }
605
977
  /**
606
978
  * One-shot atomic "deduct-if-sufficient" credit charge.
@@ -624,15 +996,14 @@ var CreditsService = class {
624
996
  * @throws Error if amount is not a positive number
625
997
  */
626
998
  async deductCredits(userId, amount, options) {
627
- if (!(amount > 0)) {
628
- throw new Error(`deductCredits amount must be positive (got ${amount})`);
629
- }
999
+ assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
630
1000
  let deduction;
631
1001
  try {
632
1002
  deduction = await this.repository.deductCreditsAtomic(userId, amount);
633
1003
  } catch (error) {
1004
+ if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
634
1005
  const credits = await this.repository.getUserCredits(userId);
635
- const available = credits ? credits.balance + credits.bonusCredits - credits.reserved : 0;
1006
+ const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
636
1007
  if (available >= amount) {
637
1008
  throw error;
638
1009
  }
@@ -641,7 +1012,7 @@ var CreditsService = class {
641
1012
  reason: "insufficient",
642
1013
  available,
643
1014
  required: amount,
644
- shortfall: amount - available
1015
+ shortfall: sumAmounts(amount, -available)
645
1016
  };
646
1017
  }
647
1018
  const operationType = options?.operationType;
@@ -655,8 +1026,8 @@ var CreditsService = class {
655
1026
  referenceType: "adjustment",
656
1027
  description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
657
1028
  metadata: {
658
- ...operationType && { operationType },
659
- ...options?.metadata
1029
+ ...options?.metadata,
1030
+ ...operationType && { operationType }
660
1031
  }
661
1032
  });
662
1033
  if (operationType) {
@@ -691,8 +1062,15 @@ var CreditsService = class {
691
1062
  const free = isFreeTier(tier);
692
1063
  await this.repository.updateUserTier(userId, {
693
1064
  tier,
694
- // Stored monthlyLimit uses the 0-means-unlimited convention.
695
- monthlyLimit: unlimited ? 0 : monthlyLimit,
1065
+ // Through the shared contract, like every path that resolves the tier's
1066
+ // limit from configuration. (The repository's `updateUserCredits` and
1067
+ // `updateUserTier` still store a caller-supplied `monthlyLimit` verbatim;
1068
+ // they are given a value, not a tier, so there is no sentinel to map.)
1069
+ // This used to store 0 for an unlimited tier under a "0 means unlimited"
1070
+ // convention that nothing actually implemented — no read path anywhere in
1071
+ // the library branches on it — so upgrading a user to unlimited gave them
1072
+ // an allowance of zero.
1073
+ monthlyLimit: storedMonthlyLimit(monthlyLimit),
696
1074
  // Reset balance to new tier limit if upgrading
697
1075
  balance: !free ? unlimited ? getUnlimitedSentinelBalance() : monthlyLimit : void 0,
698
1076
  monthlyUsed: !free ? 0 : void 0,