@nehorai/credits 2.2.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.
- package/dist/adapters/index.cjs +169 -155
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.d.cts +2 -2
- package/dist/adapters/index.d.ts +2 -2
- package/dist/adapters/index.js +169 -155
- package/dist/adapters/index.js.map +1 -1
- package/dist/core/index.cjs +148 -24
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +113 -22
- package/dist/core/index.d.ts +113 -22
- package/dist/core/index.js +137 -22
- package/dist/core/index.js.map +1 -1
- package/dist/index-BrogG0Gc.d.cts +277 -0
- package/dist/index-DOvJES2m.d.ts +277 -0
- package/dist/index.cjs +324 -71
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +311 -69
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +254 -49
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +3 -254
- package/dist/repository/index.d.ts +3 -254
- package/dist/repository/index.js +254 -49
- package/dist/repository/index.js.map +1 -1
- package/dist/sdk/index.cjs +1 -0
- package/dist/sdk/index.cjs.map +1 -1
- package/dist/sdk/index.d.cts +2 -2
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/index.js.map +1 -1
- package/dist/service/index.cjs +42 -19
- package/dist/service/index.cjs.map +1 -1
- package/dist/service/index.d.cts +2 -2
- package/dist/service/index.d.ts +2 -2
- package/dist/service/index.js +42 -19
- package/dist/service/index.js.map +1 -1
- package/dist/{types-3c-IAwZI.d.ts → types-BL3YqxUD.d.ts} +28 -6
- package/dist/{types-MaViK5tD.d.cts → types-C3sxDvNb.d.cts} +29 -6
- package/dist/{types-MaViK5tD.d.ts → types-C3sxDvNb.d.ts} +29 -6
- package/dist/{types-XSjNXO5l.d.cts → types-YLAB8bFk.d.cts} +28 -6
- package/package.json +1 -1
|
@@ -0,0 +1,277 @@
|
|
|
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, c as CreateJournalEntryInput, 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, J as JournalEntryQuery } from './types-YLAB8bFk.cjs';
|
|
2
|
+
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';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One reservation flow for both repository generations.
|
|
6
|
+
*
|
|
7
|
+
* Adapters that implement the V2 boundary get the idempotent, race-safe path
|
|
8
|
+
* and own their journal entry. Adapters that do not (Firestore, custom
|
|
9
|
+
* implementations) keep the legacy read-then-write path, with the service
|
|
10
|
+
* writing the journal exactly as it always did — so upgrading this package
|
|
11
|
+
* does not change their behaviour, and does not double-write anyone's ledger.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reserve credits through V2 when available.
|
|
16
|
+
*
|
|
17
|
+
* A legacy adapter cannot honour an idempotency key — it has no unique index
|
|
18
|
+
* to enforce it — so asking for one there is a caller error rather than a
|
|
19
|
+
* silently ignored option.
|
|
20
|
+
*/
|
|
21
|
+
declare function reserveThroughRepository(repository: ICreditRepository, input: ReserveCreditsV2Input): Promise<ReserveOutcome>;
|
|
22
|
+
/**
|
|
23
|
+
* Commit through V2 when available, else the legacy read-then-write path.
|
|
24
|
+
*
|
|
25
|
+
* **The legacy path cannot promise a single winner.** It reads the status and
|
|
26
|
+
* then writes, with no lock and no compare-and-set between the two, so two
|
|
27
|
+
* concurrent commits of the same reservation can both observe `reserved` and
|
|
28
|
+
* both proceed. The typed outcome still describes what *this* call saw, but on
|
|
29
|
+
* a legacy adapter `committed` means "this call did the work", not "this call
|
|
30
|
+
* was the only one that did". Callers needing exactly-once must use a
|
|
31
|
+
* repository for which `supportsCreditsV2` returns `true`.
|
|
32
|
+
*/
|
|
33
|
+
declare function commitThroughRepository(repository: ICreditRepository, userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<CommitOutcome>;
|
|
34
|
+
/**
|
|
35
|
+
* Release through V2 when available, else the legacy read-then-write path.
|
|
36
|
+
*
|
|
37
|
+
* Same caveat as {@link commitThroughRepository}: on a legacy adapter the
|
|
38
|
+
* read-then-write sequence is unguarded, so `released` does not exclude a
|
|
39
|
+
* concurrent commit having also run. Only V2 repositories serialise the two.
|
|
40
|
+
*/
|
|
41
|
+
declare function releaseThroughRepository(repository: ICreditRepository, userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<ReleaseOutcome>;
|
|
42
|
+
/**
|
|
43
|
+
* Credit an account through the idempotent path when the adapter has one.
|
|
44
|
+
*
|
|
45
|
+
* A legacy adapter cannot resolve `created` / `replayed` / `conflict`: it has
|
|
46
|
+
* no global unique index over `payment_ref`, so it cannot tell a re-delivered
|
|
47
|
+
* webhook from a second, genuinely different payment that reuses a reference.
|
|
48
|
+
* Rather than invent an outcome, this reports `created` — which is what the
|
|
49
|
+
* legacy method actually did — and the ambiguity is documented on the return
|
|
50
|
+
* type instead of hidden inside it.
|
|
51
|
+
*
|
|
52
|
+
* The reference is normalised here as well as in the adapters, so a legacy
|
|
53
|
+
* adapter that stores the raw string does not persist a whitespace-only
|
|
54
|
+
* reference that would then deduplicate nothing.
|
|
55
|
+
*/
|
|
56
|
+
declare function addCreditsThroughRepository(repository: ICreditRepository, input: AddCreditsV2Input): Promise<AddCreditsOutcome>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Shared utilities for credit repository implementations
|
|
60
|
+
*
|
|
61
|
+
* These are pure functions with no server-only dependencies,
|
|
62
|
+
* making them safe to use in any repository implementation.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Generate a unique ID
|
|
67
|
+
* Uses timestamp + random string for uniqueness
|
|
68
|
+
*/
|
|
69
|
+
declare function generateId(): string;
|
|
70
|
+
/**
|
|
71
|
+
* Convert date-like value to Date object
|
|
72
|
+
* Handles: Date, string, Firestore Timestamp
|
|
73
|
+
*/
|
|
74
|
+
declare function toDate(value: Date | string | unknown): Date;
|
|
75
|
+
/**
|
|
76
|
+
* Get the first day of next month
|
|
77
|
+
* Used for calculating monthly reset dates
|
|
78
|
+
*/
|
|
79
|
+
declare function getNextMonthlyReset(from?: Date): Date;
|
|
80
|
+
/** Result of validating a balance update. */
|
|
81
|
+
interface BalanceValidationResult {
|
|
82
|
+
valid: boolean;
|
|
83
|
+
errors: string[];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether an update leaves a valid account: no negative field, and `reserved` covered
|
|
87
|
+
* by `balance + bonusCredits`, which is the floor outstanding holds put under the
|
|
88
|
+
* balance. An increment is applied on top of the absolute value when a call names both.
|
|
89
|
+
*
|
|
90
|
+
* An update is a delta the caller asked for, so one that cannot be honoured is refused,
|
|
91
|
+
* as a deduct is. Only tier writes and resets stop at the floor: they state a target.
|
|
92
|
+
*
|
|
93
|
+
* A negative balance increment is a charge, so it draws only on the balance above
|
|
94
|
+
* `expiredHoldBacking`: those credits are expired and leave when their hold closes, so
|
|
95
|
+
* charging them would hand the charge back. An absolute balance replaces the field and
|
|
96
|
+
* the marks with it, so only an increment on the stored balance meets this limit.
|
|
97
|
+
*/
|
|
98
|
+
declare function validateBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): BalanceValidationResult;
|
|
99
|
+
/** Refuse an update {@link validateBalanceUpdate} finds invalid. */
|
|
100
|
+
declare function assertValidBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): void;
|
|
101
|
+
/**
|
|
102
|
+
* The journal line of a new account's starting balance, or null when it starts at 0.
|
|
103
|
+
* Write it in the step that creates the account: that step creates it once, so the
|
|
104
|
+
* line needs no guard of its own.
|
|
105
|
+
*/
|
|
106
|
+
declare function signupGrantJournalInput(userId: string, tier: SubscriptionTier, grant: number): CreateJournalEntryInput | null;
|
|
107
|
+
/**
|
|
108
|
+
* The journal line of a tier write that moved the balance, or null when it did not.
|
|
109
|
+
* Call it before writing anything: it refuses a total the ledger cannot record.
|
|
110
|
+
*
|
|
111
|
+
* `amount` follows the lines this one sits beside, not the difference between the
|
|
112
|
+
* totals: a raise records the balance it set, like `subscription_grant`, and a cut
|
|
113
|
+
* records 0, like the expiry `subscription_downgrade`. `balanceBefore` and
|
|
114
|
+
* `balanceAfter` say how far the balance moved.
|
|
115
|
+
*/
|
|
116
|
+
declare function tierChangeJournalInput(input: {
|
|
117
|
+
userId: string;
|
|
118
|
+
previousTier: SubscriptionTier;
|
|
119
|
+
newTier: SubscriptionTier;
|
|
120
|
+
previousBalance: number;
|
|
121
|
+
newBalance: number;
|
|
122
|
+
bonusCredits: number;
|
|
123
|
+
}): CreateJournalEntryInput | null;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* In-Memory Credit Repository Implementation
|
|
127
|
+
*
|
|
128
|
+
* A database-agnostic implementation of ICreditRepository for testing and prototyping.
|
|
129
|
+
* All data is stored in memory and lost when the process restarts.
|
|
130
|
+
*
|
|
131
|
+
* Usage:
|
|
132
|
+
* - Unit tests without database dependency
|
|
133
|
+
* - Local development and prototyping
|
|
134
|
+
* - Reference implementation for custom repositories
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
declare class InMemoryCreditRepository implements ICreditRepository {
|
|
138
|
+
private readonly store;
|
|
139
|
+
private readonly options;
|
|
140
|
+
constructor(options?: CreditPolicyOptions);
|
|
141
|
+
/**
|
|
142
|
+
* A copy, never the live record.
|
|
143
|
+
*
|
|
144
|
+
* Handing out the stored object made this adapter behave unlike any real
|
|
145
|
+
* one: a caller holding a "before" snapshot saw it mutate under them. The
|
|
146
|
+
* service's monthly reset compared `resetResult.credits.balance` against the
|
|
147
|
+
* balance it had read moments earlier, got zero because both were the same
|
|
148
|
+
* object, and silently skipped the reset's journal entry — while the SQL
|
|
149
|
+
* adapter, which maps each row into a fresh object, wrote it.
|
|
150
|
+
*/
|
|
151
|
+
getUserCredits(userId: string): Promise<PortableUserCredits | null>;
|
|
152
|
+
initializeUserCredits(userId: string, tier: SubscriptionTier, initialBalance: number): Promise<PortableUserCredits>;
|
|
153
|
+
/**
|
|
154
|
+
* The synchronous body of `initializeUserCredits`.
|
|
155
|
+
*
|
|
156
|
+
* Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
|
|
157
|
+
* its critical section, and an `await` between the payment-reference check
|
|
158
|
+
* and the transaction write opens a gap another caller can run through — two
|
|
159
|
+
* simultaneous first deliveries of the same reference would then both credit.
|
|
160
|
+
*/
|
|
161
|
+
private seedUser;
|
|
162
|
+
updateUserCredits(userId: string, updates: CreditBalanceUpdate): Promise<void>;
|
|
163
|
+
updateUserTier(userId: string, input: TierUpdateInput): Promise<void>;
|
|
164
|
+
createReservation(input: CreateReservationInput): Promise<PortableReservation>;
|
|
165
|
+
getReservation(userId: string, reservationId: string): Promise<PortableReservation | null>;
|
|
166
|
+
updateReservationStatus(userId: string, reservationId: string, status: ReservationStatus, completedAt?: Date): Promise<void>;
|
|
167
|
+
reserveCreditsV2(input: ReserveCreditsV2Input): Promise<ReserveOutcome>;
|
|
168
|
+
commitReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<CommitOutcome>;
|
|
169
|
+
releaseReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<ReleaseOutcome>;
|
|
170
|
+
expireReservationV2(userId: string, reservationId: string, options?: ExpireReservationV2Options): Promise<ExpireOutcome>;
|
|
171
|
+
reserveCreditsAtomic(userId: string, amount: number, operationType: string, expiresAt: Date): Promise<PortableReservation>;
|
|
172
|
+
commitReservationAtomic(userId: string, reservationId: string): Promise<void>;
|
|
173
|
+
releaseReservationAtomic(userId: string, reservationId: string): Promise<void>;
|
|
174
|
+
/**
|
|
175
|
+
* The stored transaction carrying this reference, in *any* user's ledger.
|
|
176
|
+
*
|
|
177
|
+
* Deliberately not scoped to one user. The SQL adapter's unique index covers
|
|
178
|
+
* `payment_ref` alone, so searching only the crediting user's own history made
|
|
179
|
+
* the same reference credit two different accounts here while the SQL adapter
|
|
180
|
+
* treated the second delivery as a duplicate. See `core/payment-ref.ts`.
|
|
181
|
+
*/
|
|
182
|
+
private findByPaymentRef;
|
|
183
|
+
/** The journal entry written alongside a stored purchase, if there is one. */
|
|
184
|
+
private journalForTransaction;
|
|
185
|
+
/**
|
|
186
|
+
* Credit an account, resolving `paymentRef` to created / replayed / conflict.
|
|
187
|
+
*
|
|
188
|
+
* The reference is checked and the transaction is recorded with no `await`
|
|
189
|
+
* between them. `createTransaction` is `async` but its body is synchronous, so
|
|
190
|
+
* under JavaScript's run-to-completion semantics no other caller can observe
|
|
191
|
+
* the gap — which is what stands in for the SQL adapter's unique index.
|
|
192
|
+
*
|
|
193
|
+
* A `replayed` or `conflict` outcome writes nothing at all: the check runs
|
|
194
|
+
* before the balance moves, not after.
|
|
195
|
+
*/
|
|
196
|
+
addCreditsV2(input: AddCreditsV2Input): Promise<AddCreditsOutcome>;
|
|
197
|
+
/**
|
|
198
|
+
* The legacy signature, on top of {@link addCreditsV2}.
|
|
199
|
+
*
|
|
200
|
+
* `Promise<void>` has nowhere to report a conflict, and silently returning is
|
|
201
|
+
* indistinguishable from "credited" — the exact failure this round is closing
|
|
202
|
+
* — so a conflict throws. A genuine replay still returns quietly, because
|
|
203
|
+
* that *is* the idempotent no-op the caller asked for.
|
|
204
|
+
*/
|
|
205
|
+
addCreditsAtomic(userId: string, amount: number, description: string, paymentRef?: string, options?: AddCreditsAtomicOptions): Promise<void>;
|
|
206
|
+
deductCreditsAtomic(userId: string, amount: number, journal?: DeductJournalInput): Promise<{
|
|
207
|
+
previousBalance: number;
|
|
208
|
+
newBalance: number;
|
|
209
|
+
journalEntryId?: string;
|
|
210
|
+
}>;
|
|
211
|
+
createTransaction(input: CreateTransactionInput): Promise<PortableTransaction>;
|
|
212
|
+
/** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
|
|
213
|
+
private recordTransaction;
|
|
214
|
+
getTransactions(userId: string, limit?: number, offset?: number): Promise<PortableTransaction[]>;
|
|
215
|
+
logUsage(input: CreateUsageLogInput): Promise<PortableUsageLog>;
|
|
216
|
+
getUsageLogs(query: UsageLogQuery): Promise<PortableUsageLog[]>;
|
|
217
|
+
getUsageLogsCount(query: Omit<UsageLogQuery, "limit" | "offset">): Promise<number>;
|
|
218
|
+
createJournalEntry(input: CreateJournalEntryInput): Promise<PortableJournalEntry>;
|
|
219
|
+
/**
|
|
220
|
+
* The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
|
|
221
|
+
* write its journal line inside the same run-to-completion step as the
|
|
222
|
+
* balance write — an `await` between the two is exactly the gap that loses
|
|
223
|
+
* the line when the second half fails.
|
|
224
|
+
*/
|
|
225
|
+
private recordJournalEntry;
|
|
226
|
+
getJournalEntries(query: JournalEntryQuery): Promise<PortableJournalEntry[]>;
|
|
227
|
+
getJournalEntriesCount(query: Omit<JournalEntryQuery, "limit" | "offset">): Promise<number>;
|
|
228
|
+
/**
|
|
229
|
+
* Expire every hold whose deadline has passed.
|
|
230
|
+
*
|
|
231
|
+
* Each candidate goes through the guarded {@link expireReservationV2}, which
|
|
232
|
+
* re-checks the status and the deadline under the user's lock. The previous
|
|
233
|
+
* implementation released the hold and then overwrote the status to
|
|
234
|
+
* `expired`, so a commit landing in between got its credits handed back and
|
|
235
|
+
* was then relabelled.
|
|
236
|
+
*/
|
|
237
|
+
findAndExpireReservations(_batchSize?: number, _maxIterations?: number): Promise<{
|
|
238
|
+
expiredCount: number;
|
|
239
|
+
creditsReleased: number;
|
|
240
|
+
errors: string[];
|
|
241
|
+
}>;
|
|
242
|
+
/**
|
|
243
|
+
* @param _tier - Not trusted. The caller read it earlier and the account may
|
|
244
|
+
* have changed tier since, so the reset uses the tier stored on the account.
|
|
245
|
+
*/
|
|
246
|
+
atomicMonthlyReset(userId: string, _tier: SubscriptionTier, expectedResetAt: Date | string): Promise<MonthlyResetResult>;
|
|
247
|
+
checkAndHandleSubscriptionExpiry(userId: string, gracePeriodDays?: number): Promise<SubscriptionExpiryResult>;
|
|
248
|
+
/**
|
|
249
|
+
* Clear all data (useful for testing)
|
|
250
|
+
*/
|
|
251
|
+
clear(): void;
|
|
252
|
+
/**
|
|
253
|
+
* Install a yield point inside every V2 critical section (testing only).
|
|
254
|
+
*
|
|
255
|
+
* Unset, the critical sections run start-to-finish synchronously, so
|
|
256
|
+
* concurrent callers never interleave and a concurrency test would pass even
|
|
257
|
+
* with the locking removed. Setting this to a real yield (a macrotask or a
|
|
258
|
+
* barrier) makes callers genuinely overlap, so the tests exercise the lock
|
|
259
|
+
* instead of the event loop. Never call this in production code.
|
|
260
|
+
*/
|
|
261
|
+
setSchedulingHook(hook: (() => Promise<void>) | undefined): void;
|
|
262
|
+
/**
|
|
263
|
+
* Get all users (useful for testing/debugging)
|
|
264
|
+
*/
|
|
265
|
+
getAllUsers(): PortableUserCredits[];
|
|
266
|
+
/**
|
|
267
|
+
* Get all reservations for a user (useful for testing)
|
|
268
|
+
*/
|
|
269
|
+
getAllReservations(userId: string): PortableReservation[];
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Create a new in-memory repository instance
|
|
273
|
+
* Each call creates a fresh, isolated instance
|
|
274
|
+
*/
|
|
275
|
+
declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
|
|
276
|
+
|
|
277
|
+
export { type BalanceValidationResult as B, InMemoryCreditRepository as I, addCreditsThroughRepository as a, assertValidBalanceUpdate as b, commitThroughRepository as c, createInMemoryCreditRepository as d, getNextMonthlyReset as e, reserveThroughRepository as f, generateId as g, toDate as h, releaseThroughRepository as r, signupGrantJournalInput as s, tierChangeJournalInput as t, validateBalanceUpdate as v };
|
|
@@ -0,0 +1,277 @@
|
|
|
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, c as CreateJournalEntryInput, 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, J as JournalEntryQuery } from './types-BL3YqxUD.js';
|
|
2
|
+
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';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One reservation flow for both repository generations.
|
|
6
|
+
*
|
|
7
|
+
* Adapters that implement the V2 boundary get the idempotent, race-safe path
|
|
8
|
+
* and own their journal entry. Adapters that do not (Firestore, custom
|
|
9
|
+
* implementations) keep the legacy read-then-write path, with the service
|
|
10
|
+
* writing the journal exactly as it always did — so upgrading this package
|
|
11
|
+
* does not change their behaviour, and does not double-write anyone's ledger.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reserve credits through V2 when available.
|
|
16
|
+
*
|
|
17
|
+
* A legacy adapter cannot honour an idempotency key — it has no unique index
|
|
18
|
+
* to enforce it — so asking for one there is a caller error rather than a
|
|
19
|
+
* silently ignored option.
|
|
20
|
+
*/
|
|
21
|
+
declare function reserveThroughRepository(repository: ICreditRepository, input: ReserveCreditsV2Input): Promise<ReserveOutcome>;
|
|
22
|
+
/**
|
|
23
|
+
* Commit through V2 when available, else the legacy read-then-write path.
|
|
24
|
+
*
|
|
25
|
+
* **The legacy path cannot promise a single winner.** It reads the status and
|
|
26
|
+
* then writes, with no lock and no compare-and-set between the two, so two
|
|
27
|
+
* concurrent commits of the same reservation can both observe `reserved` and
|
|
28
|
+
* both proceed. The typed outcome still describes what *this* call saw, but on
|
|
29
|
+
* a legacy adapter `committed` means "this call did the work", not "this call
|
|
30
|
+
* was the only one that did". Callers needing exactly-once must use a
|
|
31
|
+
* repository for which `supportsCreditsV2` returns `true`.
|
|
32
|
+
*/
|
|
33
|
+
declare function commitThroughRepository(repository: ICreditRepository, userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<CommitOutcome>;
|
|
34
|
+
/**
|
|
35
|
+
* Release through V2 when available, else the legacy read-then-write path.
|
|
36
|
+
*
|
|
37
|
+
* Same caveat as {@link commitThroughRepository}: on a legacy adapter the
|
|
38
|
+
* read-then-write sequence is unguarded, so `released` does not exclude a
|
|
39
|
+
* concurrent commit having also run. Only V2 repositories serialise the two.
|
|
40
|
+
*/
|
|
41
|
+
declare function releaseThroughRepository(repository: ICreditRepository, userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<ReleaseOutcome>;
|
|
42
|
+
/**
|
|
43
|
+
* Credit an account through the idempotent path when the adapter has one.
|
|
44
|
+
*
|
|
45
|
+
* A legacy adapter cannot resolve `created` / `replayed` / `conflict`: it has
|
|
46
|
+
* no global unique index over `payment_ref`, so it cannot tell a re-delivered
|
|
47
|
+
* webhook from a second, genuinely different payment that reuses a reference.
|
|
48
|
+
* Rather than invent an outcome, this reports `created` — which is what the
|
|
49
|
+
* legacy method actually did — and the ambiguity is documented on the return
|
|
50
|
+
* type instead of hidden inside it.
|
|
51
|
+
*
|
|
52
|
+
* The reference is normalised here as well as in the adapters, so a legacy
|
|
53
|
+
* adapter that stores the raw string does not persist a whitespace-only
|
|
54
|
+
* reference that would then deduplicate nothing.
|
|
55
|
+
*/
|
|
56
|
+
declare function addCreditsThroughRepository(repository: ICreditRepository, input: AddCreditsV2Input): Promise<AddCreditsOutcome>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Shared utilities for credit repository implementations
|
|
60
|
+
*
|
|
61
|
+
* These are pure functions with no server-only dependencies,
|
|
62
|
+
* making them safe to use in any repository implementation.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Generate a unique ID
|
|
67
|
+
* Uses timestamp + random string for uniqueness
|
|
68
|
+
*/
|
|
69
|
+
declare function generateId(): string;
|
|
70
|
+
/**
|
|
71
|
+
* Convert date-like value to Date object
|
|
72
|
+
* Handles: Date, string, Firestore Timestamp
|
|
73
|
+
*/
|
|
74
|
+
declare function toDate(value: Date | string | unknown): Date;
|
|
75
|
+
/**
|
|
76
|
+
* Get the first day of next month
|
|
77
|
+
* Used for calculating monthly reset dates
|
|
78
|
+
*/
|
|
79
|
+
declare function getNextMonthlyReset(from?: Date): Date;
|
|
80
|
+
/** Result of validating a balance update. */
|
|
81
|
+
interface BalanceValidationResult {
|
|
82
|
+
valid: boolean;
|
|
83
|
+
errors: string[];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether an update leaves a valid account: no negative field, and `reserved` covered
|
|
87
|
+
* by `balance + bonusCredits`, which is the floor outstanding holds put under the
|
|
88
|
+
* balance. An increment is applied on top of the absolute value when a call names both.
|
|
89
|
+
*
|
|
90
|
+
* An update is a delta the caller asked for, so one that cannot be honoured is refused,
|
|
91
|
+
* as a deduct is. Only tier writes and resets stop at the floor: they state a target.
|
|
92
|
+
*
|
|
93
|
+
* A negative balance increment is a charge, so it draws only on the balance above
|
|
94
|
+
* `expiredHoldBacking`: those credits are expired and leave when their hold closes, so
|
|
95
|
+
* charging them would hand the charge back. An absolute balance replaces the field and
|
|
96
|
+
* the marks with it, so only an increment on the stored balance meets this limit.
|
|
97
|
+
*/
|
|
98
|
+
declare function validateBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): BalanceValidationResult;
|
|
99
|
+
/** Refuse an update {@link validateBalanceUpdate} finds invalid. */
|
|
100
|
+
declare function assertValidBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): void;
|
|
101
|
+
/**
|
|
102
|
+
* The journal line of a new account's starting balance, or null when it starts at 0.
|
|
103
|
+
* Write it in the step that creates the account: that step creates it once, so the
|
|
104
|
+
* line needs no guard of its own.
|
|
105
|
+
*/
|
|
106
|
+
declare function signupGrantJournalInput(userId: string, tier: SubscriptionTier, grant: number): CreateJournalEntryInput | null;
|
|
107
|
+
/**
|
|
108
|
+
* The journal line of a tier write that moved the balance, or null when it did not.
|
|
109
|
+
* Call it before writing anything: it refuses a total the ledger cannot record.
|
|
110
|
+
*
|
|
111
|
+
* `amount` follows the lines this one sits beside, not the difference between the
|
|
112
|
+
* totals: a raise records the balance it set, like `subscription_grant`, and a cut
|
|
113
|
+
* records 0, like the expiry `subscription_downgrade`. `balanceBefore` and
|
|
114
|
+
* `balanceAfter` say how far the balance moved.
|
|
115
|
+
*/
|
|
116
|
+
declare function tierChangeJournalInput(input: {
|
|
117
|
+
userId: string;
|
|
118
|
+
previousTier: SubscriptionTier;
|
|
119
|
+
newTier: SubscriptionTier;
|
|
120
|
+
previousBalance: number;
|
|
121
|
+
newBalance: number;
|
|
122
|
+
bonusCredits: number;
|
|
123
|
+
}): CreateJournalEntryInput | null;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* In-Memory Credit Repository Implementation
|
|
127
|
+
*
|
|
128
|
+
* A database-agnostic implementation of ICreditRepository for testing and prototyping.
|
|
129
|
+
* All data is stored in memory and lost when the process restarts.
|
|
130
|
+
*
|
|
131
|
+
* Usage:
|
|
132
|
+
* - Unit tests without database dependency
|
|
133
|
+
* - Local development and prototyping
|
|
134
|
+
* - Reference implementation for custom repositories
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
declare class InMemoryCreditRepository implements ICreditRepository {
|
|
138
|
+
private readonly store;
|
|
139
|
+
private readonly options;
|
|
140
|
+
constructor(options?: CreditPolicyOptions);
|
|
141
|
+
/**
|
|
142
|
+
* A copy, never the live record.
|
|
143
|
+
*
|
|
144
|
+
* Handing out the stored object made this adapter behave unlike any real
|
|
145
|
+
* one: a caller holding a "before" snapshot saw it mutate under them. The
|
|
146
|
+
* service's monthly reset compared `resetResult.credits.balance` against the
|
|
147
|
+
* balance it had read moments earlier, got zero because both were the same
|
|
148
|
+
* object, and silently skipped the reset's journal entry — while the SQL
|
|
149
|
+
* adapter, which maps each row into a fresh object, wrote it.
|
|
150
|
+
*/
|
|
151
|
+
getUserCredits(userId: string): Promise<PortableUserCredits | null>;
|
|
152
|
+
initializeUserCredits(userId: string, tier: SubscriptionTier, initialBalance: number): Promise<PortableUserCredits>;
|
|
153
|
+
/**
|
|
154
|
+
* The synchronous body of `initializeUserCredits`.
|
|
155
|
+
*
|
|
156
|
+
* Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
|
|
157
|
+
* its critical section, and an `await` between the payment-reference check
|
|
158
|
+
* and the transaction write opens a gap another caller can run through — two
|
|
159
|
+
* simultaneous first deliveries of the same reference would then both credit.
|
|
160
|
+
*/
|
|
161
|
+
private seedUser;
|
|
162
|
+
updateUserCredits(userId: string, updates: CreditBalanceUpdate): Promise<void>;
|
|
163
|
+
updateUserTier(userId: string, input: TierUpdateInput): Promise<void>;
|
|
164
|
+
createReservation(input: CreateReservationInput): Promise<PortableReservation>;
|
|
165
|
+
getReservation(userId: string, reservationId: string): Promise<PortableReservation | null>;
|
|
166
|
+
updateReservationStatus(userId: string, reservationId: string, status: ReservationStatus, completedAt?: Date): Promise<void>;
|
|
167
|
+
reserveCreditsV2(input: ReserveCreditsV2Input): Promise<ReserveOutcome>;
|
|
168
|
+
commitReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<CommitOutcome>;
|
|
169
|
+
releaseReservationV2(userId: string, reservationId: string, options?: ReservationTransitionOptions): Promise<ReleaseOutcome>;
|
|
170
|
+
expireReservationV2(userId: string, reservationId: string, options?: ExpireReservationV2Options): Promise<ExpireOutcome>;
|
|
171
|
+
reserveCreditsAtomic(userId: string, amount: number, operationType: string, expiresAt: Date): Promise<PortableReservation>;
|
|
172
|
+
commitReservationAtomic(userId: string, reservationId: string): Promise<void>;
|
|
173
|
+
releaseReservationAtomic(userId: string, reservationId: string): Promise<void>;
|
|
174
|
+
/**
|
|
175
|
+
* The stored transaction carrying this reference, in *any* user's ledger.
|
|
176
|
+
*
|
|
177
|
+
* Deliberately not scoped to one user. The SQL adapter's unique index covers
|
|
178
|
+
* `payment_ref` alone, so searching only the crediting user's own history made
|
|
179
|
+
* the same reference credit two different accounts here while the SQL adapter
|
|
180
|
+
* treated the second delivery as a duplicate. See `core/payment-ref.ts`.
|
|
181
|
+
*/
|
|
182
|
+
private findByPaymentRef;
|
|
183
|
+
/** The journal entry written alongside a stored purchase, if there is one. */
|
|
184
|
+
private journalForTransaction;
|
|
185
|
+
/**
|
|
186
|
+
* Credit an account, resolving `paymentRef` to created / replayed / conflict.
|
|
187
|
+
*
|
|
188
|
+
* The reference is checked and the transaction is recorded with no `await`
|
|
189
|
+
* between them. `createTransaction` is `async` but its body is synchronous, so
|
|
190
|
+
* under JavaScript's run-to-completion semantics no other caller can observe
|
|
191
|
+
* the gap — which is what stands in for the SQL adapter's unique index.
|
|
192
|
+
*
|
|
193
|
+
* A `replayed` or `conflict` outcome writes nothing at all: the check runs
|
|
194
|
+
* before the balance moves, not after.
|
|
195
|
+
*/
|
|
196
|
+
addCreditsV2(input: AddCreditsV2Input): Promise<AddCreditsOutcome>;
|
|
197
|
+
/**
|
|
198
|
+
* The legacy signature, on top of {@link addCreditsV2}.
|
|
199
|
+
*
|
|
200
|
+
* `Promise<void>` has nowhere to report a conflict, and silently returning is
|
|
201
|
+
* indistinguishable from "credited" — the exact failure this round is closing
|
|
202
|
+
* — so a conflict throws. A genuine replay still returns quietly, because
|
|
203
|
+
* that *is* the idempotent no-op the caller asked for.
|
|
204
|
+
*/
|
|
205
|
+
addCreditsAtomic(userId: string, amount: number, description: string, paymentRef?: string, options?: AddCreditsAtomicOptions): Promise<void>;
|
|
206
|
+
deductCreditsAtomic(userId: string, amount: number, journal?: DeductJournalInput): Promise<{
|
|
207
|
+
previousBalance: number;
|
|
208
|
+
newBalance: number;
|
|
209
|
+
journalEntryId?: string;
|
|
210
|
+
}>;
|
|
211
|
+
createTransaction(input: CreateTransactionInput): Promise<PortableTransaction>;
|
|
212
|
+
/** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
|
|
213
|
+
private recordTransaction;
|
|
214
|
+
getTransactions(userId: string, limit?: number, offset?: number): Promise<PortableTransaction[]>;
|
|
215
|
+
logUsage(input: CreateUsageLogInput): Promise<PortableUsageLog>;
|
|
216
|
+
getUsageLogs(query: UsageLogQuery): Promise<PortableUsageLog[]>;
|
|
217
|
+
getUsageLogsCount(query: Omit<UsageLogQuery, "limit" | "offset">): Promise<number>;
|
|
218
|
+
createJournalEntry(input: CreateJournalEntryInput): Promise<PortableJournalEntry>;
|
|
219
|
+
/**
|
|
220
|
+
* The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
|
|
221
|
+
* write its journal line inside the same run-to-completion step as the
|
|
222
|
+
* balance write — an `await` between the two is exactly the gap that loses
|
|
223
|
+
* the line when the second half fails.
|
|
224
|
+
*/
|
|
225
|
+
private recordJournalEntry;
|
|
226
|
+
getJournalEntries(query: JournalEntryQuery): Promise<PortableJournalEntry[]>;
|
|
227
|
+
getJournalEntriesCount(query: Omit<JournalEntryQuery, "limit" | "offset">): Promise<number>;
|
|
228
|
+
/**
|
|
229
|
+
* Expire every hold whose deadline has passed.
|
|
230
|
+
*
|
|
231
|
+
* Each candidate goes through the guarded {@link expireReservationV2}, which
|
|
232
|
+
* re-checks the status and the deadline under the user's lock. The previous
|
|
233
|
+
* implementation released the hold and then overwrote the status to
|
|
234
|
+
* `expired`, so a commit landing in between got its credits handed back and
|
|
235
|
+
* was then relabelled.
|
|
236
|
+
*/
|
|
237
|
+
findAndExpireReservations(_batchSize?: number, _maxIterations?: number): Promise<{
|
|
238
|
+
expiredCount: number;
|
|
239
|
+
creditsReleased: number;
|
|
240
|
+
errors: string[];
|
|
241
|
+
}>;
|
|
242
|
+
/**
|
|
243
|
+
* @param _tier - Not trusted. The caller read it earlier and the account may
|
|
244
|
+
* have changed tier since, so the reset uses the tier stored on the account.
|
|
245
|
+
*/
|
|
246
|
+
atomicMonthlyReset(userId: string, _tier: SubscriptionTier, expectedResetAt: Date | string): Promise<MonthlyResetResult>;
|
|
247
|
+
checkAndHandleSubscriptionExpiry(userId: string, gracePeriodDays?: number): Promise<SubscriptionExpiryResult>;
|
|
248
|
+
/**
|
|
249
|
+
* Clear all data (useful for testing)
|
|
250
|
+
*/
|
|
251
|
+
clear(): void;
|
|
252
|
+
/**
|
|
253
|
+
* Install a yield point inside every V2 critical section (testing only).
|
|
254
|
+
*
|
|
255
|
+
* Unset, the critical sections run start-to-finish synchronously, so
|
|
256
|
+
* concurrent callers never interleave and a concurrency test would pass even
|
|
257
|
+
* with the locking removed. Setting this to a real yield (a macrotask or a
|
|
258
|
+
* barrier) makes callers genuinely overlap, so the tests exercise the lock
|
|
259
|
+
* instead of the event loop. Never call this in production code.
|
|
260
|
+
*/
|
|
261
|
+
setSchedulingHook(hook: (() => Promise<void>) | undefined): void;
|
|
262
|
+
/**
|
|
263
|
+
* Get all users (useful for testing/debugging)
|
|
264
|
+
*/
|
|
265
|
+
getAllUsers(): PortableUserCredits[];
|
|
266
|
+
/**
|
|
267
|
+
* Get all reservations for a user (useful for testing)
|
|
268
|
+
*/
|
|
269
|
+
getAllReservations(userId: string): PortableReservation[];
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Create a new in-memory repository instance
|
|
273
|
+
* Each call creates a fresh, isolated instance
|
|
274
|
+
*/
|
|
275
|
+
declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
|
|
276
|
+
|
|
277
|
+
export { type BalanceValidationResult as B, InMemoryCreditRepository as I, addCreditsThroughRepository as a, assertValidBalanceUpdate as b, commitThroughRepository as c, createInMemoryCreditRepository as d, getNextMonthlyReset as e, reserveThroughRepository as f, generateId as g, toDate as h, releaseThroughRepository as r, signupGrantJournalInput as s, tierChangeJournalInput as t, validateBalanceUpdate as v };
|