@nehorai/credits 1.7.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +384 -153
  2. package/dist/adapters/index.cjs +308 -46
  3. package/dist/adapters/index.cjs.map +1 -1
  4. package/dist/adapters/index.d.cts +3 -2
  5. package/dist/adapters/index.d.ts +3 -2
  6. package/dist/adapters/index.js +308 -46
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/auth/index.cjs.map +1 -1
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/core/index.cjs +700 -76
  11. package/dist/core/index.cjs.map +1 -1
  12. package/dist/core/index.d.cts +583 -5
  13. package/dist/core/index.d.ts +583 -5
  14. package/dist/core/index.js +652 -76
  15. package/dist/core/index.js.map +1 -1
  16. package/dist/index.cjs +1772 -371
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +44 -7
  19. package/dist/index.d.ts +44 -7
  20. package/dist/index.js +1716 -371
  21. package/dist/index.js.map +1 -1
  22. package/dist/repository/index.cjs +1403 -185
  23. package/dist/repository/index.cjs.map +1 -1
  24. package/dist/repository/index.d.cts +141 -15
  25. package/dist/repository/index.d.ts +141 -15
  26. package/dist/repository/index.js +1396 -185
  27. package/dist/repository/index.js.map +1 -1
  28. package/dist/sdk/index.cjs +23 -1
  29. package/dist/sdk/index.cjs.map +1 -1
  30. package/dist/sdk/index.d.cts +2 -2
  31. package/dist/sdk/index.d.ts +2 -2
  32. package/dist/sdk/index.js +23 -1
  33. package/dist/sdk/index.js.map +1 -1
  34. package/dist/service/index.cjs +484 -106
  35. package/dist/service/index.cjs.map +1 -1
  36. package/dist/service/index.d.cts +68 -10
  37. package/dist/service/index.d.ts +68 -10
  38. package/dist/service/index.js +484 -106
  39. package/dist/service/index.js.map +1 -1
  40. package/dist/types-BhCdep_t.d.ts +646 -0
  41. package/dist/{types-CjLj3GW5.d.cts → types-Dzc443vy.d.cts} +44 -2
  42. package/dist/{types-CjLj3GW5.d.ts → types-Dzc443vy.d.ts} +44 -2
  43. package/dist/types-wtksMa4n.d.cts +646 -0
  44. package/package.json +71 -70
  45. package/LICENSE +0 -21
  46. package/dist/types-DFe5BoJY.d.cts +0 -329
  47. package/dist/types-_bSybQnb.d.ts +0 -329
@@ -1,3 +1,167 @@
1
+ // src/core/errors.ts
2
+ var CreditErrorCode = {
3
+ INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
4
+ RESERVATION_NOT_FOUND: "RESERVATION_NOT_FOUND",
5
+ RESERVATION_EXPIRED: "RESERVATION_EXPIRED",
6
+ RESERVATION_ALREADY_PROCESSED: "RESERVATION_ALREADY_PROCESSED",
7
+ USER_NOT_FOUND: "USER_NOT_FOUND",
8
+ INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
9
+ CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
10
+ DATABASE_ERROR: "DATABASE_ERROR",
11
+ /** Caller reused an idempotency key with a different immutable payload. */
12
+ IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT",
13
+ /**
14
+ * Retryable infrastructure failure — serialization/deadlock, lock timeout,
15
+ * connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
16
+ * a deterministic failure that will fail again identically on retry.
17
+ */
18
+ TRANSIENT_ERROR: "TRANSIENT_ERROR",
19
+ /** The configured repository adapter does not implement the requested V2 method. */
20
+ UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
21
+ /** Amount was not a finite positive number. */
22
+ INVALID_AMOUNT: "INVALID_AMOUNT",
23
+ /** Idempotency key was supplied but empty or whitespace-only. */
24
+ INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY",
25
+ /**
26
+ * A persisted reservation holds a status this library does not define, so no
27
+ * transition can reason about it. Quarantined rather than cast; nothing moved.
28
+ */
29
+ CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS",
30
+ /**
31
+ * A persisted reservation does not record that its hold was placed
32
+ * atomically, so the credits it claims are not known to be reserved. Moving
33
+ * it would spend another hold's coverage; nothing moved.
34
+ */
35
+ UNBACKED_RESERVATION: "UNBACKED_RESERVATION"
36
+ };
37
+ var CreditError = class _CreditError extends Error {
38
+ code;
39
+ details;
40
+ constructor(message, code, details) {
41
+ super(message);
42
+ this.name = "CreditError";
43
+ this.code = code;
44
+ this.details = details;
45
+ if (Error.captureStackTrace) {
46
+ Error.captureStackTrace(this, _CreditError);
47
+ }
48
+ }
49
+ /**
50
+ * Convert to JSON-serializable object
51
+ */
52
+ toJSON() {
53
+ return {
54
+ name: this.name,
55
+ code: this.code,
56
+ message: this.message,
57
+ ...this.details && { details: this.details }
58
+ };
59
+ }
60
+ };
61
+ function isCreditError(error) {
62
+ return error instanceof CreditError;
63
+ }
64
+ function isInsufficientCreditsError(error) {
65
+ if (isCreditError(error)) {
66
+ return error.code === CreditErrorCode.INSUFFICIENT_CREDITS;
67
+ }
68
+ if (error instanceof Error) {
69
+ return error.message.toLowerCase().includes("insufficient credits");
70
+ }
71
+ return false;
72
+ }
73
+ function createInsufficientCreditsError(required, available) {
74
+ return new CreditError(
75
+ `Insufficient credits: available ${available}, required ${required}`,
76
+ CreditErrorCode.INSUFFICIENT_CREDITS,
77
+ { required, available, shortfall: sumAmounts(required, -available) }
78
+ );
79
+ }
80
+ function createReservationNotFoundError(reservationId) {
81
+ return new CreditError(
82
+ `Reservation ${reservationId} not found`,
83
+ CreditErrorCode.RESERVATION_NOT_FOUND,
84
+ { reservationId }
85
+ );
86
+ }
87
+ function createReservationAlreadyProcessedError(reservationId, status) {
88
+ return new CreditError(
89
+ `Reservation ${reservationId} has already been ${status}`,
90
+ CreditErrorCode.RESERVATION_ALREADY_PROCESSED,
91
+ { reservationId, status }
92
+ );
93
+ }
94
+ function createIdempotencyConflictError(idempotencyKey, details) {
95
+ return new CreditError(
96
+ `Idempotency key ${idempotencyKey} was reused with a different payload`,
97
+ CreditErrorCode.IDEMPOTENCY_CONFLICT,
98
+ { idempotencyKey, ...details }
99
+ );
100
+ }
101
+ function createUnsupportedOperationError(operation, hint) {
102
+ return new CreditError(
103
+ hint ? `Repository does not support ${operation}. ${hint}` : `Repository does not support ${operation}`,
104
+ CreditErrorCode.UNSUPPORTED_OPERATION,
105
+ { operation, ...hint ? { hint } : {} }
106
+ );
107
+ }
108
+
109
+ // src/core/amount.ts
110
+ var CREDIT_AMOUNT_SCALE = 2;
111
+ var CREDIT_AMOUNT_MAX = 999999999999e-2;
112
+ var MAX_CENTS = 999999999999;
113
+ function toCents(value) {
114
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
115
+ const cents = Math.round(value * 100);
116
+ if (!Number.isSafeInteger(cents)) return null;
117
+ return value === cents / 100 ? cents : null;
118
+ }
119
+ function sumAmounts(...values) {
120
+ let cents = 0;
121
+ for (const value of values) {
122
+ const valueCents = toCents(value);
123
+ if (valueCents === null) return floatSum(values);
124
+ cents += valueCents;
125
+ }
126
+ return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);
127
+ }
128
+ function floatSum(values) {
129
+ let total = 0;
130
+ for (const value of values) total += value;
131
+ return total;
132
+ }
133
+ function isValidCreditAmount(value) {
134
+ if (typeof value !== "number") return false;
135
+ const cents = toCents(value);
136
+ return cents !== null && cents > 0 && cents <= MAX_CENTS;
137
+ }
138
+ function assertValidCreditAmount(value, context) {
139
+ if (isValidCreditAmount(value)) return;
140
+ throw new CreditError(
141
+ creditAmountReason(value),
142
+ CreditErrorCode.INVALID_AMOUNT,
143
+ { amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
144
+ );
145
+ }
146
+ function creditAmountReason(value) {
147
+ if (typeof value !== "number") {
148
+ return `Credit amount must be a number (got ${typeof value})`;
149
+ }
150
+ if (!Number.isFinite(value)) {
151
+ return `Credit amount must be finite (got ${String(value)})`;
152
+ }
153
+ if (value <= 0) {
154
+ return `Credit amount must be greater than zero (got ${value})`;
155
+ }
156
+ if (value > CREDIT_AMOUNT_MAX) {
157
+ return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;
158
+ }
159
+ return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;
160
+ }
161
+ function storedMonthlyLimit(limit) {
162
+ return Number.isFinite(limit) ? limit : CREDIT_AMOUNT_MAX;
163
+ }
164
+
1
165
  // src/core/types.ts
2
166
  function toDate(value) {
3
167
  if (value instanceof Date) {
@@ -12,32 +176,6 @@ function toDate(value) {
12
176
  return /* @__PURE__ */ new Date();
13
177
  }
14
178
 
15
- // src/repository/types.ts
16
- function toClientUserCredits(credits) {
17
- return {
18
- userId: credits.userId,
19
- balance: credits.balance,
20
- bonusCredits: credits.bonusCredits ?? 0,
21
- reserved: credits.reserved,
22
- tier: credits.tier,
23
- monthlyLimit: credits.monthlyLimit,
24
- monthlyUsed: credits.monthlyUsed,
25
- monthlyResetAt: toISOString(credits.monthlyResetAt),
26
- subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
27
- createdAt: toISOString(credits.createdAt),
28
- updatedAt: toISOString(credits.updatedAt)
29
- };
30
- }
31
- function toISOString(value) {
32
- if (!value) return (/* @__PURE__ */ new Date()).toISOString();
33
- if (typeof value === "string") return value;
34
- if (value instanceof Date) return value.toISOString();
35
- if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
36
- return value.toDate().toISOString();
37
- }
38
- return (/* @__PURE__ */ new Date()).toISOString();
39
- }
40
-
41
179
  // src/config/index.ts
42
180
  import { z } from "zod";
43
181
 
@@ -61,11 +199,13 @@ var creditSystemConfigSchema = z.object({
61
199
  /**
62
200
  * Operation costs in credits
63
201
  * Keys are operation type names (any non-empty string)
64
- * Values are positive numbers representing credit cost
202
+ * Values are non-negative numbers representing credit cost. 0 marks a free
203
+ * operation: `withCredits` runs it without touching the ledger. Amounts that
204
+ * move credits stay strictly positive, so `reserveCredits` refuses a 0.
65
205
  */
66
206
  operationCosts: z.record(
67
207
  z.string().min(1),
68
- z.number().positive()
208
+ z.number().nonnegative()
69
209
  ),
70
210
  /**
71
211
  * Display labels for operation types (English canonical labels).
@@ -333,6 +473,178 @@ function getOperationLabel(operationType) {
333
473
  }
334
474
  initializeConfig();
335
475
 
476
+ // src/core/payment-ref.ts
477
+ function normalizePaymentRef(ref) {
478
+ if (typeof ref !== "string") return void 0;
479
+ const trimmed = ref.trim();
480
+ return trimmed.length > 0 ? trimmed : void 0;
481
+ }
482
+
483
+ // src/repository/types.ts
484
+ function supportsCreditsV2(repository) {
485
+ return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
486
+ }
487
+ function supportsIdempotentCredit(repository) {
488
+ return typeof repository.addCreditsV2 === "function";
489
+ }
490
+ function toClientUserCredits(credits) {
491
+ return {
492
+ userId: credits.userId,
493
+ balance: credits.balance,
494
+ bonusCredits: credits.bonusCredits ?? 0,
495
+ reserved: credits.reserved,
496
+ tier: credits.tier,
497
+ monthlyLimit: credits.monthlyLimit,
498
+ monthlyUsed: credits.monthlyUsed,
499
+ monthlyResetAt: toISOString(credits.monthlyResetAt),
500
+ subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
501
+ createdAt: toISOString(credits.createdAt),
502
+ updatedAt: toISOString(credits.updatedAt)
503
+ };
504
+ }
505
+ function toISOString(value) {
506
+ if (!value) return (/* @__PURE__ */ new Date()).toISOString();
507
+ if (typeof value === "string") return value;
508
+ if (value instanceof Date) return value.toISOString();
509
+ if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
510
+ return value.toDate().toISOString();
511
+ }
512
+ return (/* @__PURE__ */ new Date()).toISOString();
513
+ }
514
+
515
+ // src/repository/flow.ts
516
+ async function reserveThroughRepository(repository, input) {
517
+ const v2 = supportsCreditsV2(repository);
518
+ if (!v2 && input.idempotencyKey !== void 0) {
519
+ throw createUnsupportedOperationError(
520
+ "reserveCredits with an idempotencyKey",
521
+ "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."
522
+ );
523
+ }
524
+ assertValidCreditAmount(input.amount, { userId: input.userId });
525
+ if (v2) {
526
+ return repository.reserveCreditsV2(input);
527
+ }
528
+ try {
529
+ const reservation = await repository.reserveCreditsAtomic(
530
+ input.userId,
531
+ input.amount,
532
+ input.operationType,
533
+ input.expiresAt
534
+ );
535
+ return { outcome: "created", reservation };
536
+ } catch (error) {
537
+ if (!isInsufficientCreditsError(error)) throw error;
538
+ const credits = await repository.getUserCredits(input.userId);
539
+ const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
540
+ return {
541
+ outcome: "insufficient",
542
+ available,
543
+ required: input.amount,
544
+ shortfall: Math.max(sumAmounts(input.amount, -available), 0)
545
+ };
546
+ }
547
+ }
548
+ async function commitThroughRepository(repository, userId, reservationId, options) {
549
+ if (supportsCreditsV2(repository)) {
550
+ return repository.commitReservationV2(userId, reservationId, options);
551
+ }
552
+ const reservation = await repository.getReservation(userId, reservationId);
553
+ if (!reservation) return { outcome: "not_found", reservationId };
554
+ if (reservation.status !== "reserved") {
555
+ return {
556
+ outcome: "already_terminal",
557
+ reservation,
558
+ terminalStatus: reservation.status
559
+ };
560
+ }
561
+ await repository.commitReservationAtomic(userId, reservationId);
562
+ const credits = await repository.getUserCredits(userId);
563
+ const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
564
+ const entry = await repository.createJournalEntry({
565
+ userId,
566
+ entryType: "debit",
567
+ amount: reservation.amount,
568
+ balanceAfter,
569
+ source: "operation_commit",
570
+ referenceId: reservationId,
571
+ referenceType: "reservation",
572
+ description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
573
+ // Deterministic fields last: they carry the identity of the event, and a
574
+ // caller who happens to send an `operationType` or an `amount` must not
575
+ // relabel what actually moved.
576
+ metadata: {
577
+ ...options?.metadata,
578
+ operationType: reservation.operationType,
579
+ amount: reservation.amount
580
+ }
581
+ });
582
+ return {
583
+ outcome: "committed",
584
+ reservation: { ...reservation, status: "committed" },
585
+ amount: reservation.amount,
586
+ balanceAfter,
587
+ journalEntryId: entry.id
588
+ };
589
+ }
590
+ async function releaseThroughRepository(repository, userId, reservationId, options) {
591
+ if (supportsCreditsV2(repository)) {
592
+ return repository.releaseReservationV2(userId, reservationId, options);
593
+ }
594
+ const reservation = await repository.getReservation(userId, reservationId);
595
+ if (!reservation) {
596
+ await repository.releaseReservationAtomic(userId, reservationId);
597
+ return { outcome: "not_found", reservationId };
598
+ }
599
+ if (reservation.status !== "reserved") {
600
+ await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
601
+ return {
602
+ outcome: "already_terminal",
603
+ reservation,
604
+ terminalStatus: reservation.status
605
+ };
606
+ }
607
+ await repository.releaseReservationAtomic(userId, reservationId);
608
+ const credits = await repository.getUserCredits(userId);
609
+ const entry = await repository.createJournalEntry({
610
+ userId,
611
+ entryType: "credit",
612
+ // No credits changed hands — the hold just moved back into "available".
613
+ amount: 0,
614
+ balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
615
+ source: "operation_release",
616
+ referenceId: reservationId,
617
+ referenceType: "reservation",
618
+ description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
619
+ metadata: {
620
+ ...options?.metadata,
621
+ operationType: reservation.operationType,
622
+ amount: reservation.amount
623
+ }
624
+ });
625
+ return {
626
+ outcome: "released",
627
+ reservation: { ...reservation, status: "released" },
628
+ amount: reservation.amount,
629
+ journalEntryId: entry.id
630
+ };
631
+ }
632
+ async function addCreditsThroughRepository(repository, input) {
633
+ assertValidCreditAmount(input.amount, { userId: input.userId, operation: "addCredits" });
634
+ const paymentRef = normalizePaymentRef(input.paymentRef);
635
+ if (supportsIdempotentCredit(repository)) {
636
+ return repository.addCreditsV2({ ...input, paymentRef });
637
+ }
638
+ await repository.addCreditsAtomic(
639
+ input.userId,
640
+ input.amount,
641
+ input.description,
642
+ paymentRef,
643
+ input.options
644
+ );
645
+ return paymentRef ? { outcome: "created", paymentRef } : { outcome: "created" };
646
+ }
647
+
336
648
  // src/service/credits-service.ts
337
649
  function isPastMonthlyReset(resetAt) {
338
650
  if (!resetAt) return false;
@@ -377,28 +689,38 @@ var CreditsService = class {
377
689
  userId,
378
690
  getConfig().subscriptionGracePeriodDays
379
691
  );
380
- if (expiryResult.wasDowngraded) {
692
+ if (expiryResult.wasDowngraded && !expiryResult.journaled) {
381
693
  await this.repository.createJournalEntry({
382
694
  userId,
383
695
  entryType: "debit",
384
696
  amount: 0,
385
697
  // No credits deducted, just tier change
386
- balanceAfter: expiryResult.credits.balance,
698
+ // The ledger balance, matching every other journal writer: the V2
699
+ // transitions and add/deduct all record `balance + bonusCredits`, so
700
+ // recording `balance` alone made the audit trail disagree with itself
701
+ // for any user holding bonus credits.
702
+ balanceAfter: sumAmounts(
703
+ expiryResult.credits.balance,
704
+ expiryResult.credits.bonusCredits
705
+ ),
387
706
  source: "subscription_downgrade",
388
707
  referenceId: `downgrade-${Date.now()}`,
389
708
  referenceType: "subscription",
390
- description: `Subscription expired. Downgraded from ${data.tier} to free tier.`,
709
+ // The tier actually landed on, not a hard-coded "free": the downgrade
710
+ // target is configurable, so naming `free` here could describe a
711
+ // transition that never happened.
712
+ description: `Subscription expired. Downgraded from ${data.tier} to ${expiryResult.credits.tier} tier.`,
391
713
  metadata: {
392
714
  previousTier: data.tier,
393
715
  previousBalance: data.balance,
394
716
  newBalance: expiryResult.credits.balance
395
717
  }
396
718
  });
397
- if (this.subscriptionExpiredCallback) {
398
- this.subscriptionExpiredCallback(userId, true).catch((error) => {
399
- console.error("[Credits] Failed to send subscription expired notification:", error);
400
- });
401
- }
719
+ }
720
+ if (expiryResult.wasDowngraded && this.subscriptionExpiredCallback) {
721
+ this.subscriptionExpiredCallback(userId, true).catch((error) => {
722
+ console.error("[Credits] Failed to send subscription expired notification:", error);
723
+ });
402
724
  }
403
725
  data = expiryResult.credits;
404
726
  }
@@ -409,14 +731,17 @@ var CreditsService = class {
409
731
  data.tier,
410
732
  expectedResetAt
411
733
  );
412
- if (resetResult.wasReset) {
413
- const balanceChange = resetResult.credits.balance - data.balance;
734
+ if (resetResult.wasReset && !resetResult.journaled) {
735
+ const balanceChange = sumAmounts(resetResult.credits.balance, -data.balance);
414
736
  if (balanceChange !== 0) {
415
737
  await this.repository.createJournalEntry({
416
738
  userId,
417
739
  entryType: balanceChange > 0 ? "credit" : "debit",
418
740
  amount: Math.abs(balanceChange),
419
- balanceAfter: resetResult.credits.balance,
741
+ balanceAfter: sumAmounts(
742
+ resetResult.credits.balance,
743
+ resetResult.credits.bonusCredits
744
+ ),
420
745
  source: "monthly_reset",
421
746
  referenceId: `reset-${Date.now()}`,
422
747
  referenceType: "reset",
@@ -467,14 +792,14 @@ var CreditsService = class {
467
792
  */
468
793
  async checkCredits(userId, requiredCredits) {
469
794
  const credits = await this.getOrCreateUserCredits(userId);
470
- const totalBalance = credits.balance + credits.bonusCredits;
471
- const available = totalBalance - credits.reserved;
795
+ const totalBalance = sumAmounts(credits.balance, credits.bonusCredits);
796
+ const available = sumAmounts(totalBalance, -credits.reserved);
472
797
  const hasCredits = available >= requiredCredits;
473
798
  return {
474
799
  hasCredits,
475
800
  balance: totalBalance,
476
801
  required: requiredCredits,
477
- shortfall: hasCredits ? 0 : requiredCredits - available
802
+ shortfall: hasCredits ? 0 : sumAmounts(requiredCredits, -available)
478
803
  };
479
804
  }
480
805
  /**
@@ -488,74 +813,110 @@ var CreditsService = class {
488
813
  * @throws Error if insufficient credits
489
814
  */
490
815
  async reserveCredits(userId, amount, operationType, options) {
816
+ const outcome = await this.reserveCreditsDetailed(userId, amount, operationType, options);
817
+ if (outcome.outcome === "created" || outcome.outcome === "replayed") {
818
+ return outcome.reservation;
819
+ }
820
+ if (outcome.outcome === "insufficient") {
821
+ throw createInsufficientCreditsError(outcome.required, outcome.available);
822
+ }
823
+ throw createIdempotencyConflictError(outcome.idempotencyKey, {
824
+ userId,
825
+ requested: { amount, operationType },
826
+ existing: {
827
+ amount: outcome.existing.amount,
828
+ operationType: outcome.existing.operationType
829
+ }
830
+ });
831
+ }
832
+ /**
833
+ * Reserve credits and get the typed outcome instead of an exception.
834
+ *
835
+ * Prefer this over {@link reserveCredits} in async callers: it distinguishes
836
+ * a fresh hold from an idempotent replay, and reports insufficient funds as
837
+ * a value rather than as control flow.
838
+ */
839
+ async reserveCreditsDetailed(userId, amount, operationType, options) {
491
840
  const ttlMs = options?.ttlMs ?? getConfig().reservationExpiryMs;
492
841
  const expiresAt = new Date(Date.now() + ttlMs);
493
- return this.repository.reserveCreditsAtomic(userId, amount, operationType, expiresAt);
842
+ return reserveThroughRepository(this.repository, {
843
+ userId,
844
+ amount,
845
+ operationType,
846
+ expiresAt,
847
+ idempotencyKey: options?.idempotencyKey
848
+ });
494
849
  }
495
850
  /**
496
- * Commit a reservation (phase 2 of two-phase commit - success)
497
- * Deducts credits and marks reservation as committed
498
- * Also triggers low balance notifications if balance drops below threshold
851
+ * Commit a reservation (phase 2 of two-phase commit - success).
852
+ *
853
+ * Idempotent: re-delivering a commit for an already-committed reservation is
854
+ * a no-op. Committing one that was released or expired throws, because the
855
+ * credits are no longer held and pretending otherwise would hide a real bug.
499
856
  */
500
- async commitCredits(userId, reservationId) {
501
- const reservation = await this.repository.getReservation(userId, reservationId);
502
- if (!reservation) {
503
- throw new Error(`Reservation ${reservationId} not found`);
857
+ async commitCredits(userId, reservationId, options) {
858
+ const outcome = await this.commitCreditsDetailed(userId, reservationId, options);
859
+ if (outcome.outcome === "not_found") {
860
+ throw createReservationNotFoundError(reservationId);
504
861
  }
505
- if (reservation.status === "committed") {
506
- return;
862
+ if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
863
+ throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
507
864
  }
508
- await this.repository.commitReservationAtomic(userId, reservationId);
509
- const credits = await this.repository.getUserCredits(userId);
510
- if (credits) {
511
- await this.repository.createJournalEntry({
512
- userId,
513
- entryType: "debit",
514
- amount: reservation.amount,
515
- balanceAfter: credits.balance,
516
- source: "operation_commit",
517
- referenceId: reservationId,
518
- referenceType: "reservation",
519
- description: `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
520
- metadata: {
521
- operationType: reservation.operationType
522
- }
865
+ }
866
+ /**
867
+ * Commit a reservation and get the typed outcome.
868
+ *
869
+ * Exactly one concurrent caller sees `committed`; that call moved the balance
870
+ * and wrote the single journal entry. On a V2 repository the journal is
871
+ * written inside the same transaction, so this method adds none of its own —
872
+ * the duplicate entry the previous implementation wrote is gone.
873
+ *
874
+ * The low-balance notification fires only for the winner, and only after the
875
+ * transaction has committed, so no callback runs inside a database
876
+ * transaction.
877
+ */
878
+ async commitCreditsDetailed(userId, reservationId, options) {
879
+ const outcome = await commitThroughRepository(
880
+ this.repository,
881
+ userId,
882
+ reservationId,
883
+ options
884
+ );
885
+ if (outcome.outcome === "committed" && this.lowBalanceCallback) {
886
+ this.lowBalanceCallback(userId, outcome.balanceAfter).catch((error) => {
887
+ console.error("[Credits] Failed to send low balance notification:", error);
523
888
  });
524
- if (this.lowBalanceCallback) {
525
- this.lowBalanceCallback(userId, credits.balance).catch((error) => {
526
- console.error("[Credits] Failed to send low balance notification:", error);
527
- });
528
- }
529
889
  }
890
+ return outcome;
530
891
  }
531
892
  /**
532
- * Release a reservation (phase 2 of two-phase commit - failure)
533
- * Returns reserved credits and marks reservation as released
893
+ * Release a reservation (phase 2 of two-phase commit - failure).
894
+ *
895
+ * Throws `RESERVATION_NOT_FOUND` for an unknown reservation, matching the
896
+ * pre-V2 behaviour — a release naming a reservation that does not exist is a
897
+ * caller bug, and swallowing it hides the bug while the credits stay held.
898
+ *
899
+ * Releasing one that is already terminal — `released`, `expired`, or
900
+ * `committed` — is a no-op, which is the behaviour this method has always
901
+ * had. A release that loses a race to a concurrent commit is the common
902
+ * case in a retry-heavy caller, and callers written against the pre-V2
903
+ * contract handle it by not handling it.
904
+ *
905
+ * That does mean this method cannot tell you that the credits were spent
906
+ * rather than returned. Use {@link releaseCreditsDetailed}, which reports
907
+ * `already_terminal` with `terminalStatus: 'committed'`, when you need to
908
+ * branch on it.
534
909
  */
535
- async releaseCredits(userId, reservationId) {
536
- const reservation = await this.repository.getReservation(userId, reservationId);
537
- await this.repository.releaseReservationAtomic(userId, reservationId);
538
- if (reservation?.status === "reserved") {
539
- const credits = await this.repository.getUserCredits(userId);
540
- if (credits) {
541
- await this.repository.createJournalEntry({
542
- userId,
543
- entryType: "credit",
544
- amount: 0,
545
- // No actual credits returned (they were reserved, not spent)
546
- balanceAfter: credits.balance,
547
- source: "operation_release",
548
- referenceId: reservationId,
549
- referenceType: "reservation",
550
- description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
551
- metadata: {
552
- operationType: reservation.operationType,
553
- amount: reservation.amount
554
- }
555
- });
556
- }
910
+ async releaseCredits(userId, reservationId, options) {
911
+ const outcome = await this.releaseCreditsDetailed(userId, reservationId, options);
912
+ if (outcome.outcome === "not_found") {
913
+ throw createReservationNotFoundError(reservationId);
557
914
  }
558
915
  }
916
+ /** Release a reservation and get the typed outcome. */
917
+ async releaseCreditsDetailed(userId, reservationId, options) {
918
+ return releaseThroughRepository(this.repository, userId, reservationId, options);
919
+ }
559
920
  /**
560
921
  * Log usage for audit trail
561
922
  * @param log - Usage log data
@@ -571,9 +932,20 @@ var CreditsService = class {
571
932
  * @param paymentRef - Optional payment reference
572
933
  * @param options - Optional journal source / reference type / extra metadata
573
934
  * for the credit journal entry (e.g. revenue-attribution fields).
935
+ * @returns Whether this delivery credited the account (`created`), repeated an
936
+ * earlier one (`replayed`), or reused the reference for a different credit
937
+ * event (`conflict`, which credited nothing). On a repository without
938
+ * `addCreditsV2` the result is always `created` and carries no
939
+ * deduplication guarantee — see {@link addCreditsThroughRepository}.
574
940
  */
575
941
  async addCredits(userId, amount, description, paymentRef, options) {
576
- return this.repository.addCreditsAtomic(userId, amount, description, paymentRef, options);
942
+ return addCreditsThroughRepository(this.repository, {
943
+ userId,
944
+ amount,
945
+ description,
946
+ paymentRef,
947
+ options
948
+ });
577
949
  }
578
950
  /**
579
951
  * One-shot atomic "deduct-if-sufficient" credit charge.
@@ -597,15 +969,14 @@ var CreditsService = class {
597
969
  * @throws Error if amount is not a positive number
598
970
  */
599
971
  async deductCredits(userId, amount, options) {
600
- if (!(amount > 0)) {
601
- throw new Error(`deductCredits amount must be positive (got ${amount})`);
602
- }
972
+ assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
603
973
  let deduction;
604
974
  try {
605
975
  deduction = await this.repository.deductCreditsAtomic(userId, amount);
606
976
  } catch (error) {
977
+ if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
607
978
  const credits = await this.repository.getUserCredits(userId);
608
- const available = credits ? credits.balance + credits.bonusCredits - credits.reserved : 0;
979
+ const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
609
980
  if (available >= amount) {
610
981
  throw error;
611
982
  }
@@ -614,7 +985,7 @@ var CreditsService = class {
614
985
  reason: "insufficient",
615
986
  available,
616
987
  required: amount,
617
- shortfall: amount - available
988
+ shortfall: sumAmounts(amount, -available)
618
989
  };
619
990
  }
620
991
  const operationType = options?.operationType;
@@ -628,8 +999,8 @@ var CreditsService = class {
628
999
  referenceType: "adjustment",
629
1000
  description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
630
1001
  metadata: {
631
- ...operationType && { operationType },
632
- ...options?.metadata
1002
+ ...options?.metadata,
1003
+ ...operationType && { operationType }
633
1004
  }
634
1005
  });
635
1006
  if (operationType) {
@@ -664,8 +1035,15 @@ var CreditsService = class {
664
1035
  const free = isFreeTier(tier);
665
1036
  await this.repository.updateUserTier(userId, {
666
1037
  tier,
667
- // Stored monthlyLimit uses the 0-means-unlimited convention.
668
- monthlyLimit: unlimited ? 0 : monthlyLimit,
1038
+ // Through the shared contract, like every path that resolves the tier's
1039
+ // limit from configuration. (The repository's `updateUserCredits` and
1040
+ // `updateUserTier` still store a caller-supplied `monthlyLimit` verbatim;
1041
+ // they are given a value, not a tier, so there is no sentinel to map.)
1042
+ // This used to store 0 for an unlimited tier under a "0 means unlimited"
1043
+ // convention that nothing actually implemented — no read path anywhere in
1044
+ // the library branches on it — so upgrading a user to unlimited gave them
1045
+ // an allowance of zero.
1046
+ monthlyLimit: storedMonthlyLimit(monthlyLimit),
669
1047
  // Reset balance to new tier limit if upgrading
670
1048
  balance: !free ? unlimited ? getUnlimitedSentinelBalance() : monthlyLimit : void 0,
671
1049
  monthlyUsed: !free ? 0 : void 0,