@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
@@ -1,6 +1,60 @@
1
- import { I as ICreditRepository, d as CreditBalanceUpdate, T as TierUpdateInput, a as CreateReservationInput, A as AddCreditsAtomicOptions, b as CreateTransactionInput, c as CreateUsageLogInput, U as UsageLogQuery, C as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-DFe5BoJY.cjs';
2
- export { e as CreditRepositoryFactory, t as toClientUserCredits } from '../types-DFe5BoJY.cjs';
3
- import { b as PortableUserCredits, S as SubscriptionTier, c as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-CjLj3GW5.cjs';
1
+ import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, l as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, m as ReserveCreditsV2Input, n as ReserveOutcome, g as CreditBalanceUpdate, o as TierUpdateInput, d as CreateReservationInput, i as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-C8hZMe8a.cjs';
2
+ export { h as CreditRepositoryFactory, j as ICreditRepositoryCreditsV2, k as ICreditRepositoryV2, s as reservationJournalKey, t as supportsCreditsV2, u as supportsIdempotentCredit, v as toClientUserCredits } from '../types-C8hZMe8a.cjs';
3
+ import { c as PortableUserCredits, S as SubscriptionTier, a as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-DJ8yqaVr.cjs';
4
+
5
+ /**
6
+ * One reservation flow for both repository generations.
7
+ *
8
+ * Adapters that implement the V2 boundary get the idempotent, race-safe path
9
+ * and own their journal entry. Adapters that do not (Firestore, custom
10
+ * implementations) keep the legacy read-then-write path, with the service
11
+ * writing the journal exactly as it always did — so upgrading this package
12
+ * does not change their behaviour, and does not double-write anyone's ledger.
13
+ */
14
+
15
+ /**
16
+ * Reserve credits through V2 when available.
17
+ *
18
+ * A legacy adapter cannot honour an idempotency key — it has no unique index
19
+ * to enforce it — so asking for one there is a caller error rather than a
20
+ * silently ignored option.
21
+ */
22
+ declare function reserveThroughRepository(repository: ICreditRepository, input: ReserveCreditsV2Input): Promise<ReserveOutcome>;
23
+ /**
24
+ * Commit through V2 when available, else the legacy read-then-write path.
25
+ *
26
+ * **The legacy path cannot promise a single winner.** It reads the status and
27
+ * then writes, with no lock and no compare-and-set between the two, so two
28
+ * concurrent commits of the same reservation can both observe `reserved` and
29
+ * both proceed. The typed outcome still describes what *this* call saw, but on
30
+ * a legacy adapter `committed` means "this call did the work", not "this call
31
+ * was the only one that did". Callers needing exactly-once must use a
32
+ * repository for which `supportsCreditsV2` returns `true`.
33
+ */
34
+ declare function commitThroughRepository(repository: ICreditRepository, userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<CommitOutcome>;
35
+ /**
36
+ * Release through V2 when available, else the legacy read-then-write path.
37
+ *
38
+ * Same caveat as {@link commitThroughRepository}: on a legacy adapter the
39
+ * read-then-write sequence is unguarded, so `released` does not exclude a
40
+ * concurrent commit having also run. Only V2 repositories serialise the two.
41
+ */
42
+ declare function releaseThroughRepository(repository: ICreditRepository, userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<ReleaseOutcome>;
43
+ /**
44
+ * Credit an account through the idempotent path when the adapter has one.
45
+ *
46
+ * A legacy adapter cannot resolve `created` / `replayed` / `conflict`: it has
47
+ * no global unique index over `payment_ref`, so it cannot tell a re-delivered
48
+ * webhook from a second, genuinely different payment that reuses a reference.
49
+ * Rather than invent an outcome, this reports `created` — which is what the
50
+ * legacy method actually did — and the ambiguity is documented on the return
51
+ * type instead of hidden inside it.
52
+ *
53
+ * The reference is normalised here as well as in the adapters, so a legacy
54
+ * adapter that stores the raw string does not persist a whitespace-only
55
+ * reference that would then deduplicate nothing.
56
+ */
57
+ declare function addCreditsThroughRepository(repository: ICreditRepository, input: AddCreditsV2Input): Promise<AddCreditsOutcome>;
4
58
 
5
59
  /**
6
60
  * Shared utilities for credit repository implementations
@@ -36,41 +90,103 @@ declare function getNextMonthlyReset(from?: Date): Date;
36
90
  * - Reference implementation for custom repositories
37
91
  */
38
92
 
39
- /**
40
- * In-Memory implementation of ICreditRepository
41
- *
42
- * Implements all repository methods using Map-based storage.
43
- * Useful for testing and as a reference implementation.
44
- */
45
93
  declare class InMemoryCreditRepository implements ICreditRepository {
46
- private users;
47
- private reservations;
48
- private transactions;
49
- private usageLogs;
50
- private journalEntries;
94
+ private readonly store;
95
+ /**
96
+ * A copy, never the live record.
97
+ *
98
+ * Handing out the stored object made this adapter behave unlike any real
99
+ * one: a caller holding a "before" snapshot saw it mutate under them. The
100
+ * service's monthly reset compared `resetResult.credits.balance` against the
101
+ * balance it had read moments earlier, got zero because both were the same
102
+ * object, and silently skipped the reset's journal entry — while the SQL
103
+ * adapter, which maps each row into a fresh object, wrote it.
104
+ */
51
105
  getUserCredits(userId: string): Promise<PortableUserCredits | null>;
52
106
  initializeUserCredits(userId: string, tier: SubscriptionTier, initialBalance: number): Promise<PortableUserCredits>;
107
+ /**
108
+ * The synchronous body of `initializeUserCredits`.
109
+ *
110
+ * Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
111
+ * its critical section, and an `await` between the payment-reference check
112
+ * and the transaction write opens a gap another caller can run through — two
113
+ * simultaneous first deliveries of the same reference would then both credit.
114
+ */
115
+ private seedUser;
53
116
  updateUserCredits(userId: string, updates: CreditBalanceUpdate): Promise<void>;
54
117
  updateUserTier(userId: string, input: TierUpdateInput): Promise<void>;
55
118
  createReservation(input: CreateReservationInput): Promise<PortableReservation>;
56
119
  getReservation(userId: string, reservationId: string): Promise<PortableReservation | null>;
57
120
  updateReservationStatus(userId: string, reservationId: string, status: ReservationStatus, completedAt?: Date): Promise<void>;
121
+ reserveCreditsV2(input: ReserveCreditsV2Input): Promise<ReserveOutcome>;
122
+ commitReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<CommitOutcome>;
123
+ releaseReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<ReleaseOutcome>;
124
+ expireReservationV2(userId: string, reservationId: string, options?: ExpireReservationV2Options): Promise<ExpireOutcome>;
58
125
  reserveCreditsAtomic(userId: string, amount: number, operationType: string, expiresAt: Date): Promise<PortableReservation>;
59
126
  commitReservationAtomic(userId: string, reservationId: string): Promise<void>;
60
127
  releaseReservationAtomic(userId: string, reservationId: string): Promise<void>;
128
+ /**
129
+ * The stored transaction carrying this reference, in *any* user's ledger.
130
+ *
131
+ * Deliberately not scoped to one user. The SQL adapter's unique index covers
132
+ * `payment_ref` alone, so searching only the crediting user's own history made
133
+ * the same reference credit two different accounts here while the SQL adapter
134
+ * treated the second delivery as a duplicate. See `core/payment-ref.ts`.
135
+ */
136
+ private findByPaymentRef;
137
+ /** The journal entry written alongside a stored purchase, if there is one. */
138
+ private journalForTransaction;
139
+ /**
140
+ * Credit an account, resolving `paymentRef` to created / replayed / conflict.
141
+ *
142
+ * The reference is checked and the transaction is recorded with no `await`
143
+ * between them. `createTransaction` is `async` but its body is synchronous, so
144
+ * under JavaScript's run-to-completion semantics no other caller can observe
145
+ * the gap — which is what stands in for the SQL adapter's unique index.
146
+ *
147
+ * A `replayed` or `conflict` outcome writes nothing at all: the check runs
148
+ * before the balance moves, not after.
149
+ */
150
+ addCreditsV2(input: AddCreditsV2Input): Promise<AddCreditsOutcome>;
151
+ /**
152
+ * The legacy signature, on top of {@link addCreditsV2}.
153
+ *
154
+ * `Promise<void>` has nowhere to report a conflict, and silently returning is
155
+ * indistinguishable from "credited" — the exact failure this round is closing
156
+ * — so a conflict throws. A genuine replay still returns quietly, because
157
+ * that *is* the idempotent no-op the caller asked for.
158
+ */
61
159
  addCreditsAtomic(userId: string, amount: number, description: string, paymentRef?: string, options?: AddCreditsAtomicOptions): Promise<void>;
62
160
  deductCreditsAtomic(userId: string, amount: number): Promise<{
63
161
  previousBalance: number;
64
162
  newBalance: number;
65
163
  }>;
66
164
  createTransaction(input: CreateTransactionInput): Promise<PortableTransaction>;
165
+ /** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
166
+ private recordTransaction;
67
167
  getTransactions(userId: string, limit?: number, offset?: number): Promise<PortableTransaction[]>;
68
168
  logUsage(input: CreateUsageLogInput): Promise<PortableUsageLog>;
69
169
  getUsageLogs(query: UsageLogQuery): Promise<PortableUsageLog[]>;
70
170
  getUsageLogsCount(query: Omit<UsageLogQuery, "limit" | "offset">): Promise<number>;
71
171
  createJournalEntry(input: CreateJournalEntryInput): Promise<PortableJournalEntry>;
172
+ /**
173
+ * The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
174
+ * write its journal line inside the same run-to-completion step as the
175
+ * balance write — an `await` between the two is exactly the gap that loses
176
+ * the line when the second half fails.
177
+ */
178
+ private recordJournalEntry;
72
179
  getJournalEntries(query: JournalEntryQuery): Promise<PortableJournalEntry[]>;
73
180
  getJournalEntriesCount(query: Omit<JournalEntryQuery, "limit" | "offset">): Promise<number>;
181
+ /**
182
+ * Expire every hold whose deadline has passed.
183
+ *
184
+ * Each candidate goes through the guarded {@link expireReservationV2}, which
185
+ * re-checks the status and the deadline under the user's lock. The previous
186
+ * implementation released the hold and then overwrote the status to
187
+ * `expired`, so a commit landing in between got its credits handed back and
188
+ * was then relabelled.
189
+ */
74
190
  findAndExpireReservations(_batchSize?: number, _maxIterations?: number): Promise<{
75
191
  expiredCount: number;
76
192
  creditsReleased: number;
@@ -82,6 +198,16 @@ declare class InMemoryCreditRepository implements ICreditRepository {
82
198
  * Clear all data (useful for testing)
83
199
  */
84
200
  clear(): void;
201
+ /**
202
+ * Install a yield point inside every V2 critical section (testing only).
203
+ *
204
+ * Unset, the critical sections run start-to-finish synchronously, so
205
+ * concurrent callers never interleave and a concurrency test would pass even
206
+ * with the locking removed. Setting this to a real yield (a macrotask or a
207
+ * barrier) makes callers genuinely overlap, so the tests exercise the lock
208
+ * instead of the event loop. Never call this in production code.
209
+ */
210
+ setSchedulingHook(hook: (() => Promise<void>) | undefined): void;
85
211
  /**
86
212
  * Get all users (useful for testing/debugging)
87
213
  */
@@ -97,4 +223,4 @@ declare class InMemoryCreditRepository implements ICreditRepository {
97
223
  */
98
224
  declare function createInMemoryCreditRepository(): InMemoryCreditRepository;
99
225
 
100
- export { AddCreditsAtomicOptions, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, TierUpdateInput, UsageLogQuery, createInMemoryCreditRepository, generateId, getNextMonthlyReset, toDate };
226
+ export { AddCreditsAtomicOptions, AddCreditsV2Input, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate };
@@ -1,6 +1,60 @@
1
- import { I as ICreditRepository, d as CreditBalanceUpdate, T as TierUpdateInput, a as CreateReservationInput, A as AddCreditsAtomicOptions, b as CreateTransactionInput, c as CreateUsageLogInput, U as UsageLogQuery, C as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-_bSybQnb.js';
2
- export { e as CreditRepositoryFactory, t as toClientUserCredits } from '../types-_bSybQnb.js';
3
- import { b as PortableUserCredits, S as SubscriptionTier, c as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-CjLj3GW5.js';
1
+ import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, l as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, m as ReserveCreditsV2Input, n as ReserveOutcome, g as CreditBalanceUpdate, o as TierUpdateInput, d as CreateReservationInput, i as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-BmG5Omzj.js';
2
+ export { h as CreditRepositoryFactory, j as ICreditRepositoryCreditsV2, k as ICreditRepositoryV2, s as reservationJournalKey, t as supportsCreditsV2, u as supportsIdempotentCredit, v as toClientUserCredits } from '../types-BmG5Omzj.js';
3
+ import { c as PortableUserCredits, S as SubscriptionTier, a as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-DJ8yqaVr.js';
4
+
5
+ /**
6
+ * One reservation flow for both repository generations.
7
+ *
8
+ * Adapters that implement the V2 boundary get the idempotent, race-safe path
9
+ * and own their journal entry. Adapters that do not (Firestore, custom
10
+ * implementations) keep the legacy read-then-write path, with the service
11
+ * writing the journal exactly as it always did — so upgrading this package
12
+ * does not change their behaviour, and does not double-write anyone's ledger.
13
+ */
14
+
15
+ /**
16
+ * Reserve credits through V2 when available.
17
+ *
18
+ * A legacy adapter cannot honour an idempotency key — it has no unique index
19
+ * to enforce it — so asking for one there is a caller error rather than a
20
+ * silently ignored option.
21
+ */
22
+ declare function reserveThroughRepository(repository: ICreditRepository, input: ReserveCreditsV2Input): Promise<ReserveOutcome>;
23
+ /**
24
+ * Commit through V2 when available, else the legacy read-then-write path.
25
+ *
26
+ * **The legacy path cannot promise a single winner.** It reads the status and
27
+ * then writes, with no lock and no compare-and-set between the two, so two
28
+ * concurrent commits of the same reservation can both observe `reserved` and
29
+ * both proceed. The typed outcome still describes what *this* call saw, but on
30
+ * a legacy adapter `committed` means "this call did the work", not "this call
31
+ * was the only one that did". Callers needing exactly-once must use a
32
+ * repository for which `supportsCreditsV2` returns `true`.
33
+ */
34
+ declare function commitThroughRepository(repository: ICreditRepository, userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<CommitOutcome>;
35
+ /**
36
+ * Release through V2 when available, else the legacy read-then-write path.
37
+ *
38
+ * Same caveat as {@link commitThroughRepository}: on a legacy adapter the
39
+ * read-then-write sequence is unguarded, so `released` does not exclude a
40
+ * concurrent commit having also run. Only V2 repositories serialise the two.
41
+ */
42
+ declare function releaseThroughRepository(repository: ICreditRepository, userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<ReleaseOutcome>;
43
+ /**
44
+ * Credit an account through the idempotent path when the adapter has one.
45
+ *
46
+ * A legacy adapter cannot resolve `created` / `replayed` / `conflict`: it has
47
+ * no global unique index over `payment_ref`, so it cannot tell a re-delivered
48
+ * webhook from a second, genuinely different payment that reuses a reference.
49
+ * Rather than invent an outcome, this reports `created` — which is what the
50
+ * legacy method actually did — and the ambiguity is documented on the return
51
+ * type instead of hidden inside it.
52
+ *
53
+ * The reference is normalised here as well as in the adapters, so a legacy
54
+ * adapter that stores the raw string does not persist a whitespace-only
55
+ * reference that would then deduplicate nothing.
56
+ */
57
+ declare function addCreditsThroughRepository(repository: ICreditRepository, input: AddCreditsV2Input): Promise<AddCreditsOutcome>;
4
58
 
5
59
  /**
6
60
  * Shared utilities for credit repository implementations
@@ -36,41 +90,103 @@ declare function getNextMonthlyReset(from?: Date): Date;
36
90
  * - Reference implementation for custom repositories
37
91
  */
38
92
 
39
- /**
40
- * In-Memory implementation of ICreditRepository
41
- *
42
- * Implements all repository methods using Map-based storage.
43
- * Useful for testing and as a reference implementation.
44
- */
45
93
  declare class InMemoryCreditRepository implements ICreditRepository {
46
- private users;
47
- private reservations;
48
- private transactions;
49
- private usageLogs;
50
- private journalEntries;
94
+ private readonly store;
95
+ /**
96
+ * A copy, never the live record.
97
+ *
98
+ * Handing out the stored object made this adapter behave unlike any real
99
+ * one: a caller holding a "before" snapshot saw it mutate under them. The
100
+ * service's monthly reset compared `resetResult.credits.balance` against the
101
+ * balance it had read moments earlier, got zero because both were the same
102
+ * object, and silently skipped the reset's journal entry — while the SQL
103
+ * adapter, which maps each row into a fresh object, wrote it.
104
+ */
51
105
  getUserCredits(userId: string): Promise<PortableUserCredits | null>;
52
106
  initializeUserCredits(userId: string, tier: SubscriptionTier, initialBalance: number): Promise<PortableUserCredits>;
107
+ /**
108
+ * The synchronous body of `initializeUserCredits`.
109
+ *
110
+ * Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
111
+ * its critical section, and an `await` between the payment-reference check
112
+ * and the transaction write opens a gap another caller can run through — two
113
+ * simultaneous first deliveries of the same reference would then both credit.
114
+ */
115
+ private seedUser;
53
116
  updateUserCredits(userId: string, updates: CreditBalanceUpdate): Promise<void>;
54
117
  updateUserTier(userId: string, input: TierUpdateInput): Promise<void>;
55
118
  createReservation(input: CreateReservationInput): Promise<PortableReservation>;
56
119
  getReservation(userId: string, reservationId: string): Promise<PortableReservation | null>;
57
120
  updateReservationStatus(userId: string, reservationId: string, status: ReservationStatus, completedAt?: Date): Promise<void>;
121
+ reserveCreditsV2(input: ReserveCreditsV2Input): Promise<ReserveOutcome>;
122
+ commitReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<CommitOutcome>;
123
+ releaseReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<ReleaseOutcome>;
124
+ expireReservationV2(userId: string, reservationId: string, options?: ExpireReservationV2Options): Promise<ExpireOutcome>;
58
125
  reserveCreditsAtomic(userId: string, amount: number, operationType: string, expiresAt: Date): Promise<PortableReservation>;
59
126
  commitReservationAtomic(userId: string, reservationId: string): Promise<void>;
60
127
  releaseReservationAtomic(userId: string, reservationId: string): Promise<void>;
128
+ /**
129
+ * The stored transaction carrying this reference, in *any* user's ledger.
130
+ *
131
+ * Deliberately not scoped to one user. The SQL adapter's unique index covers
132
+ * `payment_ref` alone, so searching only the crediting user's own history made
133
+ * the same reference credit two different accounts here while the SQL adapter
134
+ * treated the second delivery as a duplicate. See `core/payment-ref.ts`.
135
+ */
136
+ private findByPaymentRef;
137
+ /** The journal entry written alongside a stored purchase, if there is one. */
138
+ private journalForTransaction;
139
+ /**
140
+ * Credit an account, resolving `paymentRef` to created / replayed / conflict.
141
+ *
142
+ * The reference is checked and the transaction is recorded with no `await`
143
+ * between them. `createTransaction` is `async` but its body is synchronous, so
144
+ * under JavaScript's run-to-completion semantics no other caller can observe
145
+ * the gap — which is what stands in for the SQL adapter's unique index.
146
+ *
147
+ * A `replayed` or `conflict` outcome writes nothing at all: the check runs
148
+ * before the balance moves, not after.
149
+ */
150
+ addCreditsV2(input: AddCreditsV2Input): Promise<AddCreditsOutcome>;
151
+ /**
152
+ * The legacy signature, on top of {@link addCreditsV2}.
153
+ *
154
+ * `Promise<void>` has nowhere to report a conflict, and silently returning is
155
+ * indistinguishable from "credited" — the exact failure this round is closing
156
+ * — so a conflict throws. A genuine replay still returns quietly, because
157
+ * that *is* the idempotent no-op the caller asked for.
158
+ */
61
159
  addCreditsAtomic(userId: string, amount: number, description: string, paymentRef?: string, options?: AddCreditsAtomicOptions): Promise<void>;
62
160
  deductCreditsAtomic(userId: string, amount: number): Promise<{
63
161
  previousBalance: number;
64
162
  newBalance: number;
65
163
  }>;
66
164
  createTransaction(input: CreateTransactionInput): Promise<PortableTransaction>;
165
+ /** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
166
+ private recordTransaction;
67
167
  getTransactions(userId: string, limit?: number, offset?: number): Promise<PortableTransaction[]>;
68
168
  logUsage(input: CreateUsageLogInput): Promise<PortableUsageLog>;
69
169
  getUsageLogs(query: UsageLogQuery): Promise<PortableUsageLog[]>;
70
170
  getUsageLogsCount(query: Omit<UsageLogQuery, "limit" | "offset">): Promise<number>;
71
171
  createJournalEntry(input: CreateJournalEntryInput): Promise<PortableJournalEntry>;
172
+ /**
173
+ * The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
174
+ * write its journal line inside the same run-to-completion step as the
175
+ * balance write — an `await` between the two is exactly the gap that loses
176
+ * the line when the second half fails.
177
+ */
178
+ private recordJournalEntry;
72
179
  getJournalEntries(query: JournalEntryQuery): Promise<PortableJournalEntry[]>;
73
180
  getJournalEntriesCount(query: Omit<JournalEntryQuery, "limit" | "offset">): Promise<number>;
181
+ /**
182
+ * Expire every hold whose deadline has passed.
183
+ *
184
+ * Each candidate goes through the guarded {@link expireReservationV2}, which
185
+ * re-checks the status and the deadline under the user's lock. The previous
186
+ * implementation released the hold and then overwrote the status to
187
+ * `expired`, so a commit landing in between got its credits handed back and
188
+ * was then relabelled.
189
+ */
74
190
  findAndExpireReservations(_batchSize?: number, _maxIterations?: number): Promise<{
75
191
  expiredCount: number;
76
192
  creditsReleased: number;
@@ -82,6 +198,16 @@ declare class InMemoryCreditRepository implements ICreditRepository {
82
198
  * Clear all data (useful for testing)
83
199
  */
84
200
  clear(): void;
201
+ /**
202
+ * Install a yield point inside every V2 critical section (testing only).
203
+ *
204
+ * Unset, the critical sections run start-to-finish synchronously, so
205
+ * concurrent callers never interleave and a concurrency test would pass even
206
+ * with the locking removed. Setting this to a real yield (a macrotask or a
207
+ * barrier) makes callers genuinely overlap, so the tests exercise the lock
208
+ * instead of the event loop. Never call this in production code.
209
+ */
210
+ setSchedulingHook(hook: (() => Promise<void>) | undefined): void;
85
211
  /**
86
212
  * Get all users (useful for testing/debugging)
87
213
  */
@@ -97,4 +223,4 @@ declare class InMemoryCreditRepository implements ICreditRepository {
97
223
  */
98
224
  declare function createInMemoryCreditRepository(): InMemoryCreditRepository;
99
225
 
100
- export { AddCreditsAtomicOptions, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, TierUpdateInput, UsageLogQuery, createInMemoryCreditRepository, generateId, getNextMonthlyReset, toDate };
226
+ export { AddCreditsAtomicOptions, AddCreditsV2Input, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate };