@nehorai/credits 1.6.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +226 -2
  3. package/dist/adapters/index.cjs +298 -40
  4. package/dist/adapters/index.cjs.map +1 -1
  5. package/dist/adapters/index.d.cts +2 -2
  6. package/dist/adapters/index.d.ts +2 -2
  7. package/dist/adapters/index.js +298 -40
  8. package/dist/adapters/index.js.map +1 -1
  9. package/dist/core/index.cjs +696 -74
  10. package/dist/core/index.cjs.map +1 -1
  11. package/dist/core/index.d.cts +583 -5
  12. package/dist/core/index.d.ts +583 -5
  13. package/dist/core/index.js +648 -74
  14. package/dist/core/index.js.map +1 -1
  15. package/dist/index.cjs +1779 -364
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +41 -6
  18. package/dist/index.d.ts +41 -6
  19. package/dist/index.js +1723 -364
  20. package/dist/index.js.map +1 -1
  21. package/dist/repository/index.cjs +1413 -181
  22. package/dist/repository/index.cjs.map +1 -1
  23. package/dist/repository/index.d.cts +142 -16
  24. package/dist/repository/index.d.ts +142 -16
  25. package/dist/repository/index.js +1406 -181
  26. package/dist/repository/index.js.map +1 -1
  27. package/dist/sdk/index.cjs +23 -1
  28. package/dist/sdk/index.cjs.map +1 -1
  29. package/dist/sdk/index.d.cts +2 -2
  30. package/dist/sdk/index.d.ts +2 -2
  31. package/dist/sdk/index.js +23 -1
  32. package/dist/sdk/index.js.map +1 -1
  33. package/dist/service/index.cjs +483 -105
  34. package/dist/service/index.cjs.map +1 -1
  35. package/dist/service/index.d.cts +70 -10
  36. package/dist/service/index.d.ts +70 -10
  37. package/dist/service/index.js +483 -105
  38. package/dist/service/index.js.map +1 -1
  39. package/dist/types-BmG5Omzj.d.ts +646 -0
  40. package/dist/types-C8hZMe8a.d.cts +646 -0
  41. package/dist/{types-CjLj3GW5.d.cts → types-DJ8yqaVr.d.cts} +39 -1
  42. package/dist/{types-CjLj3GW5.d.ts → types-DJ8yqaVr.d.ts} +39 -1
  43. package/package.json +9 -10
  44. package/dist/types-CZNv1U3e.d.cts +0 -305
  45. package/dist/types-G4WK0rM-.d.ts +0 -305
@@ -1,35 +1,3 @@
1
- // src/core/types.ts
2
- function calculateAvailableCredits(balance, bonusCredits, reserved) {
3
- return balance + bonusCredits - reserved;
4
- }
5
- function toPortableTimestamp(value) {
6
- if (!value) {
7
- return (/* @__PURE__ */ new Date()).toISOString();
8
- }
9
- if (typeof value === "string") {
10
- return value;
11
- }
12
- if (value instanceof Date) {
13
- return value.toISOString();
14
- }
15
- if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
16
- return value.toDate().toISOString();
17
- }
18
- return (/* @__PURE__ */ new Date()).toISOString();
19
- }
20
- function toDate(value) {
21
- if (value instanceof Date) {
22
- return value;
23
- }
24
- if (typeof value === "string") {
25
- return new Date(value);
26
- }
27
- if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
28
- return value.toDate();
29
- }
30
- return /* @__PURE__ */ new Date();
31
- }
32
-
33
1
  // src/core/errors.ts
34
2
  var CreditErrorCode = {
35
3
  INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
@@ -39,7 +7,32 @@ var CreditErrorCode = {
39
7
  USER_NOT_FOUND: "USER_NOT_FOUND",
40
8
  INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
41
9
  CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
42
- DATABASE_ERROR: "DATABASE_ERROR"
10
+ DATABASE_ERROR: "DATABASE_ERROR",
11
+ /** Caller reused an idempotency key with a different immutable payload. */
12
+ IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT",
13
+ /**
14
+ * Retryable infrastructure failure — serialization/deadlock, lock timeout,
15
+ * connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
16
+ * a deterministic failure that will fail again identically on retry.
17
+ */
18
+ TRANSIENT_ERROR: "TRANSIENT_ERROR",
19
+ /** The configured repository adapter does not implement the requested V2 method. */
20
+ UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
21
+ /** Amount was not a finite positive number. */
22
+ INVALID_AMOUNT: "INVALID_AMOUNT",
23
+ /** Idempotency key was supplied but empty or whitespace-only. */
24
+ INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY",
25
+ /**
26
+ * A persisted reservation holds a status this library does not define, so no
27
+ * transition can reason about it. Quarantined rather than cast; nothing moved.
28
+ */
29
+ CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS",
30
+ /**
31
+ * A persisted reservation does not record that its hold was placed
32
+ * atomically, so the credits it claims are not known to be reserved. Moving
33
+ * it would spend another hold's coverage; nothing moved.
34
+ */
35
+ UNBACKED_RESERVATION: "UNBACKED_RESERVATION"
43
36
  };
44
37
  var CreditError = class _CreditError extends Error {
45
38
  code;
@@ -81,7 +74,7 @@ function createInsufficientCreditsError(required, available) {
81
74
  return new CreditError(
82
75
  `Insufficient credits: available ${available}, required ${required}`,
83
76
  CreditErrorCode.INSUFFICIENT_CREDITS,
84
- { required, available, shortfall: required - available }
77
+ { required, available, shortfall: sumAmounts(required, -available) }
85
78
  );
86
79
  }
87
80
  function createReservationNotFoundError(reservationId) {
@@ -119,6 +112,435 @@ function createInvalidOperationTypeError(operationType, validTypes) {
119
112
  { operationType, validTypes }
120
113
  );
121
114
  }
115
+ function createIdempotencyConflictError(idempotencyKey, details) {
116
+ return new CreditError(
117
+ `Idempotency key ${idempotencyKey} was reused with a different payload`,
118
+ CreditErrorCode.IDEMPOTENCY_CONFLICT,
119
+ { idempotencyKey, ...details }
120
+ );
121
+ }
122
+ function createTransientError(message, details) {
123
+ return new CreditError(message, CreditErrorCode.TRANSIENT_ERROR, details);
124
+ }
125
+ function createUnsupportedOperationError(operation, hint) {
126
+ return new CreditError(
127
+ hint ? `Repository does not support ${operation}. ${hint}` : `Repository does not support ${operation}`,
128
+ CreditErrorCode.UNSUPPORTED_OPERATION,
129
+ { operation, ...hint ? { hint } : {} }
130
+ );
131
+ }
132
+ function createInvalidAmountError(amount) {
133
+ return new CreditError(
134
+ `Credit amount must be a finite positive number (got ${String(amount)})`,
135
+ CreditErrorCode.INVALID_AMOUNT,
136
+ { amount }
137
+ );
138
+ }
139
+ function isIdempotencyConflictError(error) {
140
+ return isCreditError(error) && error.code === CreditErrorCode.IDEMPOTENCY_CONFLICT;
141
+ }
142
+ function isTransientError(error) {
143
+ return isCreditError(error) && error.code === CreditErrorCode.TRANSIENT_ERROR;
144
+ }
145
+
146
+ // src/core/amount.ts
147
+ var CREDIT_AMOUNT_SCALE = 2;
148
+ var CREDIT_AMOUNT_MAX = 999999999999e-2;
149
+ var MAX_CENTS = 999999999999;
150
+ function toCents(value) {
151
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
152
+ const cents = Math.round(value * 100);
153
+ if (!Number.isSafeInteger(cents)) return null;
154
+ return value === cents / 100 ? cents : null;
155
+ }
156
+ function sumAmounts(...values) {
157
+ let cents = 0;
158
+ for (const value of values) {
159
+ const valueCents = toCents(value);
160
+ if (valueCents === null) return floatSum(values);
161
+ cents += valueCents;
162
+ }
163
+ return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);
164
+ }
165
+ function floatSum(values) {
166
+ let total = 0;
167
+ for (const value of values) total += value;
168
+ return total;
169
+ }
170
+ function isValidCreditAmount(value) {
171
+ if (typeof value !== "number") return false;
172
+ const cents = toCents(value);
173
+ return cents !== null && cents > 0 && cents <= MAX_CENTS;
174
+ }
175
+ function assertValidCreditAmount(value, context) {
176
+ if (isValidCreditAmount(value)) return;
177
+ throw new CreditError(
178
+ creditAmountReason(value),
179
+ CreditErrorCode.INVALID_AMOUNT,
180
+ { amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
181
+ );
182
+ }
183
+ function creditAmountReason(value) {
184
+ if (typeof value !== "number") {
185
+ return `Credit amount must be a number (got ${typeof value})`;
186
+ }
187
+ if (!Number.isFinite(value)) {
188
+ return `Credit amount must be finite (got ${String(value)})`;
189
+ }
190
+ if (value <= 0) {
191
+ return `Credit amount must be greater than zero (got ${value})`;
192
+ }
193
+ if (value > CREDIT_AMOUNT_MAX) {
194
+ return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;
195
+ }
196
+ return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;
197
+ }
198
+ function isRepresentableAmount(value) {
199
+ if (typeof value !== "number") return false;
200
+ const cents = toCents(value);
201
+ return cents !== null && Math.abs(cents) <= MAX_CENTS;
202
+ }
203
+ function assertRepresentableAmount(value, field, context) {
204
+ if (isRepresentableAmount(value)) return;
205
+ throw new CreditError(
206
+ `${field} must be a finite number exact to ${CREDIT_AMOUNT_SCALE} decimal places within \xB1${CREDIT_AMOUNT_MAX} (got ${String(value)})`,
207
+ CreditErrorCode.INVALID_AMOUNT,
208
+ { field, value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
209
+ );
210
+ }
211
+ function assertRepresentableFields(fields, context) {
212
+ for (const [field, value] of Object.entries(fields)) {
213
+ if (value === void 0) continue;
214
+ assertRepresentableAmount(value, field, context);
215
+ }
216
+ }
217
+ function backedBalanceFloor(reserved, bonusCredits) {
218
+ const floor = Math.max(0, sumAmounts(reserved, -bonusCredits));
219
+ assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
220
+ return floor;
221
+ }
222
+ function assertRepresentableTierAmount(value, field, context) {
223
+ if (value === Number.POSITIVE_INFINITY) return;
224
+ assertRepresentableAmount(value, field, context);
225
+ }
226
+ function storedMonthlyLimit(limit) {
227
+ return Number.isFinite(limit) ? limit : CREDIT_AMOUNT_MAX;
228
+ }
229
+ function assertValidStoredAmountRaw(raw, context) {
230
+ const cents = numericToCents(raw);
231
+ if (cents !== null && cents > 0n && cents <= BigInt(MAX_CENTS)) {
232
+ return Number(cents) / 100;
233
+ }
234
+ throw new CreditError(
235
+ `Stored credit amount ${String(raw)} is not a valid amount, so this transition was refused before any state changed. The row must be repaired before it can be committed, released or expired.`,
236
+ CreditErrorCode.INVALID_AMOUNT,
237
+ {
238
+ amount: typeof raw === "string" ? raw : String(raw),
239
+ reason: "corrupt_stored_amount",
240
+ max: CREDIT_AMOUNT_MAX,
241
+ scale: CREDIT_AMOUNT_SCALE,
242
+ ...context
243
+ }
244
+ );
245
+ }
246
+ function assertValidStoredAmount(value, context) {
247
+ if (isValidCreditAmount(value)) return;
248
+ throw new CreditError(
249
+ `Stored credit amount ${String(value)} is not a valid amount, so this transition was refused before any state changed. The row must be repaired before it can be committed, released or expired.`,
250
+ CreditErrorCode.INVALID_AMOUNT,
251
+ { amount: value, reason: "corrupt_stored_amount", max: CREDIT_AMOUNT_MAX, ...context }
252
+ );
253
+ }
254
+ function numericToCents(value) {
255
+ if (typeof value === "number") {
256
+ const cents2 = toCents(value);
257
+ return cents2 === null ? null : BigInt(cents2);
258
+ }
259
+ if (typeof value !== "string") return null;
260
+ const match = /^([+-]?)(\d+)(?:\.(\d*))?$/.exec(value.trim());
261
+ if (!match) return null;
262
+ const [, sign, whole, fraction = ""] = match;
263
+ if (/[1-9]/.test(fraction.slice(CREDIT_AMOUNT_SCALE))) return null;
264
+ const cents = BigInt(whole) * 100n + BigInt(fraction.slice(0, CREDIT_AMOUNT_SCALE).padEnd(CREDIT_AMOUNT_SCALE, "0"));
265
+ return sign === "-" ? -cents : cents;
266
+ }
267
+ function sameAmount(a, b) {
268
+ const left = numericToCents(a);
269
+ const right = numericToCents(b);
270
+ return left !== null && right !== null && left === right;
271
+ }
272
+
273
+ // src/core/types.ts
274
+ function calculateAvailableCredits(balance, bonusCredits, reserved) {
275
+ return sumAmounts(balance, bonusCredits, -reserved);
276
+ }
277
+ function toPortableTimestamp(value) {
278
+ if (!value) {
279
+ return (/* @__PURE__ */ new Date()).toISOString();
280
+ }
281
+ if (typeof value === "string") {
282
+ return value;
283
+ }
284
+ if (value instanceof Date) {
285
+ return value.toISOString();
286
+ }
287
+ if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
288
+ return value.toDate().toISOString();
289
+ }
290
+ return (/* @__PURE__ */ new Date()).toISOString();
291
+ }
292
+ function toDate(value) {
293
+ if (value instanceof Date) {
294
+ return value;
295
+ }
296
+ if (typeof value === "string") {
297
+ return new Date(value);
298
+ }
299
+ if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
300
+ return value.toDate();
301
+ }
302
+ return /* @__PURE__ */ new Date();
303
+ }
304
+
305
+ // src/core/outcomes.ts
306
+ function isCreditedOutcome(outcome) {
307
+ return outcome.outcome === "created";
308
+ }
309
+ function isWinningOutcome(outcome) {
310
+ return outcome.outcome === "committed" || outcome.outcome === "released" || outcome.outcome === "expired";
311
+ }
312
+ function isReservedOutcome(outcome) {
313
+ return outcome.outcome === "created" || outcome.outcome === "replayed";
314
+ }
315
+
316
+ // src/core/reservation-integrity.ts
317
+ var RESERVATION_STATUSES = [
318
+ "reserved",
319
+ "committed",
320
+ "released",
321
+ "expired"
322
+ ];
323
+ var TERMINAL_RESERVATION_STATUSES = [
324
+ "committed",
325
+ "released",
326
+ "expired"
327
+ ];
328
+ function isReservationStatus(value) {
329
+ return RESERVATION_STATUSES.includes(value);
330
+ }
331
+ function isTerminalReservationStatus(value) {
332
+ return TERMINAL_RESERVATION_STATUSES.includes(value);
333
+ }
334
+ function assertKnownReservationStatus(reservation, transition) {
335
+ if (isReservationStatus(reservation.status)) return;
336
+ throw new CreditError(
337
+ `Reservation ${reservation.id} holds the status ${JSON.stringify(reservation.status)}, which is not one of ${RESERVATION_STATUSES.join(", ")}. The ${transition} was refused before any state changed; the row must be repaired by an operator.`,
338
+ CreditErrorCode.CORRUPT_RESERVATION_STATUS,
339
+ {
340
+ userId: reservation.userId,
341
+ reservationId: reservation.id,
342
+ transition,
343
+ status: typeof reservation.status === "string" ? reservation.status : String(reservation.status),
344
+ allowed: [...RESERVATION_STATUSES],
345
+ reason: "corrupt_reservation_status"
346
+ }
347
+ );
348
+ }
349
+ function terminalStatusOf(reservation, transition) {
350
+ assertKnownReservationStatus(reservation, transition);
351
+ if (isTerminalReservationStatus(reservation.status)) return reservation.status;
352
+ throw new CreditError(
353
+ `Reservation ${reservation.id} is ${reservation.status}, which is not a terminal status, but the ${transition} tried to report it as one.`,
354
+ CreditErrorCode.CORRUPT_RESERVATION_STATUS,
355
+ {
356
+ userId: reservation.userId,
357
+ reservationId: reservation.id,
358
+ transition,
359
+ status: reservation.status,
360
+ reason: "not_terminal"
361
+ }
362
+ );
363
+ }
364
+ function assertHoldPlaced(reservation, transition) {
365
+ if (reservation.holdPlacedAt) return;
366
+ throw new CreditError(
367
+ `Reservation ${reservation.id} does not record that its hold was placed atomically (holdPlacedAt is absent), so the credits it claims to hold may never have been reserved. The ${transition} was refused before any state changed: honouring it would spend coverage belonging to another reservation. Rows written by createReservation are not V2 reservations; place holds with reserveCredits.`,
368
+ CreditErrorCode.UNBACKED_RESERVATION,
369
+ {
370
+ userId: reservation.userId,
371
+ reservationId: reservation.id,
372
+ transition,
373
+ idempotencyKey: reservation.idempotencyKey ?? void 0,
374
+ reason: "hold_not_placed"
375
+ }
376
+ );
377
+ }
378
+ function assertUnkeyedDirectReservation(input) {
379
+ if (input.idempotencyKey === void 0 || input.idempotencyKey === null) return;
380
+ throw new CreditError(
381
+ "createReservation cannot accept an idempotencyKey: it writes the reservation row without placing the hold, so the key would name credits that were never reserved, and a later reserveCredits with the same key would adopt it as a replay. Call reserveCredits (or reserveCreditsV2) with the key instead \u2014 it claims the key and places the hold in one transaction.",
382
+ CreditErrorCode.UNSUPPORTED_OPERATION,
383
+ {
384
+ userId: input.userId,
385
+ idempotencyKey: input.idempotencyKey,
386
+ operation: "createReservation",
387
+ reason: "keyed_direct_reservation"
388
+ }
389
+ );
390
+ }
391
+ function hasPlacedHold(reservation) {
392
+ return Boolean(reservation.holdPlacedAt);
393
+ }
394
+ function assertDirectStatusWriteAllowed(reservation, status) {
395
+ if (status === "reserved") {
396
+ throw new CreditError(
397
+ `Reservation ${reservation.id} cannot have its status set back to "reserved": updateReservationStatus does not place a hold, so the reopened row would claim credits nothing reserved. Create a new reservation with reserveCredits instead.`,
398
+ CreditErrorCode.UNSUPPORTED_OPERATION,
399
+ {
400
+ userId: reservation.userId,
401
+ reservationId: reservation.id,
402
+ status,
403
+ reason: "reopen_reservation"
404
+ }
405
+ );
406
+ }
407
+ if (!reservation.holdPlacedAt) return;
408
+ throw new CreditError(
409
+ `Reservation ${reservation.id} is backed by an atomically placed hold, so its status may only be assigned by the transition that settles it. Writing "${status}" directly would change the status without moving the credits it stands for, stranding the hold. Use commitReservation / releaseReservation / expireReservation (or their V2 forms) instead.`,
410
+ CreditErrorCode.UNSUPPORTED_OPERATION,
411
+ {
412
+ userId: reservation.userId,
413
+ reservationId: reservation.id,
414
+ status,
415
+ reason: "direct_status_write_on_backed_hold"
416
+ }
417
+ );
418
+ }
419
+
420
+ // src/core/payment-ref.ts
421
+ function normalizePaymentRef(ref) {
422
+ if (typeof ref !== "string") return void 0;
423
+ const trimmed = ref.trim();
424
+ return trimmed.length > 0 ? trimmed : void 0;
425
+ }
426
+ function describePaymentMismatch(stored, expected) {
427
+ if (stored.userId !== expected.userId) return "userId";
428
+ if (!sameAmount(stored.amount, expected.amount)) return "amount";
429
+ if (stored.type !== expected.type) return "type";
430
+ if (stored.source !== void 0 && stored.source !== expected.source) return "source";
431
+ if (stored.referenceType !== void 0 && stored.referenceType !== expected.referenceType) {
432
+ return "referenceType";
433
+ }
434
+ return null;
435
+ }
436
+ function assertUnreferencedDirectTransaction(input) {
437
+ if (normalizePaymentRef(input.paymentRef) === void 0) return;
438
+ throw new CreditError(
439
+ "createTransaction cannot accept a paymentRef: it records a transaction without crediting the account, and the reference would then be treated as an already-delivered payment \u2014 a later addCredits call with the same reference reports `replayed` and credits nothing. Record unreferenced transactions here, or deliver referenced payments through addCredits (addCreditsAtomic / addCreditsV2), which claims the reference and moves the balance atomically.",
440
+ CreditErrorCode.UNSUPPORTED_OPERATION,
441
+ {
442
+ userId: input.userId,
443
+ paymentRef: input.paymentRef,
444
+ operation: "createTransaction",
445
+ reason: "referenced_direct_transaction"
446
+ }
447
+ );
448
+ }
449
+ function createPaymentRefConflictError(paymentRef, details) {
450
+ return new CreditError(
451
+ `Payment reference ${paymentRef} has already been used for a different credit event (${details.mismatch} differs). Nothing was credited. A payment reference identifies one credit event globally; reusing it for another user, amount or source is refused rather than silently ignored.`,
452
+ CreditErrorCode.IDEMPOTENCY_CONFLICT,
453
+ { paymentRef, reason: "payment_ref_conflict", ...details }
454
+ );
455
+ }
456
+
457
+ // src/core/idempotency.ts
458
+ function isValidIdempotencyKey(value) {
459
+ return typeof value === "string" && value.trim().length > 0;
460
+ }
461
+ function assertValidIdempotencyKey(value, context) {
462
+ if (value === void 0 || value === null) return;
463
+ if (isValidIdempotencyKey(value)) return;
464
+ throw new CreditError(
465
+ typeof value === "string" ? "An idempotency key was supplied but is empty or whitespace-only. It cannot deduplicate anything, so it was rejected rather than stored as a key that never matches. Omit the key for at-least-once behaviour, or send a meaningful one." : `Idempotency key must be a string (got ${typeof value})`,
466
+ CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
467
+ { idempotencyKey: value, ...context }
468
+ );
469
+ }
470
+ var RESERVED_JOURNAL_KEY_PREFIX = "reservation:";
471
+ function isReservedJournalKey(key) {
472
+ return typeof key === "string" && key.startsWith(RESERVED_JOURNAL_KEY_PREFIX);
473
+ }
474
+ function assertPublicJournalKey(key, userId) {
475
+ if (!isReservedJournalKey(key)) return;
476
+ throw new CreditError(
477
+ `Idempotency keys starting with "${RESERVED_JOURNAL_KEY_PREFIX}" are reserved for the V2 reservation transitions and cannot be written directly. Use a key of your own, or commit/release/expire the reservation through the V2 methods.`,
478
+ CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
479
+ { userId, idempotencyKey: key, reason: "reserved_namespace" }
480
+ );
481
+ }
482
+
483
+ // src/core/error-classify.ts
484
+ var TRANSIENT_SQLSTATES = /* @__PURE__ */ new Set([
485
+ "40000",
486
+ "40001",
487
+ "40P01",
488
+ "55P03",
489
+ "57014",
490
+ "57P01",
491
+ "57P02",
492
+ "57P03",
493
+ "53200",
494
+ "53300",
495
+ "53400"
496
+ ]);
497
+ var TRANSIENT_DRIVER_CODES = /* @__PURE__ */ new Set([
498
+ "ECONNRESET",
499
+ "ECONNREFUSED",
500
+ "EPIPE",
501
+ "ETIMEDOUT",
502
+ "ENOTFOUND",
503
+ "EHOSTUNREACH"
504
+ ]);
505
+ var NUMERIC_OVERFLOW = "22003";
506
+ function getSqlState(error) {
507
+ const seen = /* @__PURE__ */ new Set();
508
+ let current = error;
509
+ for (let depth = 0; depth < 8; depth += 1) {
510
+ if (typeof current !== "object" || current === null || seen.has(current)) return void 0;
511
+ seen.add(current);
512
+ const code = current.code;
513
+ if (typeof code === "string") return code;
514
+ current = current.cause;
515
+ }
516
+ return void 0;
517
+ }
518
+ function isUniqueViolation(error) {
519
+ return getSqlState(error) === "23505";
520
+ }
521
+ function isTransientDatabaseError(error) {
522
+ const code = getSqlState(error);
523
+ if (!code) return false;
524
+ if (TRANSIENT_SQLSTATES.has(code)) return true;
525
+ if (TRANSIENT_DRIVER_CODES.has(code)) return true;
526
+ return code.startsWith("08");
527
+ }
528
+ function classifyDatabaseError(error, context) {
529
+ if (isCreditError(error)) return error;
530
+ const sqlState = getSqlState(error);
531
+ const message = error instanceof Error ? error.message : String(error);
532
+ const details = { ...context, ...sqlState ? { sqlState } : {} };
533
+ if (isTransientDatabaseError(error)) {
534
+ return new CreditError(message, CreditErrorCode.TRANSIENT_ERROR, details);
535
+ }
536
+ if (sqlState === NUMERIC_OVERFLOW) {
537
+ return new CreditError(message, CreditErrorCode.INVALID_AMOUNT, {
538
+ ...details,
539
+ reason: "amount_out_of_range"
540
+ });
541
+ }
542
+ return new CreditError(message, CreditErrorCode.DATABASE_ERROR, details);
543
+ }
122
544
 
123
545
  // src/core/deferred.ts
124
546
  var genericDeferred = {
@@ -409,77 +831,229 @@ function getOperationLabel(operationType) {
409
831
  }
410
832
  initializeConfig();
411
833
 
412
- // src/core/operations.ts
413
- async function commitReservationWithJournal(repository, userId, reservationId) {
834
+ // src/repository/types.ts
835
+ function supportsCreditsV2(repository) {
836
+ return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
837
+ }
838
+
839
+ // src/repository/flow.ts
840
+ async function reserveThroughRepository(repository, input) {
841
+ const v2 = supportsCreditsV2(repository);
842
+ if (!v2 && input.idempotencyKey !== void 0) {
843
+ throw createUnsupportedOperationError(
844
+ "reserveCredits with an idempotencyKey",
845
+ "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."
846
+ );
847
+ }
848
+ assertValidCreditAmount(input.amount, { userId: input.userId });
849
+ if (v2) {
850
+ return repository.reserveCreditsV2(input);
851
+ }
852
+ try {
853
+ const reservation = await repository.reserveCreditsAtomic(
854
+ input.userId,
855
+ input.amount,
856
+ input.operationType,
857
+ input.expiresAt
858
+ );
859
+ return { outcome: "created", reservation };
860
+ } catch (error) {
861
+ if (!isInsufficientCreditsError(error)) throw error;
862
+ const credits = await repository.getUserCredits(input.userId);
863
+ const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
864
+ return {
865
+ outcome: "insufficient",
866
+ available,
867
+ required: input.amount,
868
+ shortfall: Math.max(sumAmounts(input.amount, -available), 0)
869
+ };
870
+ }
871
+ }
872
+ async function commitThroughRepository(repository, userId, reservationId, options) {
873
+ if (supportsCreditsV2(repository)) {
874
+ return repository.commitReservationV2(userId, reservationId, options);
875
+ }
414
876
  const reservation = await repository.getReservation(userId, reservationId);
415
- if (!reservation) {
416
- throw new Error(`Reservation ${reservationId} not found`);
877
+ if (!reservation) return { outcome: "not_found", reservationId };
878
+ if (reservation.status !== "reserved") {
879
+ return {
880
+ outcome: "already_terminal",
881
+ reservation,
882
+ terminalStatus: reservation.status
883
+ };
417
884
  }
418
885
  await repository.commitReservationAtomic(userId, reservationId);
419
886
  const credits = await repository.getUserCredits(userId);
420
- if (credits) {
421
- await repository.createJournalEntry({
422
- userId,
423
- entryType: "debit",
424
- amount: reservation.amount,
425
- balanceAfter: credits.balance,
426
- source: "operation_commit",
427
- referenceId: reservationId,
428
- referenceType: "reservation",
429
- description: `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
430
- metadata: {
431
- operationType: reservation.operationType
432
- }
433
- });
434
- }
887
+ const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
888
+ const entry = await repository.createJournalEntry({
889
+ userId,
890
+ entryType: "debit",
891
+ amount: reservation.amount,
892
+ balanceAfter,
893
+ source: "operation_commit",
894
+ referenceId: reservationId,
895
+ referenceType: "reservation",
896
+ description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
897
+ // Deterministic fields last: they carry the identity of the event, and a
898
+ // caller who happens to send an `operationType` or an `amount` must not
899
+ // relabel what actually moved.
900
+ metadata: {
901
+ ...options?.metadata,
902
+ operationType: reservation.operationType,
903
+ amount: reservation.amount
904
+ }
905
+ });
906
+ return {
907
+ outcome: "committed",
908
+ reservation: { ...reservation, status: "committed" },
909
+ amount: reservation.amount,
910
+ balanceAfter,
911
+ journalEntryId: entry.id
912
+ };
435
913
  }
436
- async function releaseReservationWithJournal(repository, userId, reservationId) {
914
+ async function releaseThroughRepository(repository, userId, reservationId, options) {
915
+ if (supportsCreditsV2(repository)) {
916
+ return repository.releaseReservationV2(userId, reservationId, options);
917
+ }
437
918
  const reservation = await repository.getReservation(userId, reservationId);
919
+ if (!reservation) {
920
+ await repository.releaseReservationAtomic(userId, reservationId);
921
+ return { outcome: "not_found", reservationId };
922
+ }
923
+ if (reservation.status !== "reserved") {
924
+ await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
925
+ return {
926
+ outcome: "already_terminal",
927
+ reservation,
928
+ terminalStatus: reservation.status
929
+ };
930
+ }
438
931
  await repository.releaseReservationAtomic(userId, reservationId);
439
- if (reservation?.status === "reserved") {
440
- const credits = await repository.getUserCredits(userId);
441
- if (credits) {
442
- await repository.createJournalEntry({
443
- userId,
444
- entryType: "credit",
445
- amount: 0,
446
- // No actual credits returned (they were reserved, not spent)
447
- balanceAfter: credits.balance,
448
- source: "operation_release",
449
- referenceId: reservationId,
450
- referenceType: "reservation",
451
- description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
452
- metadata: {
453
- operationType: reservation.operationType,
454
- amount: reservation.amount
455
- }
456
- });
932
+ const credits = await repository.getUserCredits(userId);
933
+ const entry = await repository.createJournalEntry({
934
+ userId,
935
+ entryType: "credit",
936
+ // No credits changed hands — the hold just moved back into "available".
937
+ amount: 0,
938
+ balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
939
+ source: "operation_release",
940
+ referenceId: reservationId,
941
+ referenceType: "reservation",
942
+ description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
943
+ metadata: {
944
+ ...options?.metadata,
945
+ operationType: reservation.operationType,
946
+ amount: reservation.amount
457
947
  }
948
+ });
949
+ return {
950
+ outcome: "released",
951
+ reservation: { ...reservation, status: "released" },
952
+ amount: reservation.amount,
953
+ journalEntryId: entry.id
954
+ };
955
+ }
956
+
957
+ // src/core/operations.ts
958
+ async function commitReservationWithJournal(repository, userId, reservationId) {
959
+ const outcome = await commitThroughRepository(repository, userId, reservationId);
960
+ if (outcome.outcome === "not_found") {
961
+ throw createReservationNotFoundError(reservationId);
458
962
  }
963
+ if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
964
+ throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
965
+ }
966
+ }
967
+ async function releaseReservationWithJournal(repository, userId, reservationId) {
968
+ await releaseThroughRepository(repository, userId, reservationId);
459
969
  }
460
- async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3) {
970
+ async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3, idempotencyKey) {
461
971
  const expiresAt = new Date(Date.now() + expiryMs);
462
- return repository.reserveCreditsAtomic(userId, amount, operationType, expiresAt);
972
+ const outcome = await reserveThroughRepository(repository, {
973
+ userId,
974
+ amount,
975
+ operationType,
976
+ expiresAt,
977
+ idempotencyKey
978
+ });
979
+ if (outcome.outcome === "created" || outcome.outcome === "replayed") {
980
+ return outcome.reservation;
981
+ }
982
+ if (outcome.outcome === "insufficient") {
983
+ throw createInsufficientCreditsError(outcome.required, outcome.available);
984
+ }
985
+ throw createReservationAlreadyProcessedError(
986
+ outcome.existing.id,
987
+ outcome.existing.status
988
+ );
463
989
  }
464
990
  export {
991
+ CREDIT_AMOUNT_MAX,
992
+ CREDIT_AMOUNT_SCALE,
465
993
  CreditError,
466
994
  CreditErrorCode,
995
+ RESERVATION_STATUSES,
996
+ RESERVED_JOURNAL_KEY_PREFIX,
997
+ TERMINAL_RESERVATION_STATUSES,
998
+ assertDirectStatusWriteAllowed,
999
+ assertHoldPlaced,
1000
+ assertKnownReservationStatus,
1001
+ assertPublicJournalKey,
1002
+ assertRepresentableAmount,
1003
+ assertRepresentableFields,
1004
+ assertRepresentableTierAmount,
1005
+ assertUnkeyedDirectReservation,
1006
+ assertUnreferencedDirectTransaction,
1007
+ assertValidCreditAmount,
1008
+ assertValidIdempotencyKey,
1009
+ assertValidStoredAmount,
1010
+ assertValidStoredAmountRaw,
1011
+ backedBalanceFloor,
467
1012
  calculateAvailableCredits,
1013
+ classifyDatabaseError,
468
1014
  commitReservationWithJournal,
469
1015
  createDeferredExecutor,
1016
+ createIdempotencyConflictError,
470
1017
  createInsufficientCreditsError,
1018
+ createInvalidAmountError,
471
1019
  createInvalidOperationTypeError,
1020
+ createPaymentRefConflictError,
472
1021
  createReservationAlreadyProcessedError,
473
1022
  createReservationExpiredError,
474
1023
  createReservationNotFoundError,
1024
+ createTransientError,
1025
+ createUnsupportedOperationError,
475
1026
  createUserNotFoundError,
1027
+ describePaymentMismatch,
476
1028
  genericDeferred,
1029
+ getSqlState,
1030
+ hasPlacedHold,
477
1031
  isCreditError,
1032
+ isCreditedOutcome,
1033
+ isIdempotencyConflictError,
478
1034
  isInsufficientCreditsError,
1035
+ isRepresentableAmount,
1036
+ isReservationStatus,
1037
+ isReservedJournalKey,
1038
+ isReservedOutcome,
1039
+ isTerminalReservationStatus,
1040
+ isTransientDatabaseError,
1041
+ isTransientError,
1042
+ isUniqueViolation,
1043
+ isValidCreditAmount,
1044
+ isValidIdempotencyKey,
1045
+ isWinningOutcome,
479
1046
  noopDeferred,
1047
+ normalizePaymentRef,
1048
+ numericToCents,
480
1049
  releaseReservationWithJournal,
481
1050
  reserveCreditsForOperation,
1051
+ sameAmount,
1052
+ storedMonthlyLimit,
1053
+ sumAmounts,
482
1054
  synchronousDeferred,
1055
+ terminalStatusOf,
1056
+ toCents,
483
1057
  toDate,
484
1058
  toPortableTimestamp
485
1059
  };