@nehorai/credits 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +384 -377
- package/dist/adapters/index.cjs +10 -6
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.d.cts +3 -2
- package/dist/adapters/index.d.ts +3 -2
- package/dist/adapters/index.js +10 -6
- package/dist/adapters/index.js.map +1 -1
- package/dist/auth/index.cjs.map +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.cjs +4 -2
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +4 -4
- package/dist/core/index.d.ts +4 -4
- package/dist/core/index.js +4 -2
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +10 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -4
- package/dist/index.d.ts +6 -4
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +4 -2
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +3 -3
- package/dist/repository/index.d.ts +3 -3
- package/dist/repository/index.js +4 -2
- package/dist/repository/index.js.map +1 -1
- 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.map +1 -1
- package/dist/service/index.cjs +4 -2
- 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 +4 -2
- package/dist/service/index.js.map +1 -1
- package/dist/{types-BmG5Omzj.d.ts → types-BhCdep_t.d.ts} +1 -1
- package/dist/{types-DJ8yqaVr.d.ts → types-Dzc443vy.d.cts} +5 -1
- package/dist/{types-DJ8yqaVr.d.cts → types-Dzc443vy.d.ts} +5 -1
- package/dist/{types-C8hZMe8a.d.cts → types-wtksMa4n.d.cts} +1 -1
- package/package.json +71 -70
- package/LICENSE +0 -21
package/README.md
CHANGED
|
@@ -1,377 +1,384 @@
|
|
|
1
|
-
# @nehorai/credits
|
|
2
|
-
|
|
3
|
-
Framework-agnostic credits and billing system with two-phase commit for safe credit operations.
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
- **Two-phase commit** -- Reserve credits before executing operations, then commit or release
|
|
8
|
-
- **Journal-based audit trail** -- Every credit change is logged with source, reference, and metadata
|
|
9
|
-
- **Subscription tier management** -- Free, basic, premium, and unlimited tiers with monthly resets
|
|
10
|
-
- **Notification hooks** -- Low balance and subscription expiry notifications
|
|
11
|
-
- **REST API SDK** -- Client and admin SDK for external integrations
|
|
12
|
-
- **In-memory repository** -- Included for testing and development
|
|
13
|
-
- **Type-safe** -- Full TypeScript with portable types (no framework dependencies)
|
|
14
|
-
|
|
15
|
-
## Installation
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
pnpm add @nehorai/credits
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
## Quick Start
|
|
22
|
-
|
|
23
|
-
```typescript
|
|
24
|
-
import {
|
|
25
|
-
CreditsService,
|
|
26
|
-
createInMemoryCreditRepository,
|
|
27
|
-
} from "@nehorai/credits";
|
|
28
|
-
|
|
29
|
-
const repository = createInMemoryCreditRepository();
|
|
30
|
-
const service = new CreditsService(repository);
|
|
31
|
-
|
|
32
|
-
// Initialize a user with free tier
|
|
33
|
-
await service.initializeUserCredits("user-123");
|
|
34
|
-
|
|
35
|
-
// Check if user has enough credits
|
|
36
|
-
const check = await service.checkCredits("user-123", 10);
|
|
37
|
-
if (!check.hasCredits) {
|
|
38
|
-
console.log(`Need ${check.shortfall} more credits`);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Reserve-Execute-Commit pattern
|
|
42
|
-
const reservation = await service.reserveCredits("user-123", 10, "story_generation");
|
|
43
|
-
try {
|
|
44
|
-
await doExpensiveWork();
|
|
45
|
-
await service.commitCredits("user-123", reservation.id); // Deducts credits
|
|
46
|
-
} catch (error) {
|
|
47
|
-
await service.releaseCredits("user-123", reservation.id); // Refunds credits
|
|
48
|
-
throw error;
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
## API Reference
|
|
53
|
-
|
|
54
|
-
### `CreditsService`
|
|
55
|
-
|
|
56
|
-
The main service class. Accepts any `ICreditRepository` implementation.
|
|
57
|
-
|
|
58
|
-
| Method | Description |
|
|
59
|
-
|--------|-------------|
|
|
60
|
-
| `getUserCredits(userId)` | Get user balance (auto-handles monthly reset and subscription expiry) |
|
|
61
|
-
| `initializeUserCredits(userId)` | Create a new user with free tier |
|
|
62
|
-
| `getOrCreateUserCredits(userId)` | Get or initialize user credits |
|
|
63
|
-
| `checkCredits(userId, amount)` | Check if user has sufficient credits |
|
|
64
|
-
| `reserveCredits(userId, amount, operationType)` | Reserve credits (phase 1) |
|
|
65
|
-
| `commitCredits(userId, reservationId)` | Commit reservation (phase 2 -- success) |
|
|
66
|
-
| `releaseCredits(userId, reservationId)` | Release reservation (phase 2 -- failure). A no-op for any terminal status; use `releaseCreditsDetailed` to see a concurrent commit. |
|
|
67
|
-
| `addCredits(userId, amount, description, paymentRef?)` | Add credits (purchases, bonuses). Returns an `AddCreditsOutcome`: `created`, `replayed` or `conflict` -- see [`paymentRef`](#crediting-an-account-and-paymentref). |
|
|
68
|
-
| `updateTier(userId, tier, expiresAt?)` | Change subscription tier |
|
|
69
|
-
| `getUsageHistory(userId, limit?, offset?)` | Paginated usage history |
|
|
70
|
-
| `logUsage(log)` | Log a usage event for auditing |
|
|
71
|
-
|
|
72
|
-
### The V2 reservation boundary
|
|
73
|
-
|
|
74
|
-
The legacy `reserveCredits`/`commitCredits`/`releaseCredits` methods above still
|
|
75
|
-
work unchanged. Alongside them are `*Detailed` variants that return a **typed
|
|
76
|
-
outcome** instead of throwing, and accept a caller **idempotency key**:
|
|
77
|
-
|
|
78
|
-
```typescript
|
|
79
|
-
const outcome = await service.reserveCreditsDetailed(userId, 40, "story_generation", {
|
|
80
|
-
idempotencyKey: `job:${jobId}`,
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
switch (outcome.outcome) {
|
|
84
|
-
case "created": break; // fresh hold
|
|
85
|
-
case "replayed": break; // same key + same amount/operation: same reservation
|
|
86
|
-
case "insufficient": break; // outcome.available / .required / .shortfall
|
|
87
|
-
case "idempotency_conflict": break; // key reused with a different payload
|
|
88
|
-
}
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
Same idea for `commitCreditsDetailed` and `releaseCreditsDetailed`: the caller
|
|
92
|
-
that wins the transition gets `committed` / `released`, and every other caller
|
|
93
|
-
gets `already_terminal` with the status that won, rather than an exception.
|
|
94
|
-
That distinction is what lets a retry tell "someone else already did this" apart
|
|
95
|
-
from "this failed".
|
|
96
|
-
|
|
97
|
-
### Crediting an account, and `paymentRef`
|
|
98
|
-
|
|
99
|
-
`paymentRef` identifies a **credit event** — one webhook, one invoice, one
|
|
100
|
-
charge — and it is global, not per user: the same reference is the same event
|
|
101
|
-
whoever presents it. `addCreditsV2` says which of the three things happened:
|
|
102
|
-
|
|
103
|
-
```typescript
|
|
104
|
-
const outcome = await repository.addCreditsV2({
|
|
105
|
-
userId,
|
|
106
|
-
amount: 25,
|
|
107
|
-
description: "Purchase",
|
|
108
|
-
paymentRef: event.id,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
switch (outcome.outcome) {
|
|
112
|
-
case "created": break; // credited; outcome.transaction / .journalEntryId
|
|
113
|
-
case "replayed": break; // same reference, same payload: nothing was written
|
|
114
|
-
case "conflict": break; // same reference, different event: outcome.mismatch
|
|
115
|
-
}
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
A replay is decided on the *payload*, not on the reference being present: user,
|
|
119
|
-
amount, transaction type, journal source and reference type all have to match.
|
|
120
|
-
`description` is deliberately excluded, since a retry may legitimately
|
|
121
|
-
regenerate the copy. A `conflict` writes nothing at all — no balance change, no
|
|
122
|
-
transaction row, no journal entry.
|
|
123
|
-
|
|
124
|
-
Empty and whitespace-only references are normalised to "no reference", and a
|
|
125
|
-
padded one is trimmed to the same reference. Unlike an idempotency key, a blank
|
|
126
|
-
`paymentRef` is not an error: a key is an explicit request for exactly-once,
|
|
127
|
-
while a reference is an optional annotation on a credit.
|
|
128
|
-
|
|
129
|
-
`addCredits` on the service and `addCreditsAtomic` on the repository keep their
|
|
130
|
-
old signatures. `addCreditsAtomic` returns quietly on a genuine replay — that is
|
|
131
|
-
the idempotent no-op the caller asked for — and **throws**
|
|
132
|
-
`IDEMPOTENCY_CONFLICT` on a conflict, because a `void` return has nowhere to
|
|
133
|
-
report one and staying silent is indistinguishable from having credited.
|
|
134
|
-
|
|
135
|
-
In SQL the arbiter is the partial unique index on `payment_ref`, so concurrent
|
|
136
|
-
deliveries are resolved by the database rather than by a read-then-write. The
|
|
137
|
-
in-memory adapter gives the same three outcomes.
|
|
138
|
-
|
|
139
|
-
### A reservation row is not a hold
|
|
140
|
-
|
|
141
|
-
The V2 boundary rests on one invariant: **a row is a reservation only if the
|
|
142
|
-
same atomic operation that wrote it also increased `reserved` by its amount.**
|
|
143
|
-
`reserveCredits`/`reserveCreditsV2` records that as `holdPlacedAt`, in the same
|
|
144
|
-
transaction as the hold, and nothing else writes it.
|
|
145
|
-
|
|
146
|
-
Two consequences you can see from the outside:
|
|
147
|
-
|
|
148
|
-
- `createReservation` — the low-level row writer, which does not touch
|
|
149
|
-
`reserved` — **refuses an `idempotencyKey`** with `UNSUPPORTED_OPERATION`. A
|
|
150
|
-
key there would name a hold this method never placed. Place idempotent holds
|
|
151
|
-
with `reserveCredits`/`reserveCreditsV2`.
|
|
152
|
-
- A reservation without `holdPlacedAt` is never adopted as a replay and cannot
|
|
153
|
-
be committed, released or expired; it raises `UNBACKED_RESERVATION` and
|
|
154
|
-
changes nothing. Rows that predate the field are backfilled by the SQL
|
|
155
|
-
migration, so existing holds keep working.
|
|
156
|
-
|
|
157
|
-
Without this, a keyed row that no hold backed came back as `replayed`, and the
|
|
158
|
-
commit that followed passed its `reserved >= amount` check by consuming coverage
|
|
159
|
-
belonging to a different, genuine reservation.
|
|
160
|
-
|
|
161
|
-
### What an amount has to be
|
|
162
|
-
|
|
163
|
-
Every public method that writes a credit amount validates it first, in the
|
|
164
|
-
service *and* in both shipped repositories, so a direct adapter caller gets the
|
|
165
|
-
same guarantees. That covers values the adapter derives as well as the ones the
|
|
166
|
-
caller passes: balance and counter increments are checked as *results*, because
|
|
167
|
-
a legal increment on a legal balance can still land outside the column. The
|
|
168
|
-
in-memory adapter projects the whole record — the fields you set outright, the
|
|
169
|
-
increments applied on top of them, and every value derived from the result —
|
|
170
|
-
validates all of it, and only then writes anything back. It has no transaction
|
|
171
|
-
to roll back a partial write, so a refused call has to leave the record exactly
|
|
172
|
-
as it found it. That includes the journal's `balanceAfter`, which is a *total*
|
|
173
|
-
rather than one of the balance columns and so stays representable only if it is
|
|
174
|
-
checked in its own right; the error names both the field and the transition.
|
|
175
|
-
|
|
176
|
-
An amount that credits or debits a balance must be finite, greater than zero,
|
|
177
|
-
exact to two decimal places, and within
|
|
178
|
-
`numeric(12, 2)`; anything else raises `INVALID_AMOUNT` before the write.
|
|
179
|
-
(`Infinity` is the sentinel for an unlimited tier and is never written to a
|
|
180
|
-
column, so tier limits are allowed to be it.)
|
|
181
|
-
|
|
182
|
-
That is narrower than "a positive number". `1.005` and `0.1 + 0.2` are rejected
|
|
183
|
-
rather than silently rounded by the column — round to cents before calling.
|
|
184
|
-
Ledger *records* (`createTransaction`, `createJournalEntry`) are checked only for
|
|
185
|
-
representability, since a correcting entry may be negative and a balance may be
|
|
186
|
-
below zero.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
The check
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
`
|
|
205
|
-
is
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
|
287
|
-
|
|
288
|
-
| `
|
|
289
|
-
| `
|
|
290
|
-
| `
|
|
291
|
-
| `
|
|
292
|
-
| `
|
|
293
|
-
| `
|
|
294
|
-
|
|
295
|
-
`
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
`
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
```
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
|
350
|
-
|
|
351
|
-
| `
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
##
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
1
|
+
# @nehorai/credits
|
|
2
|
+
|
|
3
|
+
Framework-agnostic credits and billing system with two-phase commit for safe credit operations.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Two-phase commit** -- Reserve credits before executing operations, then commit or release
|
|
8
|
+
- **Journal-based audit trail** -- Every credit change is logged with source, reference, and metadata
|
|
9
|
+
- **Subscription tier management** -- Free, basic, premium, and unlimited tiers with monthly resets
|
|
10
|
+
- **Notification hooks** -- Low balance and subscription expiry notifications
|
|
11
|
+
- **REST API SDK** -- Client and admin SDK for external integrations
|
|
12
|
+
- **In-memory repository** -- Included for testing and development
|
|
13
|
+
- **Type-safe** -- Full TypeScript with portable types (no framework dependencies)
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @nehorai/credits
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import {
|
|
25
|
+
CreditsService,
|
|
26
|
+
createInMemoryCreditRepository,
|
|
27
|
+
} from "@nehorai/credits";
|
|
28
|
+
|
|
29
|
+
const repository = createInMemoryCreditRepository();
|
|
30
|
+
const service = new CreditsService(repository);
|
|
31
|
+
|
|
32
|
+
// Initialize a user with free tier
|
|
33
|
+
await service.initializeUserCredits("user-123");
|
|
34
|
+
|
|
35
|
+
// Check if user has enough credits
|
|
36
|
+
const check = await service.checkCredits("user-123", 10);
|
|
37
|
+
if (!check.hasCredits) {
|
|
38
|
+
console.log(`Need ${check.shortfall} more credits`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Reserve-Execute-Commit pattern
|
|
42
|
+
const reservation = await service.reserveCredits("user-123", 10, "story_generation");
|
|
43
|
+
try {
|
|
44
|
+
await doExpensiveWork();
|
|
45
|
+
await service.commitCredits("user-123", reservation.id); // Deducts credits
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await service.releaseCredits("user-123", reservation.id); // Refunds credits
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API Reference
|
|
53
|
+
|
|
54
|
+
### `CreditsService`
|
|
55
|
+
|
|
56
|
+
The main service class. Accepts any `ICreditRepository` implementation.
|
|
57
|
+
|
|
58
|
+
| Method | Description |
|
|
59
|
+
|--------|-------------|
|
|
60
|
+
| `getUserCredits(userId)` | Get user balance (auto-handles monthly reset and subscription expiry) |
|
|
61
|
+
| `initializeUserCredits(userId)` | Create a new user with free tier |
|
|
62
|
+
| `getOrCreateUserCredits(userId)` | Get or initialize user credits |
|
|
63
|
+
| `checkCredits(userId, amount)` | Check if user has sufficient credits |
|
|
64
|
+
| `reserveCredits(userId, amount, operationType)` | Reserve credits (phase 1) |
|
|
65
|
+
| `commitCredits(userId, reservationId)` | Commit reservation (phase 2 -- success) |
|
|
66
|
+
| `releaseCredits(userId, reservationId)` | Release reservation (phase 2 -- failure). A no-op for any terminal status; use `releaseCreditsDetailed` to see a concurrent commit. |
|
|
67
|
+
| `addCredits(userId, amount, description, paymentRef?)` | Add credits (purchases, bonuses). Returns an `AddCreditsOutcome`: `created`, `replayed` or `conflict` -- see [`paymentRef`](#crediting-an-account-and-paymentref). |
|
|
68
|
+
| `updateTier(userId, tier, expiresAt?)` | Change subscription tier |
|
|
69
|
+
| `getUsageHistory(userId, limit?, offset?)` | Paginated usage history |
|
|
70
|
+
| `logUsage(log)` | Log a usage event for auditing |
|
|
71
|
+
|
|
72
|
+
### The V2 reservation boundary
|
|
73
|
+
|
|
74
|
+
The legacy `reserveCredits`/`commitCredits`/`releaseCredits` methods above still
|
|
75
|
+
work unchanged. Alongside them are `*Detailed` variants that return a **typed
|
|
76
|
+
outcome** instead of throwing, and accept a caller **idempotency key**:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
const outcome = await service.reserveCreditsDetailed(userId, 40, "story_generation", {
|
|
80
|
+
idempotencyKey: `job:${jobId}`,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
switch (outcome.outcome) {
|
|
84
|
+
case "created": break; // fresh hold
|
|
85
|
+
case "replayed": break; // same key + same amount/operation: same reservation
|
|
86
|
+
case "insufficient": break; // outcome.available / .required / .shortfall
|
|
87
|
+
case "idempotency_conflict": break; // key reused with a different payload
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Same idea for `commitCreditsDetailed` and `releaseCreditsDetailed`: the caller
|
|
92
|
+
that wins the transition gets `committed` / `released`, and every other caller
|
|
93
|
+
gets `already_terminal` with the status that won, rather than an exception.
|
|
94
|
+
That distinction is what lets a retry tell "someone else already did this" apart
|
|
95
|
+
from "this failed".
|
|
96
|
+
|
|
97
|
+
### Crediting an account, and `paymentRef`
|
|
98
|
+
|
|
99
|
+
`paymentRef` identifies a **credit event** — one webhook, one invoice, one
|
|
100
|
+
charge — and it is global, not per user: the same reference is the same event
|
|
101
|
+
whoever presents it. `addCreditsV2` says which of the three things happened:
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
const outcome = await repository.addCreditsV2({
|
|
105
|
+
userId,
|
|
106
|
+
amount: 25,
|
|
107
|
+
description: "Purchase",
|
|
108
|
+
paymentRef: event.id,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
switch (outcome.outcome) {
|
|
112
|
+
case "created": break; // credited; outcome.transaction / .journalEntryId
|
|
113
|
+
case "replayed": break; // same reference, same payload: nothing was written
|
|
114
|
+
case "conflict": break; // same reference, different event: outcome.mismatch
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A replay is decided on the *payload*, not on the reference being present: user,
|
|
119
|
+
amount, transaction type, journal source and reference type all have to match.
|
|
120
|
+
`description` is deliberately excluded, since a retry may legitimately
|
|
121
|
+
regenerate the copy. A `conflict` writes nothing at all — no balance change, no
|
|
122
|
+
transaction row, no journal entry.
|
|
123
|
+
|
|
124
|
+
Empty and whitespace-only references are normalised to "no reference", and a
|
|
125
|
+
padded one is trimmed to the same reference. Unlike an idempotency key, a blank
|
|
126
|
+
`paymentRef` is not an error: a key is an explicit request for exactly-once,
|
|
127
|
+
while a reference is an optional annotation on a credit.
|
|
128
|
+
|
|
129
|
+
`addCredits` on the service and `addCreditsAtomic` on the repository keep their
|
|
130
|
+
old signatures. `addCreditsAtomic` returns quietly on a genuine replay — that is
|
|
131
|
+
the idempotent no-op the caller asked for — and **throws**
|
|
132
|
+
`IDEMPOTENCY_CONFLICT` on a conflict, because a `void` return has nowhere to
|
|
133
|
+
report one and staying silent is indistinguishable from having credited.
|
|
134
|
+
|
|
135
|
+
In SQL the arbiter is the partial unique index on `payment_ref`, so concurrent
|
|
136
|
+
deliveries are resolved by the database rather than by a read-then-write. The
|
|
137
|
+
in-memory adapter gives the same three outcomes.
|
|
138
|
+
|
|
139
|
+
### A reservation row is not a hold
|
|
140
|
+
|
|
141
|
+
The V2 boundary rests on one invariant: **a row is a reservation only if the
|
|
142
|
+
same atomic operation that wrote it also increased `reserved` by its amount.**
|
|
143
|
+
`reserveCredits`/`reserveCreditsV2` records that as `holdPlacedAt`, in the same
|
|
144
|
+
transaction as the hold, and nothing else writes it.
|
|
145
|
+
|
|
146
|
+
Two consequences you can see from the outside:
|
|
147
|
+
|
|
148
|
+
- `createReservation` — the low-level row writer, which does not touch
|
|
149
|
+
`reserved` — **refuses an `idempotencyKey`** with `UNSUPPORTED_OPERATION`. A
|
|
150
|
+
key there would name a hold this method never placed. Place idempotent holds
|
|
151
|
+
with `reserveCredits`/`reserveCreditsV2`.
|
|
152
|
+
- A reservation without `holdPlacedAt` is never adopted as a replay and cannot
|
|
153
|
+
be committed, released or expired; it raises `UNBACKED_RESERVATION` and
|
|
154
|
+
changes nothing. Rows that predate the field are backfilled by the SQL
|
|
155
|
+
migration, so existing holds keep working.
|
|
156
|
+
|
|
157
|
+
Without this, a keyed row that no hold backed came back as `replayed`, and the
|
|
158
|
+
commit that followed passed its `reserved >= amount` check by consuming coverage
|
|
159
|
+
belonging to a different, genuine reservation.
|
|
160
|
+
|
|
161
|
+
### What an amount has to be
|
|
162
|
+
|
|
163
|
+
Every public method that writes a credit amount validates it first, in the
|
|
164
|
+
service *and* in both shipped repositories, so a direct adapter caller gets the
|
|
165
|
+
same guarantees. That covers values the adapter derives as well as the ones the
|
|
166
|
+
caller passes: balance and counter increments are checked as *results*, because
|
|
167
|
+
a legal increment on a legal balance can still land outside the column. The
|
|
168
|
+
in-memory adapter projects the whole record — the fields you set outright, the
|
|
169
|
+
increments applied on top of them, and every value derived from the result —
|
|
170
|
+
validates all of it, and only then writes anything back. It has no transaction
|
|
171
|
+
to roll back a partial write, so a refused call has to leave the record exactly
|
|
172
|
+
as it found it. That includes the journal's `balanceAfter`, which is a *total*
|
|
173
|
+
rather than one of the balance columns and so stays representable only if it is
|
|
174
|
+
checked in its own right; the error names both the field and the transition.
|
|
175
|
+
|
|
176
|
+
An amount that credits or debits a balance must be finite, greater than zero,
|
|
177
|
+
exact to two decimal places, and within
|
|
178
|
+
`numeric(12, 2)`; anything else raises `INVALID_AMOUNT` before the write.
|
|
179
|
+
(`Infinity` is the sentinel for an unlimited tier and is never written to a
|
|
180
|
+
column, so tier limits are allowed to be it.)
|
|
181
|
+
|
|
182
|
+
That is narrower than "a positive number". `1.005` and `0.1 + 0.2` are rejected
|
|
183
|
+
rather than silently rounded by the column — round to cents before calling.
|
|
184
|
+
Ledger *records* (`createTransaction`, `createJournalEntry`) are checked only for
|
|
185
|
+
representability, since a correcting entry may be negative and a balance may be
|
|
186
|
+
below zero.
|
|
187
|
+
|
|
188
|
+
A cost of `0` in `operationCosts` is valid and marks a free operation. `withCredits`
|
|
189
|
+
runs it with a placeholder reservation (`id: "free-operation"`, `amount: 0`) and
|
|
190
|
+
logs usage, but places no hold and writes no journal entry, so it needs no balance.
|
|
191
|
+
No credits move, so the rule above still holds: `reserveCredits` and `deductCredits`
|
|
192
|
+
refuse `0`, and so does `withCredits` with `customCost: 0`. A caller that drives
|
|
193
|
+
reservations itself checks for a zero cost and skips them.
|
|
194
|
+
|
|
195
|
+
The transitions re-check the amount they read back from storage, too. A
|
|
196
|
+
reservation row written with a negative amount — by an older version, a repair
|
|
197
|
+
script, a hand-run `UPDATE` — used to satisfy `reserved >= amount` trivially and
|
|
198
|
+
*add* credits on commit. Commit, release and expire all refuse it now and change
|
|
199
|
+
nothing, so the row waits for an operator instead of moving on arithmetic
|
|
200
|
+
nobody can trust.
|
|
201
|
+
|
|
202
|
+
The check runs before the early exits, not just before the write. A corrupt row
|
|
203
|
+
that is already `committed`, or not yet due to expire, would otherwise come back
|
|
204
|
+
as a successful `already_terminal` or `not_due` — which tells the caller the
|
|
205
|
+
reservation is fine. It raises `INVALID_AMOUNT` instead, for every status.
|
|
206
|
+
|
|
207
|
+
An adapter must validate the amount in the representation *its storage* returns,
|
|
208
|
+
before any conversion. The SQL adapter reads a `numeric` column as a string and
|
|
209
|
+
checks it with exact integer arithmetic, because converting first would hide the
|
|
210
|
+
problem: a legacy unconstrained `numeric` can hold `9999999999.9900001`, which
|
|
211
|
+
`Number()` rounds to a perfectly valid `9999999999.99`. `assertValidStoredAmount`
|
|
212
|
+
is the float-typed form for stores that hold JS numbers natively;
|
|
213
|
+
`assertValidStoredAmountRaw` is the one to use otherwise.
|
|
214
|
+
|
|
215
|
+
Guarantees a V2 repository must provide, and which the shipped adapters do:
|
|
216
|
+
|
|
217
|
+
- concurrent commits of one reservation deduct **once** and journal **once**;
|
|
218
|
+
- commit vs release vs expire has exactly **one** winner;
|
|
219
|
+
- a caller idempotency key is unique per `(user, key)`, and a replay returns the
|
|
220
|
+
original reservation instead of placing a second hold;
|
|
221
|
+
- the balance mutation and the journal entry commit **together** or not at all.
|
|
222
|
+
|
|
223
|
+
**Not every repository implements V2.** Check before relying on it:
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
import { supportsCreditsV2 } from "@nehorai/credits";
|
|
227
|
+
|
|
228
|
+
if (supportsCreditsV2(repo)) { /* V2 methods are available */ }
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`InMemoryCreditRepository` and `@nehorai/credits-drizzle` implement V2.
|
|
232
|
+
`@nehorai/credits-firestore` does **not** — it remains legacy-only, and gets no
|
|
233
|
+
idempotency-key or single-winner guarantees. `supportsCreditsV2` returns `false`
|
|
234
|
+
for it and the legacy code path is used, exactly as before.
|
|
235
|
+
|
|
236
|
+
Two things follow from that, and they are enforced rather than assumed:
|
|
237
|
+
|
|
238
|
+
- Passing an `idempotencyKey` to a non-V2 repository throws
|
|
239
|
+
`UNSUPPORTED_OPERATION` before the reserve is attempted. It does not silently
|
|
240
|
+
drop the key and report a fresh hold, because a retry would then charge twice.
|
|
241
|
+
- On a non-V2 repository, `commitCreditsDetailed` and `releaseCreditsDetailed`
|
|
242
|
+
**cannot promise a single winner**. They read the status and then write, with
|
|
243
|
+
no lock or compare-and-set between, so two concurrent commits can both
|
|
244
|
+
proceed. `committed` there means "this call did the work", not "only this call
|
|
245
|
+
did".
|
|
246
|
+
|
|
247
|
+
The Drizzle adapter needs a schema migration before V2 calls work; see its
|
|
248
|
+
README. It fails loudly rather than silently double-holding if the migration
|
|
249
|
+
has not been applied.
|
|
250
|
+
|
|
251
|
+
### `ICreditRepository`
|
|
252
|
+
|
|
253
|
+
Interface for database implementations. Implement this to use any database backend.
|
|
254
|
+
|
|
255
|
+
Key methods: `getUserCredits`, `initializeUserCredits`, `reserveCreditsAtomic`, `commitReservationAtomic`, `releaseReservationAtomic`, `addCreditsAtomic`, `atomicMonthlyReset`, `createJournalEntry`, `findAndExpireReservations`.
|
|
256
|
+
|
|
257
|
+
Optional V2 methods: `reserveCreditsV2`, `commitReservationV2`, `releaseReservationV2`, `expireReservationV2`. A repository that implements all four owns its own journal writes; the service layer will not add a second entry.
|
|
258
|
+
|
|
259
|
+
### `InMemoryCreditRepository`
|
|
260
|
+
|
|
261
|
+
In-memory implementation of `ICreditRepository` for testing:
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
import { createInMemoryCreditRepository } from "@nehorai/credits";
|
|
265
|
+
|
|
266
|
+
const repo = createInMemoryCreditRepository();
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Error Handling
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
import { CreditError, isCreditError, CreditErrorCode } from "@nehorai/credits";
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
await service.reserveCredits(userId, 100, "expensive_op");
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (isCreditError(error) && error.code === CreditErrorCode.INSUFFICIENT_CREDITS) {
|
|
278
|
+
// Handle insufficient credits
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`CreditErrorCode` is a stable contract, so callers can branch on the cause
|
|
284
|
+
rather than on message text:
|
|
285
|
+
|
|
286
|
+
| Code | Meaning |
|
|
287
|
+
|------|---------|
|
|
288
|
+
| `INSUFFICIENT_CREDITS` | The user cannot cover the amount. Not retryable as-is. |
|
|
289
|
+
| `IDEMPOTENCY_CONFLICT` | The key was reused with a different amount or operation. A bug in the caller, not a race. |
|
|
290
|
+
| `RESERVATION_NOT_FOUND` | No such reservation for that user. |
|
|
291
|
+
| `RESERVATION_ALREADY_PROCESSED` | Someone already committed, released, or expired it. |
|
|
292
|
+
| `RESERVATION_EXPIRED` | The hold lapsed before it was committed. |
|
|
293
|
+
| `INVALID_AMOUNT` | Amount was not a finite positive number on the cent grid and within `numeric(12, 2)`; or a stored reservation amount failed the same check; or the arithmetic overflowed the column (`details.reason`). |
|
|
294
|
+
| `UNBACKED_RESERVATION` | The reservation does not record that its hold was placed atomically, so the credits it claims may never have been held. The operation was refused before any state changed. |
|
|
295
|
+
| `CORRUPT_RESERVATION_STATUS` | The stored status is not one of `reserved`/`committed`/`released`/`expired`. The row is quarantined for an operator; nothing changed. |
|
|
296
|
+
| `INVALID_IDEMPOTENCY_KEY` | Key was empty, whitespace-only, or in the `reservation:` namespace the V2 transitions own. |
|
|
297
|
+
| `TRANSIENT_ERROR` | Deadlock, serialisation failure, lock timeout, connection loss. **Safe to retry** — with the same idempotency key. |
|
|
298
|
+
| `DATABASE_ERROR` | A database failure that is not known to be transient. |
|
|
299
|
+
| `CONFIGURATION_ERROR` | Misconfiguration (tiers, costs, adapter wiring). |
|
|
300
|
+
| `USER_NOT_FOUND` / `INVALID_OPERATION_TYPE` / `UNSUPPORTED_OPERATION` | Bad input, or a V2 call against a legacy repository. |
|
|
301
|
+
|
|
302
|
+
`isTransientError(error)` is the one to branch on for retry logic. One honest
|
|
303
|
+
caveat: a lost connection is reported as transient, but if it dropped during
|
|
304
|
+
COMMIT the operation may have succeeded. Retries are safe for the transitions
|
|
305
|
+
(the status CAS makes them idempotent) and for reserve **only when you pass an
|
|
306
|
+
`idempotencyKey`**.
|
|
307
|
+
|
|
308
|
+
### Amounts
|
|
309
|
+
|
|
310
|
+
Credit amounts are stored as `numeric(12, 2)`, so only values on the cent grid
|
|
311
|
+
up to `9999999999.99` are representable. Anything off that grid — non-finite,
|
|
312
|
+
out of range, or with more than two decimals — is rejected with `INVALID_AMOUNT`
|
|
313
|
+
before any write, rather than being silently rounded on the way into the
|
|
314
|
+
database.
|
|
315
|
+
|
|
316
|
+
Two rules, not one. An amount that *moves* — a deduction, a hold, a purchase —
|
|
317
|
+
must also be strictly positive. An amount that is merely *recorded* need not be:
|
|
318
|
+
a release journals `amount: 0`, and a corrected account has a negative
|
|
319
|
+
`balanceAfter`, so the transaction and journal writers check representability
|
|
320
|
+
alone. Derived totals are summed on the cent grid rather than with float
|
|
321
|
+
arithmetic, because `0.10 + 0.20` is `0.30000000000000004` and would otherwise
|
|
322
|
+
fail a check it should pass.
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
import { isValidCreditAmount, toCents, sameAmount } from "@nehorai/credits";
|
|
326
|
+
|
|
327
|
+
isValidCreditAmount(1.05); // true
|
|
328
|
+
isValidCreditAmount(1.005); // false — a third decimal the column cannot hold
|
|
329
|
+
toCents(1.1); // 110
|
|
330
|
+
sameAmount("40.00", 40); // true — exact, not float comparison
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### SDK Clients
|
|
334
|
+
|
|
335
|
+
REST API clients for external integrations:
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
import { CreditsClient, AdminCreditsClient } from "@nehorai/credits";
|
|
339
|
+
|
|
340
|
+
// User-facing client
|
|
341
|
+
const client = new CreditsClient({ baseUrl: "/api/v1/credits", getToken });
|
|
342
|
+
|
|
343
|
+
// Admin client
|
|
344
|
+
const admin = new AdminCreditsClient({ baseUrl: "/api/v1/admin", getToken });
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Core Types
|
|
348
|
+
|
|
349
|
+
| Type | Description |
|
|
350
|
+
|------|-------------|
|
|
351
|
+
| `PortableUserCredits` | User balance with tier, monthly limits, timestamps |
|
|
352
|
+
| `PortableReservation` | Credit reservation for two-phase commit |
|
|
353
|
+
| `PortableTransaction` | Purchase/refund transaction record |
|
|
354
|
+
| `PortableJournalEntry` | Audit trail entry |
|
|
355
|
+
| `PortableUsageLog` | Operation usage log |
|
|
356
|
+
| `CreditCheckResult` | Result of credit sufficiency check |
|
|
357
|
+
| `SubscriptionTier` | `"free" \| "basic" \| "premium" \| "unlimited"` |
|
|
358
|
+
| `WithCreditsOptions` | Options for the `withCredits` HOF |
|
|
359
|
+
|
|
360
|
+
### Sub-path Exports
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
import { ... } from "@nehorai/credits/core"; // Core types and errors
|
|
364
|
+
import { ... } from "@nehorai/credits/repository"; // Repository types and in-memory impl
|
|
365
|
+
import { ... } from "@nehorai/credits/auth"; // Auth provider types
|
|
366
|
+
import { ... } from "@nehorai/credits/service"; // CreditsService
|
|
367
|
+
import { ... } from "@nehorai/credits/adapters"; // Adapter types
|
|
368
|
+
import { ... } from "@nehorai/credits/sdk"; // REST API clients
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## Related Packages
|
|
372
|
+
|
|
373
|
+
| Package | Description |
|
|
374
|
+
|---------|-------------|
|
|
375
|
+
| [`@nehorai/credits-firestore`](https://www.npmjs.com/package/@nehorai/credits-firestore) | Firestore implementation of `ICreditRepository` |
|
|
376
|
+
| [`@nehorai/credits-nextjs`](https://www.npmjs.com/package/@nehorai/credits-nextjs) | Next.js adapter with NextAuth integration |
|
|
377
|
+
|
|
378
|
+
## Repository
|
|
379
|
+
|
|
380
|
+
[https://github.com/NehoraiHadad/nehorai-plugins](https://github.com/NehoraiHadad/nehorai-plugins)
|
|
381
|
+
|
|
382
|
+
## License
|
|
383
|
+
|
|
384
|
+
MIT
|