@nehorai/credits 1.7.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +226 -2
  2. package/dist/adapters/index.cjs +298 -40
  3. package/dist/adapters/index.cjs.map +1 -1
  4. package/dist/adapters/index.d.cts +2 -2
  5. package/dist/adapters/index.d.ts +2 -2
  6. package/dist/adapters/index.js +298 -40
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/core/index.cjs +696 -74
  9. package/dist/core/index.cjs.map +1 -1
  10. package/dist/core/index.d.cts +583 -5
  11. package/dist/core/index.d.ts +583 -5
  12. package/dist/core/index.js +648 -74
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/index.cjs +1762 -365
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +41 -6
  17. package/dist/index.d.ts +41 -6
  18. package/dist/index.js +1706 -365
  19. package/dist/index.js.map +1 -1
  20. package/dist/repository/index.cjs +1399 -183
  21. package/dist/repository/index.cjs.map +1 -1
  22. package/dist/repository/index.d.cts +141 -15
  23. package/dist/repository/index.d.ts +141 -15
  24. package/dist/repository/index.js +1392 -183
  25. package/dist/repository/index.js.map +1 -1
  26. package/dist/sdk/index.cjs +23 -1
  27. package/dist/sdk/index.cjs.map +1 -1
  28. package/dist/sdk/index.d.cts +2 -2
  29. package/dist/sdk/index.d.ts +2 -2
  30. package/dist/sdk/index.js +23 -1
  31. package/dist/sdk/index.js.map +1 -1
  32. package/dist/service/index.cjs +480 -104
  33. package/dist/service/index.cjs.map +1 -1
  34. package/dist/service/index.d.cts +68 -10
  35. package/dist/service/index.d.ts +68 -10
  36. package/dist/service/index.js +480 -104
  37. package/dist/service/index.js.map +1 -1
  38. package/dist/types-BmG5Omzj.d.ts +646 -0
  39. package/dist/types-C8hZMe8a.d.cts +646 -0
  40. package/dist/{types-CjLj3GW5.d.cts → types-DJ8yqaVr.d.cts} +39 -1
  41. package/dist/{types-CjLj3GW5.d.ts → types-DJ8yqaVr.d.ts} +39 -1
  42. package/package.json +1 -1
  43. package/dist/types-DFe5BoJY.d.cts +0 -329
  44. package/dist/types-_bSybQnb.d.ts +0 -329
package/README.md CHANGED
@@ -63,18 +63,192 @@ The main service class. Accepts any `ICreditRepository` implementation.
63
63
  | `checkCredits(userId, amount)` | Check if user has sufficient credits |
64
64
  | `reserveCredits(userId, amount, operationType)` | Reserve credits (phase 1) |
65
65
  | `commitCredits(userId, reservationId)` | Commit reservation (phase 2 -- success) |
66
- | `releaseCredits(userId, reservationId)` | Release reservation (phase 2 -- failure) |
67
- | `addCredits(userId, amount, description)` | Add credits (purchases, bonuses) |
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
68
  | `updateTier(userId, tier, expiresAt?)` | Change subscription tier |
69
69
  | `getUsageHistory(userId, limit?, offset?)` | Paginated usage history |
70
70
  | `logUsage(log)` | Log a usage event for auditing |
71
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
+ The transitions re-check the amount they read back from storage, too. A
189
+ reservation row written with a negative amount — by an older version, a repair
190
+ script, a hand-run `UPDATE` — used to satisfy `reserved >= amount` trivially and
191
+ *add* credits on commit. Commit, release and expire all refuse it now and change
192
+ nothing, so the row waits for an operator instead of moving on arithmetic
193
+ nobody can trust.
194
+
195
+ The check runs before the early exits, not just before the write. A corrupt row
196
+ that is already `committed`, or not yet due to expire, would otherwise come back
197
+ as a successful `already_terminal` or `not_due` — which tells the caller the
198
+ reservation is fine. It raises `INVALID_AMOUNT` instead, for every status.
199
+
200
+ An adapter must validate the amount in the representation *its storage* returns,
201
+ before any conversion. The SQL adapter reads a `numeric` column as a string and
202
+ checks it with exact integer arithmetic, because converting first would hide the
203
+ problem: a legacy unconstrained `numeric` can hold `9999999999.9900001`, which
204
+ `Number()` rounds to a perfectly valid `9999999999.99`. `assertValidStoredAmount`
205
+ is the float-typed form for stores that hold JS numbers natively;
206
+ `assertValidStoredAmountRaw` is the one to use otherwise.
207
+
208
+ Guarantees a V2 repository must provide, and which the shipped adapters do:
209
+
210
+ - concurrent commits of one reservation deduct **once** and journal **once**;
211
+ - commit vs release vs expire has exactly **one** winner;
212
+ - a caller idempotency key is unique per `(user, key)`, and a replay returns the
213
+ original reservation instead of placing a second hold;
214
+ - the balance mutation and the journal entry commit **together** or not at all.
215
+
216
+ **Not every repository implements V2.** Check before relying on it:
217
+
218
+ ```typescript
219
+ import { supportsCreditsV2 } from "@nehorai/credits";
220
+
221
+ if (supportsCreditsV2(repo)) { /* V2 methods are available */ }
222
+ ```
223
+
224
+ `InMemoryCreditRepository` and `@nehorai/credits-drizzle` implement V2.
225
+ `@nehorai/credits-firestore` does **not** — it remains legacy-only, and gets no
226
+ idempotency-key or single-winner guarantees. `supportsCreditsV2` returns `false`
227
+ for it and the legacy code path is used, exactly as before.
228
+
229
+ Two things follow from that, and they are enforced rather than assumed:
230
+
231
+ - Passing an `idempotencyKey` to a non-V2 repository throws
232
+ `UNSUPPORTED_OPERATION` before the reserve is attempted. It does not silently
233
+ drop the key and report a fresh hold, because a retry would then charge twice.
234
+ - On a non-V2 repository, `commitCreditsDetailed` and `releaseCreditsDetailed`
235
+ **cannot promise a single winner**. They read the status and then write, with
236
+ no lock or compare-and-set between, so two concurrent commits can both
237
+ proceed. `committed` there means "this call did the work", not "only this call
238
+ did".
239
+
240
+ The Drizzle adapter needs a schema migration before V2 calls work; see its
241
+ README. It fails loudly rather than silently double-holding if the migration
242
+ has not been applied.
243
+
72
244
  ### `ICreditRepository`
73
245
 
74
246
  Interface for database implementations. Implement this to use any database backend.
75
247
 
76
248
  Key methods: `getUserCredits`, `initializeUserCredits`, `reserveCreditsAtomic`, `commitReservationAtomic`, `releaseReservationAtomic`, `addCreditsAtomic`, `atomicMonthlyReset`, `createJournalEntry`, `findAndExpireReservations`.
77
249
 
250
+ 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.
251
+
78
252
  ### `InMemoryCreditRepository`
79
253
 
80
254
  In-memory implementation of `ICreditRepository` for testing:
@@ -99,6 +273,56 @@ try {
99
273
  }
100
274
  ```
101
275
 
276
+ `CreditErrorCode` is a stable contract, so callers can branch on the cause
277
+ rather than on message text:
278
+
279
+ | Code | Meaning |
280
+ |------|---------|
281
+ | `INSUFFICIENT_CREDITS` | The user cannot cover the amount. Not retryable as-is. |
282
+ | `IDEMPOTENCY_CONFLICT` | The key was reused with a different amount or operation. A bug in the caller, not a race. |
283
+ | `RESERVATION_NOT_FOUND` | No such reservation for that user. |
284
+ | `RESERVATION_ALREADY_PROCESSED` | Someone already committed, released, or expired it. |
285
+ | `RESERVATION_EXPIRED` | The hold lapsed before it was committed. |
286
+ | `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`). |
287
+ | `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. |
288
+ | `CORRUPT_RESERVATION_STATUS` | The stored status is not one of `reserved`/`committed`/`released`/`expired`. The row is quarantined for an operator; nothing changed. |
289
+ | `INVALID_IDEMPOTENCY_KEY` | Key was empty, whitespace-only, or in the `reservation:` namespace the V2 transitions own. |
290
+ | `TRANSIENT_ERROR` | Deadlock, serialisation failure, lock timeout, connection loss. **Safe to retry** — with the same idempotency key. |
291
+ | `DATABASE_ERROR` | A database failure that is not known to be transient. |
292
+ | `CONFIGURATION_ERROR` | Misconfiguration (tiers, costs, adapter wiring). |
293
+ | `USER_NOT_FOUND` / `INVALID_OPERATION_TYPE` / `UNSUPPORTED_OPERATION` | Bad input, or a V2 call against a legacy repository. |
294
+
295
+ `isTransientError(error)` is the one to branch on for retry logic. One honest
296
+ caveat: a lost connection is reported as transient, but if it dropped during
297
+ COMMIT the operation may have succeeded. Retries are safe for the transitions
298
+ (the status CAS makes them idempotent) and for reserve **only when you pass an
299
+ `idempotencyKey`**.
300
+
301
+ ### Amounts
302
+
303
+ Credit amounts are stored as `numeric(12, 2)`, so only values on the cent grid
304
+ up to `9999999999.99` are representable. Anything off that grid — non-finite,
305
+ out of range, or with more than two decimals — is rejected with `INVALID_AMOUNT`
306
+ before any write, rather than being silently rounded on the way into the
307
+ database.
308
+
309
+ Two rules, not one. An amount that *moves* — a deduction, a hold, a purchase —
310
+ must also be strictly positive. An amount that is merely *recorded* need not be:
311
+ a release journals `amount: 0`, and a corrected account has a negative
312
+ `balanceAfter`, so the transaction and journal writers check representability
313
+ alone. Derived totals are summed on the cent grid rather than with float
314
+ arithmetic, because `0.10 + 0.20` is `0.30000000000000004` and would otherwise
315
+ fail a check it should pass.
316
+
317
+ ```typescript
318
+ import { isValidCreditAmount, toCents, sameAmount } from "@nehorai/credits";
319
+
320
+ isValidCreditAmount(1.05); // true
321
+ isValidCreditAmount(1.005); // false — a third decimal the column cannot hold
322
+ toCents(1.1); // 110
323
+ sameAmount("40.00", 40); // true — exact, not float comparison
324
+ ```
325
+
102
326
  ### SDK Clients
103
327
 
104
328
  REST API clients for external integrations:
@@ -285,57 +285,315 @@ function getOperationLabel(operationType) {
285
285
  }
286
286
  initializeConfig();
287
287
 
288
- // src/core/operations.ts
289
- async function commitReservationWithJournal(repository, userId, reservationId) {
288
+ // src/core/errors.ts
289
+ var CreditErrorCode = {
290
+ INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
291
+ RESERVATION_NOT_FOUND: "RESERVATION_NOT_FOUND",
292
+ RESERVATION_EXPIRED: "RESERVATION_EXPIRED",
293
+ RESERVATION_ALREADY_PROCESSED: "RESERVATION_ALREADY_PROCESSED",
294
+ USER_NOT_FOUND: "USER_NOT_FOUND",
295
+ INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
296
+ CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
297
+ DATABASE_ERROR: "DATABASE_ERROR",
298
+ /** Caller reused an idempotency key with a different immutable payload. */
299
+ IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT",
300
+ /**
301
+ * Retryable infrastructure failure — serialization/deadlock, lock timeout,
302
+ * connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
303
+ * a deterministic failure that will fail again identically on retry.
304
+ */
305
+ TRANSIENT_ERROR: "TRANSIENT_ERROR",
306
+ /** The configured repository adapter does not implement the requested V2 method. */
307
+ UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
308
+ /** Amount was not a finite positive number. */
309
+ INVALID_AMOUNT: "INVALID_AMOUNT",
310
+ /** Idempotency key was supplied but empty or whitespace-only. */
311
+ INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY",
312
+ /**
313
+ * A persisted reservation holds a status this library does not define, so no
314
+ * transition can reason about it. Quarantined rather than cast; nothing moved.
315
+ */
316
+ CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS",
317
+ /**
318
+ * A persisted reservation does not record that its hold was placed
319
+ * atomically, so the credits it claims are not known to be reserved. Moving
320
+ * it would spend another hold's coverage; nothing moved.
321
+ */
322
+ UNBACKED_RESERVATION: "UNBACKED_RESERVATION"
323
+ };
324
+ var CreditError = class _CreditError extends Error {
325
+ code;
326
+ details;
327
+ constructor(message, code, details) {
328
+ super(message);
329
+ this.name = "CreditError";
330
+ this.code = code;
331
+ this.details = details;
332
+ if (Error.captureStackTrace) {
333
+ Error.captureStackTrace(this, _CreditError);
334
+ }
335
+ }
336
+ /**
337
+ * Convert to JSON-serializable object
338
+ */
339
+ toJSON() {
340
+ return {
341
+ name: this.name,
342
+ code: this.code,
343
+ message: this.message,
344
+ ...this.details && { details: this.details }
345
+ };
346
+ }
347
+ };
348
+ function isCreditError(error) {
349
+ return error instanceof CreditError;
350
+ }
351
+ function isInsufficientCreditsError(error) {
352
+ if (isCreditError(error)) {
353
+ return error.code === CreditErrorCode.INSUFFICIENT_CREDITS;
354
+ }
355
+ if (error instanceof Error) {
356
+ return error.message.toLowerCase().includes("insufficient credits");
357
+ }
358
+ return false;
359
+ }
360
+ function createInsufficientCreditsError(required, available) {
361
+ return new CreditError(
362
+ `Insufficient credits: available ${available}, required ${required}`,
363
+ CreditErrorCode.INSUFFICIENT_CREDITS,
364
+ { required, available, shortfall: sumAmounts(required, -available) }
365
+ );
366
+ }
367
+ function createReservationNotFoundError(reservationId) {
368
+ return new CreditError(
369
+ `Reservation ${reservationId} not found`,
370
+ CreditErrorCode.RESERVATION_NOT_FOUND,
371
+ { reservationId }
372
+ );
373
+ }
374
+ function createReservationAlreadyProcessedError(reservationId, status) {
375
+ return new CreditError(
376
+ `Reservation ${reservationId} has already been ${status}`,
377
+ CreditErrorCode.RESERVATION_ALREADY_PROCESSED,
378
+ { reservationId, status }
379
+ );
380
+ }
381
+ function createUnsupportedOperationError(operation, hint) {
382
+ return new CreditError(
383
+ hint ? `Repository does not support ${operation}. ${hint}` : `Repository does not support ${operation}`,
384
+ CreditErrorCode.UNSUPPORTED_OPERATION,
385
+ { operation, ...hint ? { hint } : {} }
386
+ );
387
+ }
388
+
389
+ // src/core/amount.ts
390
+ var CREDIT_AMOUNT_SCALE = 2;
391
+ var CREDIT_AMOUNT_MAX = 999999999999e-2;
392
+ var MAX_CENTS = 999999999999;
393
+ function toCents(value) {
394
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
395
+ const cents = Math.round(value * 100);
396
+ if (!Number.isSafeInteger(cents)) return null;
397
+ return value === cents / 100 ? cents : null;
398
+ }
399
+ function sumAmounts(...values) {
400
+ let cents = 0;
401
+ for (const value of values) {
402
+ const valueCents = toCents(value);
403
+ if (valueCents === null) return floatSum(values);
404
+ cents += valueCents;
405
+ }
406
+ return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);
407
+ }
408
+ function floatSum(values) {
409
+ let total = 0;
410
+ for (const value of values) total += value;
411
+ return total;
412
+ }
413
+ function isValidCreditAmount(value) {
414
+ if (typeof value !== "number") return false;
415
+ const cents = toCents(value);
416
+ return cents !== null && cents > 0 && cents <= MAX_CENTS;
417
+ }
418
+ function assertValidCreditAmount(value, context) {
419
+ if (isValidCreditAmount(value)) return;
420
+ throw new CreditError(
421
+ creditAmountReason(value),
422
+ CreditErrorCode.INVALID_AMOUNT,
423
+ { amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
424
+ );
425
+ }
426
+ function creditAmountReason(value) {
427
+ if (typeof value !== "number") {
428
+ return `Credit amount must be a number (got ${typeof value})`;
429
+ }
430
+ if (!Number.isFinite(value)) {
431
+ return `Credit amount must be finite (got ${String(value)})`;
432
+ }
433
+ if (value <= 0) {
434
+ return `Credit amount must be greater than zero (got ${value})`;
435
+ }
436
+ if (value > CREDIT_AMOUNT_MAX) {
437
+ return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;
438
+ }
439
+ return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;
440
+ }
441
+
442
+ // src/repository/types.ts
443
+ function supportsCreditsV2(repository) {
444
+ return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
445
+ }
446
+
447
+ // src/repository/flow.ts
448
+ async function reserveThroughRepository(repository, input) {
449
+ const v2 = supportsCreditsV2(repository);
450
+ if (!v2 && input.idempotencyKey !== void 0) {
451
+ throw createUnsupportedOperationError(
452
+ "reserveCredits with an idempotencyKey",
453
+ "This repository does not implement the V2 boundary, so it cannot enforce idempotency keys. Use a V2 repository (check with supportsCreditsV2) or omit the key and accept at-least-once holds."
454
+ );
455
+ }
456
+ assertValidCreditAmount(input.amount, { userId: input.userId });
457
+ if (v2) {
458
+ return repository.reserveCreditsV2(input);
459
+ }
460
+ try {
461
+ const reservation = await repository.reserveCreditsAtomic(
462
+ input.userId,
463
+ input.amount,
464
+ input.operationType,
465
+ input.expiresAt
466
+ );
467
+ return { outcome: "created", reservation };
468
+ } catch (error) {
469
+ if (!isInsufficientCreditsError(error)) throw error;
470
+ const credits = await repository.getUserCredits(input.userId);
471
+ const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
472
+ return {
473
+ outcome: "insufficient",
474
+ available,
475
+ required: input.amount,
476
+ shortfall: Math.max(sumAmounts(input.amount, -available), 0)
477
+ };
478
+ }
479
+ }
480
+ async function commitThroughRepository(repository, userId, reservationId, options) {
481
+ if (supportsCreditsV2(repository)) {
482
+ return repository.commitReservationV2(userId, reservationId, options);
483
+ }
290
484
  const reservation = await repository.getReservation(userId, reservationId);
291
- if (!reservation) {
292
- throw new Error(`Reservation ${reservationId} not found`);
485
+ if (!reservation) return { outcome: "not_found", reservationId };
486
+ if (reservation.status !== "reserved") {
487
+ return {
488
+ outcome: "already_terminal",
489
+ reservation,
490
+ terminalStatus: reservation.status
491
+ };
293
492
  }
294
493
  await repository.commitReservationAtomic(userId, reservationId);
295
494
  const credits = await repository.getUserCredits(userId);
296
- if (credits) {
297
- await repository.createJournalEntry({
298
- userId,
299
- entryType: "debit",
300
- amount: reservation.amount,
301
- balanceAfter: credits.balance,
302
- source: "operation_commit",
303
- referenceId: reservationId,
304
- referenceType: "reservation",
305
- description: `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
306
- metadata: {
307
- operationType: reservation.operationType
308
- }
309
- });
310
- }
495
+ const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
496
+ const entry = await repository.createJournalEntry({
497
+ userId,
498
+ entryType: "debit",
499
+ amount: reservation.amount,
500
+ balanceAfter,
501
+ source: "operation_commit",
502
+ referenceId: reservationId,
503
+ referenceType: "reservation",
504
+ description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
505
+ // Deterministic fields last: they carry the identity of the event, and a
506
+ // caller who happens to send an `operationType` or an `amount` must not
507
+ // relabel what actually moved.
508
+ metadata: {
509
+ ...options?.metadata,
510
+ operationType: reservation.operationType,
511
+ amount: reservation.amount
512
+ }
513
+ });
514
+ return {
515
+ outcome: "committed",
516
+ reservation: { ...reservation, status: "committed" },
517
+ amount: reservation.amount,
518
+ balanceAfter,
519
+ journalEntryId: entry.id
520
+ };
311
521
  }
312
- async function releaseReservationWithJournal(repository, userId, reservationId) {
522
+ async function releaseThroughRepository(repository, userId, reservationId, options) {
523
+ if (supportsCreditsV2(repository)) {
524
+ return repository.releaseReservationV2(userId, reservationId, options);
525
+ }
313
526
  const reservation = await repository.getReservation(userId, reservationId);
527
+ if (!reservation) {
528
+ await repository.releaseReservationAtomic(userId, reservationId);
529
+ return { outcome: "not_found", reservationId };
530
+ }
531
+ if (reservation.status !== "reserved") {
532
+ await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
533
+ return {
534
+ outcome: "already_terminal",
535
+ reservation,
536
+ terminalStatus: reservation.status
537
+ };
538
+ }
314
539
  await repository.releaseReservationAtomic(userId, reservationId);
315
- if (reservation?.status === "reserved") {
316
- const credits = await repository.getUserCredits(userId);
317
- if (credits) {
318
- await repository.createJournalEntry({
319
- userId,
320
- entryType: "credit",
321
- amount: 0,
322
- // No actual credits returned (they were reserved, not spent)
323
- balanceAfter: credits.balance,
324
- source: "operation_release",
325
- referenceId: reservationId,
326
- referenceType: "reservation",
327
- description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
328
- metadata: {
329
- operationType: reservation.operationType,
330
- amount: reservation.amount
331
- }
332
- });
540
+ const credits = await repository.getUserCredits(userId);
541
+ const entry = await repository.createJournalEntry({
542
+ userId,
543
+ entryType: "credit",
544
+ // No credits changed hands — the hold just moved back into "available".
545
+ amount: 0,
546
+ balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
547
+ source: "operation_release",
548
+ referenceId: reservationId,
549
+ referenceType: "reservation",
550
+ description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
551
+ metadata: {
552
+ ...options?.metadata,
553
+ operationType: reservation.operationType,
554
+ amount: reservation.amount
333
555
  }
556
+ });
557
+ return {
558
+ outcome: "released",
559
+ reservation: { ...reservation, status: "released" },
560
+ amount: reservation.amount,
561
+ journalEntryId: entry.id
562
+ };
563
+ }
564
+
565
+ // src/core/operations.ts
566
+ async function commitReservationWithJournal(repository, userId, reservationId) {
567
+ const outcome = await commitThroughRepository(repository, userId, reservationId);
568
+ if (outcome.outcome === "not_found") {
569
+ throw createReservationNotFoundError(reservationId);
570
+ }
571
+ if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
572
+ throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
334
573
  }
335
574
  }
336
- async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3) {
575
+ async function releaseReservationWithJournal(repository, userId, reservationId) {
576
+ await releaseThroughRepository(repository, userId, reservationId);
577
+ }
578
+ async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3, idempotencyKey) {
337
579
  const expiresAt = new Date(Date.now() + expiryMs);
338
- return repository.reserveCreditsAtomic(userId, amount, operationType, expiresAt);
580
+ const outcome = await reserveThroughRepository(repository, {
581
+ userId,
582
+ amount,
583
+ operationType,
584
+ expiresAt,
585
+ idempotencyKey
586
+ });
587
+ if (outcome.outcome === "created" || outcome.outcome === "replayed") {
588
+ return outcome.reservation;
589
+ }
590
+ if (outcome.outcome === "insufficient") {
591
+ throw createInsufficientCreditsError(outcome.required, outcome.available);
592
+ }
593
+ throw createReservationAlreadyProcessedError(
594
+ outcome.existing.id,
595
+ outcome.existing.status
596
+ );
339
597
  }
340
598
 
341
599
  // src/adapters/generic.ts