@nehorai/credits 3.0.0 → 3.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.
@@ -1,255 +1,3 @@
1
- import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, m as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, n as ReserveCreditsV2Input, o as ReserveOutcome, g as CreditBalanceUpdate, h as CreditPolicyOptions, p as TierUpdateInput, d as CreateReservationInput, j as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, D as DeductJournalInput, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-DOjguhEX.cjs';
2
- export { i as CreditRepositoryFactory, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from '../types-DOjguhEX.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-C3sxDvNb.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>;
58
-
59
- /**
60
- * Shared utilities for credit repository implementations
61
- *
62
- * These are pure functions with no server-only dependencies,
63
- * making them safe to use in any repository implementation.
64
- */
65
-
66
- /**
67
- * Generate a unique ID
68
- * Uses timestamp + random string for uniqueness
69
- */
70
- declare function generateId(): string;
71
- /**
72
- * Convert date-like value to Date object
73
- * Handles: Date, string, Firestore Timestamp
74
- */
75
- declare function toDate(value: Date | string | unknown): Date;
76
- /**
77
- * Get the first day of next month
78
- * Used for calculating monthly reset dates
79
- */
80
- declare function getNextMonthlyReset(from?: Date): Date;
81
- /** Result of validating a balance update. */
82
- interface BalanceValidationResult {
83
- valid: boolean;
84
- errors: string[];
85
- }
86
- /**
87
- * Whether an update leaves a valid account: no negative field, and `reserved` covered
88
- * by `balance + bonusCredits`, which is the floor outstanding holds put under the
89
- * balance. An increment is applied on top of the absolute value when a call names both.
90
- *
91
- * An update is a delta the caller asked for, so one that cannot be honoured is refused,
92
- * as a deduct is. Only tier writes and resets stop at the floor: they state a target.
93
- *
94
- * A negative balance increment is a charge, so it draws only on the balance above
95
- * `expiredHoldBacking`: those credits are expired and leave when their hold closes, so
96
- * charging them would hand the charge back. An absolute balance replaces the field and
97
- * the marks with it, so only an increment on the stored balance meets this limit.
98
- */
99
- declare function validateBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): BalanceValidationResult;
100
- /** Refuse an update {@link validateBalanceUpdate} finds invalid. */
101
- declare function assertValidBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): void;
102
-
103
- /**
104
- * In-Memory Credit Repository Implementation
105
- *
106
- * A database-agnostic implementation of ICreditRepository for testing and prototyping.
107
- * All data is stored in memory and lost when the process restarts.
108
- *
109
- * Usage:
110
- * - Unit tests without database dependency
111
- * - Local development and prototyping
112
- * - Reference implementation for custom repositories
113
- */
114
-
115
- declare class InMemoryCreditRepository implements ICreditRepository {
116
- private readonly store;
117
- private readonly options;
118
- constructor(options?: CreditPolicyOptions);
119
- /**
120
- * A copy, never the live record.
121
- *
122
- * Handing out the stored object made this adapter behave unlike any real
123
- * one: a caller holding a "before" snapshot saw it mutate under them. The
124
- * service's monthly reset compared `resetResult.credits.balance` against the
125
- * balance it had read moments earlier, got zero because both were the same
126
- * object, and silently skipped the reset's journal entry — while the SQL
127
- * adapter, which maps each row into a fresh object, wrote it.
128
- */
129
- getUserCredits(userId: string): Promise<PortableUserCredits | null>;
130
- initializeUserCredits(userId: string, tier: SubscriptionTier, initialBalance: number): Promise<PortableUserCredits>;
131
- /**
132
- * The synchronous body of `initializeUserCredits`.
133
- *
134
- * Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
135
- * its critical section, and an `await` between the payment-reference check
136
- * and the transaction write opens a gap another caller can run through — two
137
- * simultaneous first deliveries of the same reference would then both credit.
138
- */
139
- private seedUser;
140
- updateUserCredits(userId: string, updates: CreditBalanceUpdate): Promise<void>;
141
- updateUserTier(userId: string, input: TierUpdateInput): Promise<void>;
142
- createReservation(input: CreateReservationInput): Promise<PortableReservation>;
143
- getReservation(userId: string, reservationId: string): Promise<PortableReservation | null>;
144
- updateReservationStatus(userId: string, reservationId: string, status: ReservationStatus, completedAt?: Date): Promise<void>;
145
- reserveCreditsV2(input: ReserveCreditsV2Input): Promise<ReserveOutcome>;
146
- commitReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<CommitOutcome>;
147
- releaseReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<ReleaseOutcome>;
148
- expireReservationV2(userId: string, reservationId: string, options?: ExpireReservationV2Options): Promise<ExpireOutcome>;
149
- reserveCreditsAtomic(userId: string, amount: number, operationType: string, expiresAt: Date): Promise<PortableReservation>;
150
- commitReservationAtomic(userId: string, reservationId: string): Promise<void>;
151
- releaseReservationAtomic(userId: string, reservationId: string): Promise<void>;
152
- /**
153
- * The stored transaction carrying this reference, in *any* user's ledger.
154
- *
155
- * Deliberately not scoped to one user. The SQL adapter's unique index covers
156
- * `payment_ref` alone, so searching only the crediting user's own history made
157
- * the same reference credit two different accounts here while the SQL adapter
158
- * treated the second delivery as a duplicate. See `core/payment-ref.ts`.
159
- */
160
- private findByPaymentRef;
161
- /** The journal entry written alongside a stored purchase, if there is one. */
162
- private journalForTransaction;
163
- /**
164
- * Credit an account, resolving `paymentRef` to created / replayed / conflict.
165
- *
166
- * The reference is checked and the transaction is recorded with no `await`
167
- * between them. `createTransaction` is `async` but its body is synchronous, so
168
- * under JavaScript's run-to-completion semantics no other caller can observe
169
- * the gap — which is what stands in for the SQL adapter's unique index.
170
- *
171
- * A `replayed` or `conflict` outcome writes nothing at all: the check runs
172
- * before the balance moves, not after.
173
- */
174
- addCreditsV2(input: AddCreditsV2Input): Promise<AddCreditsOutcome>;
175
- /**
176
- * The legacy signature, on top of {@link addCreditsV2}.
177
- *
178
- * `Promise<void>` has nowhere to report a conflict, and silently returning is
179
- * indistinguishable from "credited" — the exact failure this round is closing
180
- * — so a conflict throws. A genuine replay still returns quietly, because
181
- * that *is* the idempotent no-op the caller asked for.
182
- */
183
- addCreditsAtomic(userId: string, amount: number, description: string, paymentRef?: string, options?: AddCreditsAtomicOptions): Promise<void>;
184
- deductCreditsAtomic(userId: string, amount: number, journal?: DeductJournalInput): Promise<{
185
- previousBalance: number;
186
- newBalance: number;
187
- journalEntryId?: string;
188
- }>;
189
- createTransaction(input: CreateTransactionInput): Promise<PortableTransaction>;
190
- /** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
191
- private recordTransaction;
192
- getTransactions(userId: string, limit?: number, offset?: number): Promise<PortableTransaction[]>;
193
- logUsage(input: CreateUsageLogInput): Promise<PortableUsageLog>;
194
- getUsageLogs(query: UsageLogQuery): Promise<PortableUsageLog[]>;
195
- getUsageLogsCount(query: Omit<UsageLogQuery, "limit" | "offset">): Promise<number>;
196
- createJournalEntry(input: CreateJournalEntryInput): Promise<PortableJournalEntry>;
197
- /**
198
- * The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
199
- * write its journal line inside the same run-to-completion step as the
200
- * balance write — an `await` between the two is exactly the gap that loses
201
- * the line when the second half fails.
202
- */
203
- private recordJournalEntry;
204
- getJournalEntries(query: JournalEntryQuery): Promise<PortableJournalEntry[]>;
205
- getJournalEntriesCount(query: Omit<JournalEntryQuery, "limit" | "offset">): Promise<number>;
206
- /**
207
- * Expire every hold whose deadline has passed.
208
- *
209
- * Each candidate goes through the guarded {@link expireReservationV2}, which
210
- * re-checks the status and the deadline under the user's lock. The previous
211
- * implementation released the hold and then overwrote the status to
212
- * `expired`, so a commit landing in between got its credits handed back and
213
- * was then relabelled.
214
- */
215
- findAndExpireReservations(_batchSize?: number, _maxIterations?: number): Promise<{
216
- expiredCount: number;
217
- creditsReleased: number;
218
- errors: string[];
219
- }>;
220
- /**
221
- * @param _tier - Not trusted. The caller read it earlier and the account may
222
- * have changed tier since, so the reset uses the tier stored on the account.
223
- */
224
- atomicMonthlyReset(userId: string, _tier: SubscriptionTier, expectedResetAt: Date | string): Promise<MonthlyResetResult>;
225
- checkAndHandleSubscriptionExpiry(userId: string, gracePeriodDays?: number): Promise<SubscriptionExpiryResult>;
226
- /**
227
- * Clear all data (useful for testing)
228
- */
229
- clear(): void;
230
- /**
231
- * Install a yield point inside every V2 critical section (testing only).
232
- *
233
- * Unset, the critical sections run start-to-finish synchronously, so
234
- * concurrent callers never interleave and a concurrency test would pass even
235
- * with the locking removed. Setting this to a real yield (a macrotask or a
236
- * barrier) makes callers genuinely overlap, so the tests exercise the lock
237
- * instead of the event loop. Never call this in production code.
238
- */
239
- setSchedulingHook(hook: (() => Promise<void>) | undefined): void;
240
- /**
241
- * Get all users (useful for testing/debugging)
242
- */
243
- getAllUsers(): PortableUserCredits[];
244
- /**
245
- * Get all reservations for a user (useful for testing)
246
- */
247
- getAllReservations(userId: string): PortableReservation[];
248
- }
249
- /**
250
- * Create a new in-memory repository instance
251
- * Each call creates a fresh, isolated instance
252
- */
253
- declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
254
-
255
- export { AddCreditsAtomicOptions, AddCreditsV2Input, type BalanceValidationResult, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, DeductJournalInput, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, assertValidBalanceUpdate, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate, validateBalanceUpdate };
1
+ export { A as AddCreditsAtomicOptions, b as AddCreditsV2Input, c as CreateJournalEntryInput, d as CreateReservationInput, e as CreateTransactionInput, f as CreateUsageLogInput, g as CreditBalanceUpdate, i as CreditRepositoryFactory, D as DeductJournalInput, j as ExpireReservationV2Options, I as ICreditRepository, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, J as JournalEntryQuery, m as ReservationTransitionOptions, n as ReserveCreditsV2Input, p as TierUpdateInput, U as UsageLogQuery, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from '../types-YLAB8bFk.cjs';
2
+ export { B as BalanceValidationResult, I as InMemoryCreditRepository, a as addCreditsThroughRepository, b as assertValidBalanceUpdate, c as commitThroughRepository, d as createInMemoryCreditRepository, g as generateId, e as getNextMonthlyReset, r as releaseThroughRepository, f as reserveThroughRepository, h as toDate, v as validateBalanceUpdate } from '../index-BrogG0Gc.cjs';
3
+ import '../types-C3sxDvNb.cjs';
@@ -1,255 +1,3 @@
1
- import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, m as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, n as ReserveCreditsV2Input, o as ReserveOutcome, g as CreditBalanceUpdate, h as CreditPolicyOptions, p as TierUpdateInput, d as CreateReservationInput, j as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, D as DeductJournalInput, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-CY-oHIMA.js';
2
- export { i as CreditRepositoryFactory, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from '../types-CY-oHIMA.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-C3sxDvNb.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>;
58
-
59
- /**
60
- * Shared utilities for credit repository implementations
61
- *
62
- * These are pure functions with no server-only dependencies,
63
- * making them safe to use in any repository implementation.
64
- */
65
-
66
- /**
67
- * Generate a unique ID
68
- * Uses timestamp + random string for uniqueness
69
- */
70
- declare function generateId(): string;
71
- /**
72
- * Convert date-like value to Date object
73
- * Handles: Date, string, Firestore Timestamp
74
- */
75
- declare function toDate(value: Date | string | unknown): Date;
76
- /**
77
- * Get the first day of next month
78
- * Used for calculating monthly reset dates
79
- */
80
- declare function getNextMonthlyReset(from?: Date): Date;
81
- /** Result of validating a balance update. */
82
- interface BalanceValidationResult {
83
- valid: boolean;
84
- errors: string[];
85
- }
86
- /**
87
- * Whether an update leaves a valid account: no negative field, and `reserved` covered
88
- * by `balance + bonusCredits`, which is the floor outstanding holds put under the
89
- * balance. An increment is applied on top of the absolute value when a call names both.
90
- *
91
- * An update is a delta the caller asked for, so one that cannot be honoured is refused,
92
- * as a deduct is. Only tier writes and resets stop at the floor: they state a target.
93
- *
94
- * A negative balance increment is a charge, so it draws only on the balance above
95
- * `expiredHoldBacking`: those credits are expired and leave when their hold closes, so
96
- * charging them would hand the charge back. An absolute balance replaces the field and
97
- * the marks with it, so only an increment on the stored balance meets this limit.
98
- */
99
- declare function validateBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): BalanceValidationResult;
100
- /** Refuse an update {@link validateBalanceUpdate} finds invalid. */
101
- declare function assertValidBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): void;
102
-
103
- /**
104
- * In-Memory Credit Repository Implementation
105
- *
106
- * A database-agnostic implementation of ICreditRepository for testing and prototyping.
107
- * All data is stored in memory and lost when the process restarts.
108
- *
109
- * Usage:
110
- * - Unit tests without database dependency
111
- * - Local development and prototyping
112
- * - Reference implementation for custom repositories
113
- */
114
-
115
- declare class InMemoryCreditRepository implements ICreditRepository {
116
- private readonly store;
117
- private readonly options;
118
- constructor(options?: CreditPolicyOptions);
119
- /**
120
- * A copy, never the live record.
121
- *
122
- * Handing out the stored object made this adapter behave unlike any real
123
- * one: a caller holding a "before" snapshot saw it mutate under them. The
124
- * service's monthly reset compared `resetResult.credits.balance` against the
125
- * balance it had read moments earlier, got zero because both were the same
126
- * object, and silently skipped the reset's journal entry — while the SQL
127
- * adapter, which maps each row into a fresh object, wrote it.
128
- */
129
- getUserCredits(userId: string): Promise<PortableUserCredits | null>;
130
- initializeUserCredits(userId: string, tier: SubscriptionTier, initialBalance: number): Promise<PortableUserCredits>;
131
- /**
132
- * The synchronous body of `initializeUserCredits`.
133
- *
134
- * Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
135
- * its critical section, and an `await` between the payment-reference check
136
- * and the transaction write opens a gap another caller can run through — two
137
- * simultaneous first deliveries of the same reference would then both credit.
138
- */
139
- private seedUser;
140
- updateUserCredits(userId: string, updates: CreditBalanceUpdate): Promise<void>;
141
- updateUserTier(userId: string, input: TierUpdateInput): Promise<void>;
142
- createReservation(input: CreateReservationInput): Promise<PortableReservation>;
143
- getReservation(userId: string, reservationId: string): Promise<PortableReservation | null>;
144
- updateReservationStatus(userId: string, reservationId: string, status: ReservationStatus, completedAt?: Date): Promise<void>;
145
- reserveCreditsV2(input: ReserveCreditsV2Input): Promise<ReserveOutcome>;
146
- commitReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<CommitOutcome>;
147
- releaseReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<ReleaseOutcome>;
148
- expireReservationV2(userId: string, reservationId: string, options?: ExpireReservationV2Options): Promise<ExpireOutcome>;
149
- reserveCreditsAtomic(userId: string, amount: number, operationType: string, expiresAt: Date): Promise<PortableReservation>;
150
- commitReservationAtomic(userId: string, reservationId: string): Promise<void>;
151
- releaseReservationAtomic(userId: string, reservationId: string): Promise<void>;
152
- /**
153
- * The stored transaction carrying this reference, in *any* user's ledger.
154
- *
155
- * Deliberately not scoped to one user. The SQL adapter's unique index covers
156
- * `payment_ref` alone, so searching only the crediting user's own history made
157
- * the same reference credit two different accounts here while the SQL adapter
158
- * treated the second delivery as a duplicate. See `core/payment-ref.ts`.
159
- */
160
- private findByPaymentRef;
161
- /** The journal entry written alongside a stored purchase, if there is one. */
162
- private journalForTransaction;
163
- /**
164
- * Credit an account, resolving `paymentRef` to created / replayed / conflict.
165
- *
166
- * The reference is checked and the transaction is recorded with no `await`
167
- * between them. `createTransaction` is `async` but its body is synchronous, so
168
- * under JavaScript's run-to-completion semantics no other caller can observe
169
- * the gap — which is what stands in for the SQL adapter's unique index.
170
- *
171
- * A `replayed` or `conflict` outcome writes nothing at all: the check runs
172
- * before the balance moves, not after.
173
- */
174
- addCreditsV2(input: AddCreditsV2Input): Promise<AddCreditsOutcome>;
175
- /**
176
- * The legacy signature, on top of {@link addCreditsV2}.
177
- *
178
- * `Promise<void>` has nowhere to report a conflict, and silently returning is
179
- * indistinguishable from "credited" — the exact failure this round is closing
180
- * — so a conflict throws. A genuine replay still returns quietly, because
181
- * that *is* the idempotent no-op the caller asked for.
182
- */
183
- addCreditsAtomic(userId: string, amount: number, description: string, paymentRef?: string, options?: AddCreditsAtomicOptions): Promise<void>;
184
- deductCreditsAtomic(userId: string, amount: number, journal?: DeductJournalInput): Promise<{
185
- previousBalance: number;
186
- newBalance: number;
187
- journalEntryId?: string;
188
- }>;
189
- createTransaction(input: CreateTransactionInput): Promise<PortableTransaction>;
190
- /** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
191
- private recordTransaction;
192
- getTransactions(userId: string, limit?: number, offset?: number): Promise<PortableTransaction[]>;
193
- logUsage(input: CreateUsageLogInput): Promise<PortableUsageLog>;
194
- getUsageLogs(query: UsageLogQuery): Promise<PortableUsageLog[]>;
195
- getUsageLogsCount(query: Omit<UsageLogQuery, "limit" | "offset">): Promise<number>;
196
- createJournalEntry(input: CreateJournalEntryInput): Promise<PortableJournalEntry>;
197
- /**
198
- * The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
199
- * write its journal line inside the same run-to-completion step as the
200
- * balance write — an `await` between the two is exactly the gap that loses
201
- * the line when the second half fails.
202
- */
203
- private recordJournalEntry;
204
- getJournalEntries(query: JournalEntryQuery): Promise<PortableJournalEntry[]>;
205
- getJournalEntriesCount(query: Omit<JournalEntryQuery, "limit" | "offset">): Promise<number>;
206
- /**
207
- * Expire every hold whose deadline has passed.
208
- *
209
- * Each candidate goes through the guarded {@link expireReservationV2}, which
210
- * re-checks the status and the deadline under the user's lock. The previous
211
- * implementation released the hold and then overwrote the status to
212
- * `expired`, so a commit landing in between got its credits handed back and
213
- * was then relabelled.
214
- */
215
- findAndExpireReservations(_batchSize?: number, _maxIterations?: number): Promise<{
216
- expiredCount: number;
217
- creditsReleased: number;
218
- errors: string[];
219
- }>;
220
- /**
221
- * @param _tier - Not trusted. The caller read it earlier and the account may
222
- * have changed tier since, so the reset uses the tier stored on the account.
223
- */
224
- atomicMonthlyReset(userId: string, _tier: SubscriptionTier, expectedResetAt: Date | string): Promise<MonthlyResetResult>;
225
- checkAndHandleSubscriptionExpiry(userId: string, gracePeriodDays?: number): Promise<SubscriptionExpiryResult>;
226
- /**
227
- * Clear all data (useful for testing)
228
- */
229
- clear(): void;
230
- /**
231
- * Install a yield point inside every V2 critical section (testing only).
232
- *
233
- * Unset, the critical sections run start-to-finish synchronously, so
234
- * concurrent callers never interleave and a concurrency test would pass even
235
- * with the locking removed. Setting this to a real yield (a macrotask or a
236
- * barrier) makes callers genuinely overlap, so the tests exercise the lock
237
- * instead of the event loop. Never call this in production code.
238
- */
239
- setSchedulingHook(hook: (() => Promise<void>) | undefined): void;
240
- /**
241
- * Get all users (useful for testing/debugging)
242
- */
243
- getAllUsers(): PortableUserCredits[];
244
- /**
245
- * Get all reservations for a user (useful for testing)
246
- */
247
- getAllReservations(userId: string): PortableReservation[];
248
- }
249
- /**
250
- * Create a new in-memory repository instance
251
- * Each call creates a fresh, isolated instance
252
- */
253
- declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
254
-
255
- export { AddCreditsAtomicOptions, AddCreditsV2Input, type BalanceValidationResult, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, DeductJournalInput, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, assertValidBalanceUpdate, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate, validateBalanceUpdate };
1
+ export { A as AddCreditsAtomicOptions, b as AddCreditsV2Input, c as CreateJournalEntryInput, d as CreateReservationInput, e as CreateTransactionInput, f as CreateUsageLogInput, g as CreditBalanceUpdate, i as CreditRepositoryFactory, D as DeductJournalInput, j as ExpireReservationV2Options, I as ICreditRepository, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, J as JournalEntryQuery, m as ReservationTransitionOptions, n as ReserveCreditsV2Input, p as TierUpdateInput, U as UsageLogQuery, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from '../types-BL3YqxUD.js';
2
+ export { B as BalanceValidationResult, I as InMemoryCreditRepository, a as addCreditsThroughRepository, b as assertValidBalanceUpdate, c as commitThroughRepository, d as createInMemoryCreditRepository, g as generateId, e as getNextMonthlyReset, r as releaseThroughRepository, f as reserveThroughRepository, h as toDate, v as validateBalanceUpdate } from '../index-DOvJES2m.js';
3
+ import '../types-C3sxDvNb.js';
@@ -591,17 +591,25 @@ var DEFAULT_CONFIG = {
591
591
  }
592
592
  };
593
593
  var currentConfig = DEFAULT_CONFIG;
594
+ function isOperationCostMap(value) {
595
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
596
+ return Object.values(value).every((cost) => isRepresentableAmount(cost) && cost >= 0);
597
+ }
594
598
  function loadConfigFromEnv() {
595
599
  const envConfig = {};
596
600
  const operationCostsEnv = process.env.CREDITS_OPERATION_COSTS;
597
601
  if (operationCostsEnv) {
598
602
  try {
599
603
  const parsed = JSON.parse(operationCostsEnv);
600
- if (typeof parsed === "object" && parsed !== null) {
604
+ if (isOperationCostMap(parsed)) {
601
605
  envConfig.operationCosts = {
602
606
  ...DEFAULT_CONFIG.operationCosts,
603
607
  ...parsed
604
608
  };
609
+ } else {
610
+ console.warn(
611
+ "[Credits Config] Ignoring CREDITS_OPERATION_COSTS: it must be a JSON object whose costs are numbers >= 0, exact to the cent"
612
+ );
605
613
  }
606
614
  } catch {
607
615
  console.warn("[Credits Config] Failed to parse CREDITS_OPERATION_COSTS env var as JSON");
@@ -946,6 +954,50 @@ function assertValidBalanceUpdate(current, updates) {
946
954
  throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
947
955
  }
948
956
  }
957
+ function signupGrantJournalInput(userId, tier, grant) {
958
+ if (grant <= 0) return null;
959
+ return {
960
+ userId,
961
+ entryType: "credit",
962
+ amount: grant,
963
+ balanceBefore: 0,
964
+ balanceAfter: grant,
965
+ source: "bonus",
966
+ referenceId: `signup-${userId}`,
967
+ referenceType: "adjustment",
968
+ description: `Signup grant for ${tier} tier.`,
969
+ metadata: { tier }
970
+ };
971
+ }
972
+ function tierChangeJournalInput(input) {
973
+ const { userId, previousTier, newTier, previousBalance, newBalance, bonusCredits } = input;
974
+ const change = sumAmounts(newBalance, -previousBalance);
975
+ if (change === 0) return null;
976
+ const raised = change > 0;
977
+ const amount = raised ? newBalance : 0;
978
+ const balanceBefore = sumAmounts(previousBalance, bonusCredits);
979
+ const balanceAfter = sumAmounts(newBalance, bonusCredits);
980
+ assertRepresentableFields(
981
+ {
982
+ "journal amount": amount,
983
+ "journal balanceBefore": balanceBefore,
984
+ "journal balanceAfter": balanceAfter
985
+ },
986
+ { userId, operation: "updateUserTier" }
987
+ );
988
+ return {
989
+ userId,
990
+ entryType: raised ? "credit" : "debit",
991
+ amount,
992
+ balanceBefore,
993
+ balanceAfter,
994
+ source: raised ? "subscription_upgrade" : "subscription_downgrade",
995
+ referenceId: `tier-${Date.now()}`,
996
+ referenceType: "subscription",
997
+ description: `Tier changed from ${previousTier} to ${newTier}.`,
998
+ metadata: { previousTier, newTier, previousBalance, newBalance }
999
+ };
1000
+ }
949
1001
 
950
1002
  // src/repository/memory/snapshot.ts
951
1003
  function copyRecord(record) {
@@ -1590,6 +1642,8 @@ var InMemoryCreditRepository = class {
1590
1642
  updatedAt: now
1591
1643
  };
1592
1644
  this.store.users.set(userId, credits);
1645
+ const grantLine = signupGrantJournalInput(userId, tier, initialBalance);
1646
+ if (grantLine) this.recordJournalEntry(grantLine);
1593
1647
  return credits;
1594
1648
  }
1595
1649
  async updateUserCredits(userId, updates) {
@@ -1670,21 +1724,29 @@ var InMemoryCreditRepository = class {
1670
1724
  if (!credits) {
1671
1725
  throw new Error(`User ${userId} not found`);
1672
1726
  }
1673
- if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1674
- const capped = planBalanceCap(
1727
+ let planned;
1728
+ if (input.balance !== void 0) {
1729
+ planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1730
+ } else if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
1731
+ planned = planBalanceCap(
1675
1732
  credits.balance,
1676
1733
  input.balanceCap,
1677
1734
  credits.reserved,
1678
1735
  credits.bonusCredits,
1679
1736
  credits.expiredHoldBacking
1680
1737
  );
1681
- credits.balance = capped.balance;
1682
- writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
1683
1738
  }
1739
+ const journalLine = planned ? tierChangeJournalInput({
1740
+ userId,
1741
+ previousTier: credits.tier,
1742
+ newTier: input.tier,
1743
+ previousBalance: credits.balance,
1744
+ newBalance: planned.balance,
1745
+ bonusCredits: credits.bonusCredits
1746
+ }) : null;
1684
1747
  credits.tier = input.tier;
1685
1748
  credits.monthlyLimit = input.monthlyLimit;
1686
- if (input.balance !== void 0) {
1687
- const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
1749
+ if (planned) {
1688
1750
  credits.balance = planned.balance;
1689
1751
  writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
1690
1752
  }
@@ -1703,6 +1765,7 @@ var InMemoryCreditRepository = class {
1703
1765
  }
1704
1766
  credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1705
1767
  this.store.users.set(userId, credits);
1768
+ if (journalLine) this.recordJournalEntry(journalLine);
1706
1769
  }
1707
1770
  // ==================== Reservations ====================
1708
1771
  async createReservation(input) {
@@ -2324,7 +2387,7 @@ var InMemoryCreditRepository = class {
2324
2387
  userId,
2325
2388
  entryType: "debit",
2326
2389
  amount: 0,
2327
- // No credits deducted, just tier change
2390
+ // 0, however far the cap lowered the balance: the totals record the move
2328
2391
  balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
2329
2392
  balanceAfter,
2330
2393
  source: "subscription_downgrade",