@nehorai/credits 1.7.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.
Files changed (47) hide show
  1. package/README.md +384 -153
  2. package/dist/adapters/index.cjs +308 -46
  3. package/dist/adapters/index.cjs.map +1 -1
  4. package/dist/adapters/index.d.cts +3 -2
  5. package/dist/adapters/index.d.ts +3 -2
  6. package/dist/adapters/index.js +308 -46
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/auth/index.cjs.map +1 -1
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/core/index.cjs +700 -76
  11. package/dist/core/index.cjs.map +1 -1
  12. package/dist/core/index.d.cts +583 -5
  13. package/dist/core/index.d.ts +583 -5
  14. package/dist/core/index.js +652 -76
  15. package/dist/core/index.js.map +1 -1
  16. package/dist/index.cjs +1772 -371
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +44 -7
  19. package/dist/index.d.ts +44 -7
  20. package/dist/index.js +1716 -371
  21. package/dist/index.js.map +1 -1
  22. package/dist/repository/index.cjs +1403 -185
  23. package/dist/repository/index.cjs.map +1 -1
  24. package/dist/repository/index.d.cts +141 -15
  25. package/dist/repository/index.d.ts +141 -15
  26. package/dist/repository/index.js +1396 -185
  27. package/dist/repository/index.js.map +1 -1
  28. package/dist/sdk/index.cjs +23 -1
  29. package/dist/sdk/index.cjs.map +1 -1
  30. package/dist/sdk/index.d.cts +2 -2
  31. package/dist/sdk/index.d.ts +2 -2
  32. package/dist/sdk/index.js +23 -1
  33. package/dist/sdk/index.js.map +1 -1
  34. package/dist/service/index.cjs +484 -106
  35. package/dist/service/index.cjs.map +1 -1
  36. package/dist/service/index.d.cts +68 -10
  37. package/dist/service/index.d.ts +68 -10
  38. package/dist/service/index.js +484 -106
  39. package/dist/service/index.js.map +1 -1
  40. package/dist/types-BhCdep_t.d.ts +646 -0
  41. package/dist/{types-CjLj3GW5.d.cts → types-Dzc443vy.d.cts} +44 -2
  42. package/dist/{types-CjLj3GW5.d.ts → types-Dzc443vy.d.ts} +44 -2
  43. package/dist/types-wtksMa4n.d.cts +646 -0
  44. package/package.json +71 -70
  45. package/LICENSE +0 -21
  46. package/dist/types-DFe5BoJY.d.cts +0 -329
  47. package/dist/types-_bSybQnb.d.ts +0 -329
@@ -20,61 +20,77 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/core/index.ts
21
21
  var core_exports = {};
22
22
  __export(core_exports, {
23
+ CREDIT_AMOUNT_MAX: () => CREDIT_AMOUNT_MAX,
24
+ CREDIT_AMOUNT_SCALE: () => CREDIT_AMOUNT_SCALE,
23
25
  CreditError: () => CreditError,
24
26
  CreditErrorCode: () => CreditErrorCode,
27
+ RESERVATION_STATUSES: () => RESERVATION_STATUSES,
28
+ RESERVED_JOURNAL_KEY_PREFIX: () => RESERVED_JOURNAL_KEY_PREFIX,
29
+ TERMINAL_RESERVATION_STATUSES: () => TERMINAL_RESERVATION_STATUSES,
30
+ assertDirectStatusWriteAllowed: () => assertDirectStatusWriteAllowed,
31
+ assertHoldPlaced: () => assertHoldPlaced,
32
+ assertKnownReservationStatus: () => assertKnownReservationStatus,
33
+ assertPublicJournalKey: () => assertPublicJournalKey,
34
+ assertRepresentableAmount: () => assertRepresentableAmount,
35
+ assertRepresentableFields: () => assertRepresentableFields,
36
+ assertRepresentableTierAmount: () => assertRepresentableTierAmount,
37
+ assertUnkeyedDirectReservation: () => assertUnkeyedDirectReservation,
38
+ assertUnreferencedDirectTransaction: () => assertUnreferencedDirectTransaction,
39
+ assertValidCreditAmount: () => assertValidCreditAmount,
40
+ assertValidIdempotencyKey: () => assertValidIdempotencyKey,
41
+ assertValidStoredAmount: () => assertValidStoredAmount,
42
+ assertValidStoredAmountRaw: () => assertValidStoredAmountRaw,
43
+ backedBalanceFloor: () => backedBalanceFloor,
25
44
  calculateAvailableCredits: () => calculateAvailableCredits,
45
+ classifyDatabaseError: () => classifyDatabaseError,
26
46
  commitReservationWithJournal: () => commitReservationWithJournal,
27
47
  createDeferredExecutor: () => createDeferredExecutor,
48
+ createIdempotencyConflictError: () => createIdempotencyConflictError,
28
49
  createInsufficientCreditsError: () => createInsufficientCreditsError,
50
+ createInvalidAmountError: () => createInvalidAmountError,
29
51
  createInvalidOperationTypeError: () => createInvalidOperationTypeError,
52
+ createPaymentRefConflictError: () => createPaymentRefConflictError,
30
53
  createReservationAlreadyProcessedError: () => createReservationAlreadyProcessedError,
31
54
  createReservationExpiredError: () => createReservationExpiredError,
32
55
  createReservationNotFoundError: () => createReservationNotFoundError,
56
+ createTransientError: () => createTransientError,
57
+ createUnsupportedOperationError: () => createUnsupportedOperationError,
33
58
  createUserNotFoundError: () => createUserNotFoundError,
59
+ describePaymentMismatch: () => describePaymentMismatch,
34
60
  genericDeferred: () => genericDeferred,
61
+ getSqlState: () => getSqlState,
62
+ hasPlacedHold: () => hasPlacedHold,
35
63
  isCreditError: () => isCreditError,
64
+ isCreditedOutcome: () => isCreditedOutcome,
65
+ isIdempotencyConflictError: () => isIdempotencyConflictError,
36
66
  isInsufficientCreditsError: () => isInsufficientCreditsError,
67
+ isRepresentableAmount: () => isRepresentableAmount,
68
+ isReservationStatus: () => isReservationStatus,
69
+ isReservedJournalKey: () => isReservedJournalKey,
70
+ isReservedOutcome: () => isReservedOutcome,
71
+ isTerminalReservationStatus: () => isTerminalReservationStatus,
72
+ isTransientDatabaseError: () => isTransientDatabaseError,
73
+ isTransientError: () => isTransientError,
74
+ isUniqueViolation: () => isUniqueViolation,
75
+ isValidCreditAmount: () => isValidCreditAmount,
76
+ isValidIdempotencyKey: () => isValidIdempotencyKey,
77
+ isWinningOutcome: () => isWinningOutcome,
37
78
  noopDeferred: () => noopDeferred,
79
+ normalizePaymentRef: () => normalizePaymentRef,
80
+ numericToCents: () => numericToCents,
38
81
  releaseReservationWithJournal: () => releaseReservationWithJournal,
39
82
  reserveCreditsForOperation: () => reserveCreditsForOperation,
83
+ sameAmount: () => sameAmount,
84
+ storedMonthlyLimit: () => storedMonthlyLimit,
85
+ sumAmounts: () => sumAmounts,
40
86
  synchronousDeferred: () => synchronousDeferred,
87
+ terminalStatusOf: () => terminalStatusOf,
88
+ toCents: () => toCents,
41
89
  toDate: () => toDate,
42
90
  toPortableTimestamp: () => toPortableTimestamp
43
91
  });
44
92
  module.exports = __toCommonJS(core_exports);
45
93
 
46
- // src/core/types.ts
47
- function calculateAvailableCredits(balance, bonusCredits, reserved) {
48
- return balance + bonusCredits - reserved;
49
- }
50
- function toPortableTimestamp(value) {
51
- if (!value) {
52
- return (/* @__PURE__ */ new Date()).toISOString();
53
- }
54
- if (typeof value === "string") {
55
- return value;
56
- }
57
- if (value instanceof Date) {
58
- return value.toISOString();
59
- }
60
- if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
61
- return value.toDate().toISOString();
62
- }
63
- return (/* @__PURE__ */ new Date()).toISOString();
64
- }
65
- function toDate(value) {
66
- if (value instanceof Date) {
67
- return value;
68
- }
69
- if (typeof value === "string") {
70
- return new Date(value);
71
- }
72
- if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
73
- return value.toDate();
74
- }
75
- return /* @__PURE__ */ new Date();
76
- }
77
-
78
94
  // src/core/errors.ts
79
95
  var CreditErrorCode = {
80
96
  INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
@@ -84,7 +100,32 @@ var CreditErrorCode = {
84
100
  USER_NOT_FOUND: "USER_NOT_FOUND",
85
101
  INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
86
102
  CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
87
- DATABASE_ERROR: "DATABASE_ERROR"
103
+ DATABASE_ERROR: "DATABASE_ERROR",
104
+ /** Caller reused an idempotency key with a different immutable payload. */
105
+ IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT",
106
+ /**
107
+ * Retryable infrastructure failure — serialization/deadlock, lock timeout,
108
+ * connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
109
+ * a deterministic failure that will fail again identically on retry.
110
+ */
111
+ TRANSIENT_ERROR: "TRANSIENT_ERROR",
112
+ /** The configured repository adapter does not implement the requested V2 method. */
113
+ UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
114
+ /** Amount was not a finite positive number. */
115
+ INVALID_AMOUNT: "INVALID_AMOUNT",
116
+ /** Idempotency key was supplied but empty or whitespace-only. */
117
+ INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY",
118
+ /**
119
+ * A persisted reservation holds a status this library does not define, so no
120
+ * transition can reason about it. Quarantined rather than cast; nothing moved.
121
+ */
122
+ CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS",
123
+ /**
124
+ * A persisted reservation does not record that its hold was placed
125
+ * atomically, so the credits it claims are not known to be reserved. Moving
126
+ * it would spend another hold's coverage; nothing moved.
127
+ */
128
+ UNBACKED_RESERVATION: "UNBACKED_RESERVATION"
88
129
  };
89
130
  var CreditError = class _CreditError extends Error {
90
131
  code;
@@ -126,7 +167,7 @@ function createInsufficientCreditsError(required, available) {
126
167
  return new CreditError(
127
168
  `Insufficient credits: available ${available}, required ${required}`,
128
169
  CreditErrorCode.INSUFFICIENT_CREDITS,
129
- { required, available, shortfall: required - available }
170
+ { required, available, shortfall: sumAmounts(required, -available) }
130
171
  );
131
172
  }
132
173
  function createReservationNotFoundError(reservationId) {
@@ -164,6 +205,435 @@ function createInvalidOperationTypeError(operationType, validTypes) {
164
205
  { operationType, validTypes }
165
206
  );
166
207
  }
208
+ function createIdempotencyConflictError(idempotencyKey, details) {
209
+ return new CreditError(
210
+ `Idempotency key ${idempotencyKey} was reused with a different payload`,
211
+ CreditErrorCode.IDEMPOTENCY_CONFLICT,
212
+ { idempotencyKey, ...details }
213
+ );
214
+ }
215
+ function createTransientError(message, details) {
216
+ return new CreditError(message, CreditErrorCode.TRANSIENT_ERROR, details);
217
+ }
218
+ function createUnsupportedOperationError(operation, hint) {
219
+ return new CreditError(
220
+ hint ? `Repository does not support ${operation}. ${hint}` : `Repository does not support ${operation}`,
221
+ CreditErrorCode.UNSUPPORTED_OPERATION,
222
+ { operation, ...hint ? { hint } : {} }
223
+ );
224
+ }
225
+ function createInvalidAmountError(amount) {
226
+ return new CreditError(
227
+ `Credit amount must be a finite positive number (got ${String(amount)})`,
228
+ CreditErrorCode.INVALID_AMOUNT,
229
+ { amount }
230
+ );
231
+ }
232
+ function isIdempotencyConflictError(error) {
233
+ return isCreditError(error) && error.code === CreditErrorCode.IDEMPOTENCY_CONFLICT;
234
+ }
235
+ function isTransientError(error) {
236
+ return isCreditError(error) && error.code === CreditErrorCode.TRANSIENT_ERROR;
237
+ }
238
+
239
+ // src/core/amount.ts
240
+ var CREDIT_AMOUNT_SCALE = 2;
241
+ var CREDIT_AMOUNT_MAX = 999999999999e-2;
242
+ var MAX_CENTS = 999999999999;
243
+ function toCents(value) {
244
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
245
+ const cents = Math.round(value * 100);
246
+ if (!Number.isSafeInteger(cents)) return null;
247
+ return value === cents / 100 ? cents : null;
248
+ }
249
+ function sumAmounts(...values) {
250
+ let cents = 0;
251
+ for (const value of values) {
252
+ const valueCents = toCents(value);
253
+ if (valueCents === null) return floatSum(values);
254
+ cents += valueCents;
255
+ }
256
+ return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);
257
+ }
258
+ function floatSum(values) {
259
+ let total = 0;
260
+ for (const value of values) total += value;
261
+ return total;
262
+ }
263
+ function isValidCreditAmount(value) {
264
+ if (typeof value !== "number") return false;
265
+ const cents = toCents(value);
266
+ return cents !== null && cents > 0 && cents <= MAX_CENTS;
267
+ }
268
+ function assertValidCreditAmount(value, context) {
269
+ if (isValidCreditAmount(value)) return;
270
+ throw new CreditError(
271
+ creditAmountReason(value),
272
+ CreditErrorCode.INVALID_AMOUNT,
273
+ { amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
274
+ );
275
+ }
276
+ function creditAmountReason(value) {
277
+ if (typeof value !== "number") {
278
+ return `Credit amount must be a number (got ${typeof value})`;
279
+ }
280
+ if (!Number.isFinite(value)) {
281
+ return `Credit amount must be finite (got ${String(value)})`;
282
+ }
283
+ if (value <= 0) {
284
+ return `Credit amount must be greater than zero (got ${value})`;
285
+ }
286
+ if (value > CREDIT_AMOUNT_MAX) {
287
+ return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;
288
+ }
289
+ return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;
290
+ }
291
+ function isRepresentableAmount(value) {
292
+ if (typeof value !== "number") return false;
293
+ const cents = toCents(value);
294
+ return cents !== null && Math.abs(cents) <= MAX_CENTS;
295
+ }
296
+ function assertRepresentableAmount(value, field, context) {
297
+ if (isRepresentableAmount(value)) return;
298
+ throw new CreditError(
299
+ `${field} must be a finite number exact to ${CREDIT_AMOUNT_SCALE} decimal places within \xB1${CREDIT_AMOUNT_MAX} (got ${String(value)})`,
300
+ CreditErrorCode.INVALID_AMOUNT,
301
+ { field, value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
302
+ );
303
+ }
304
+ function assertRepresentableFields(fields, context) {
305
+ for (const [field, value] of Object.entries(fields)) {
306
+ if (value === void 0) continue;
307
+ assertRepresentableAmount(value, field, context);
308
+ }
309
+ }
310
+ function backedBalanceFloor(reserved, bonusCredits) {
311
+ const floor = Math.max(0, sumAmounts(reserved, -bonusCredits));
312
+ assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
313
+ return floor;
314
+ }
315
+ function assertRepresentableTierAmount(value, field, context) {
316
+ if (value === Number.POSITIVE_INFINITY) return;
317
+ assertRepresentableAmount(value, field, context);
318
+ }
319
+ function storedMonthlyLimit(limit) {
320
+ return Number.isFinite(limit) ? limit : CREDIT_AMOUNT_MAX;
321
+ }
322
+ function assertValidStoredAmountRaw(raw, context) {
323
+ const cents = numericToCents(raw);
324
+ if (cents !== null && cents > 0n && cents <= BigInt(MAX_CENTS)) {
325
+ return Number(cents) / 100;
326
+ }
327
+ throw new CreditError(
328
+ `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.`,
329
+ CreditErrorCode.INVALID_AMOUNT,
330
+ {
331
+ amount: typeof raw === "string" ? raw : String(raw),
332
+ reason: "corrupt_stored_amount",
333
+ max: CREDIT_AMOUNT_MAX,
334
+ scale: CREDIT_AMOUNT_SCALE,
335
+ ...context
336
+ }
337
+ );
338
+ }
339
+ function assertValidStoredAmount(value, context) {
340
+ if (isValidCreditAmount(value)) return;
341
+ throw new CreditError(
342
+ `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.`,
343
+ CreditErrorCode.INVALID_AMOUNT,
344
+ { amount: value, reason: "corrupt_stored_amount", max: CREDIT_AMOUNT_MAX, ...context }
345
+ );
346
+ }
347
+ function numericToCents(value) {
348
+ if (typeof value === "number") {
349
+ const cents2 = toCents(value);
350
+ return cents2 === null ? null : BigInt(cents2);
351
+ }
352
+ if (typeof value !== "string") return null;
353
+ const match = /^([+-]?)(\d+)(?:\.(\d*))?$/.exec(value.trim());
354
+ if (!match) return null;
355
+ const [, sign, whole, fraction = ""] = match;
356
+ if (/[1-9]/.test(fraction.slice(CREDIT_AMOUNT_SCALE))) return null;
357
+ const cents = BigInt(whole) * 100n + BigInt(fraction.slice(0, CREDIT_AMOUNT_SCALE).padEnd(CREDIT_AMOUNT_SCALE, "0"));
358
+ return sign === "-" ? -cents : cents;
359
+ }
360
+ function sameAmount(a, b) {
361
+ const left = numericToCents(a);
362
+ const right = numericToCents(b);
363
+ return left !== null && right !== null && left === right;
364
+ }
365
+
366
+ // src/core/types.ts
367
+ function calculateAvailableCredits(balance, bonusCredits, reserved) {
368
+ return sumAmounts(balance, bonusCredits, -reserved);
369
+ }
370
+ function toPortableTimestamp(value) {
371
+ if (!value) {
372
+ return (/* @__PURE__ */ new Date()).toISOString();
373
+ }
374
+ if (typeof value === "string") {
375
+ return value;
376
+ }
377
+ if (value instanceof Date) {
378
+ return value.toISOString();
379
+ }
380
+ if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
381
+ return value.toDate().toISOString();
382
+ }
383
+ return (/* @__PURE__ */ new Date()).toISOString();
384
+ }
385
+ function toDate(value) {
386
+ if (value instanceof Date) {
387
+ return value;
388
+ }
389
+ if (typeof value === "string") {
390
+ return new Date(value);
391
+ }
392
+ if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
393
+ return value.toDate();
394
+ }
395
+ return /* @__PURE__ */ new Date();
396
+ }
397
+
398
+ // src/core/outcomes.ts
399
+ function isCreditedOutcome(outcome) {
400
+ return outcome.outcome === "created";
401
+ }
402
+ function isWinningOutcome(outcome) {
403
+ return outcome.outcome === "committed" || outcome.outcome === "released" || outcome.outcome === "expired";
404
+ }
405
+ function isReservedOutcome(outcome) {
406
+ return outcome.outcome === "created" || outcome.outcome === "replayed";
407
+ }
408
+
409
+ // src/core/reservation-integrity.ts
410
+ var RESERVATION_STATUSES = [
411
+ "reserved",
412
+ "committed",
413
+ "released",
414
+ "expired"
415
+ ];
416
+ var TERMINAL_RESERVATION_STATUSES = [
417
+ "committed",
418
+ "released",
419
+ "expired"
420
+ ];
421
+ function isReservationStatus(value) {
422
+ return RESERVATION_STATUSES.includes(value);
423
+ }
424
+ function isTerminalReservationStatus(value) {
425
+ return TERMINAL_RESERVATION_STATUSES.includes(value);
426
+ }
427
+ function assertKnownReservationStatus(reservation, transition) {
428
+ if (isReservationStatus(reservation.status)) return;
429
+ throw new CreditError(
430
+ `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.`,
431
+ CreditErrorCode.CORRUPT_RESERVATION_STATUS,
432
+ {
433
+ userId: reservation.userId,
434
+ reservationId: reservation.id,
435
+ transition,
436
+ status: typeof reservation.status === "string" ? reservation.status : String(reservation.status),
437
+ allowed: [...RESERVATION_STATUSES],
438
+ reason: "corrupt_reservation_status"
439
+ }
440
+ );
441
+ }
442
+ function terminalStatusOf(reservation, transition) {
443
+ assertKnownReservationStatus(reservation, transition);
444
+ if (isTerminalReservationStatus(reservation.status)) return reservation.status;
445
+ throw new CreditError(
446
+ `Reservation ${reservation.id} is ${reservation.status}, which is not a terminal status, but the ${transition} tried to report it as one.`,
447
+ CreditErrorCode.CORRUPT_RESERVATION_STATUS,
448
+ {
449
+ userId: reservation.userId,
450
+ reservationId: reservation.id,
451
+ transition,
452
+ status: reservation.status,
453
+ reason: "not_terminal"
454
+ }
455
+ );
456
+ }
457
+ function assertHoldPlaced(reservation, transition) {
458
+ if (reservation.holdPlacedAt) return;
459
+ throw new CreditError(
460
+ `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.`,
461
+ CreditErrorCode.UNBACKED_RESERVATION,
462
+ {
463
+ userId: reservation.userId,
464
+ reservationId: reservation.id,
465
+ transition,
466
+ idempotencyKey: reservation.idempotencyKey ?? void 0,
467
+ reason: "hold_not_placed"
468
+ }
469
+ );
470
+ }
471
+ function assertUnkeyedDirectReservation(input) {
472
+ if (input.idempotencyKey === void 0 || input.idempotencyKey === null) return;
473
+ throw new CreditError(
474
+ "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.",
475
+ CreditErrorCode.UNSUPPORTED_OPERATION,
476
+ {
477
+ userId: input.userId,
478
+ idempotencyKey: input.idempotencyKey,
479
+ operation: "createReservation",
480
+ reason: "keyed_direct_reservation"
481
+ }
482
+ );
483
+ }
484
+ function hasPlacedHold(reservation) {
485
+ return Boolean(reservation.holdPlacedAt);
486
+ }
487
+ function assertDirectStatusWriteAllowed(reservation, status) {
488
+ if (status === "reserved") {
489
+ throw new CreditError(
490
+ `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.`,
491
+ CreditErrorCode.UNSUPPORTED_OPERATION,
492
+ {
493
+ userId: reservation.userId,
494
+ reservationId: reservation.id,
495
+ status,
496
+ reason: "reopen_reservation"
497
+ }
498
+ );
499
+ }
500
+ if (!reservation.holdPlacedAt) return;
501
+ throw new CreditError(
502
+ `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.`,
503
+ CreditErrorCode.UNSUPPORTED_OPERATION,
504
+ {
505
+ userId: reservation.userId,
506
+ reservationId: reservation.id,
507
+ status,
508
+ reason: "direct_status_write_on_backed_hold"
509
+ }
510
+ );
511
+ }
512
+
513
+ // src/core/payment-ref.ts
514
+ function normalizePaymentRef(ref) {
515
+ if (typeof ref !== "string") return void 0;
516
+ const trimmed = ref.trim();
517
+ return trimmed.length > 0 ? trimmed : void 0;
518
+ }
519
+ function describePaymentMismatch(stored, expected) {
520
+ if (stored.userId !== expected.userId) return "userId";
521
+ if (!sameAmount(stored.amount, expected.amount)) return "amount";
522
+ if (stored.type !== expected.type) return "type";
523
+ if (stored.source !== void 0 && stored.source !== expected.source) return "source";
524
+ if (stored.referenceType !== void 0 && stored.referenceType !== expected.referenceType) {
525
+ return "referenceType";
526
+ }
527
+ return null;
528
+ }
529
+ function assertUnreferencedDirectTransaction(input) {
530
+ if (normalizePaymentRef(input.paymentRef) === void 0) return;
531
+ throw new CreditError(
532
+ "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.",
533
+ CreditErrorCode.UNSUPPORTED_OPERATION,
534
+ {
535
+ userId: input.userId,
536
+ paymentRef: input.paymentRef,
537
+ operation: "createTransaction",
538
+ reason: "referenced_direct_transaction"
539
+ }
540
+ );
541
+ }
542
+ function createPaymentRefConflictError(paymentRef, details) {
543
+ return new CreditError(
544
+ `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.`,
545
+ CreditErrorCode.IDEMPOTENCY_CONFLICT,
546
+ { paymentRef, reason: "payment_ref_conflict", ...details }
547
+ );
548
+ }
549
+
550
+ // src/core/idempotency.ts
551
+ function isValidIdempotencyKey(value) {
552
+ return typeof value === "string" && value.trim().length > 0;
553
+ }
554
+ function assertValidIdempotencyKey(value, context) {
555
+ if (value === void 0 || value === null) return;
556
+ if (isValidIdempotencyKey(value)) return;
557
+ throw new CreditError(
558
+ 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})`,
559
+ CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
560
+ { idempotencyKey: value, ...context }
561
+ );
562
+ }
563
+ var RESERVED_JOURNAL_KEY_PREFIX = "reservation:";
564
+ function isReservedJournalKey(key) {
565
+ return typeof key === "string" && key.startsWith(RESERVED_JOURNAL_KEY_PREFIX);
566
+ }
567
+ function assertPublicJournalKey(key, userId) {
568
+ if (!isReservedJournalKey(key)) return;
569
+ throw new CreditError(
570
+ `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.`,
571
+ CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
572
+ { userId, idempotencyKey: key, reason: "reserved_namespace" }
573
+ );
574
+ }
575
+
576
+ // src/core/error-classify.ts
577
+ var TRANSIENT_SQLSTATES = /* @__PURE__ */ new Set([
578
+ "40000",
579
+ "40001",
580
+ "40P01",
581
+ "55P03",
582
+ "57014",
583
+ "57P01",
584
+ "57P02",
585
+ "57P03",
586
+ "53200",
587
+ "53300",
588
+ "53400"
589
+ ]);
590
+ var TRANSIENT_DRIVER_CODES = /* @__PURE__ */ new Set([
591
+ "ECONNRESET",
592
+ "ECONNREFUSED",
593
+ "EPIPE",
594
+ "ETIMEDOUT",
595
+ "ENOTFOUND",
596
+ "EHOSTUNREACH"
597
+ ]);
598
+ var NUMERIC_OVERFLOW = "22003";
599
+ function getSqlState(error) {
600
+ const seen = /* @__PURE__ */ new Set();
601
+ let current = error;
602
+ for (let depth = 0; depth < 8; depth += 1) {
603
+ if (typeof current !== "object" || current === null || seen.has(current)) return void 0;
604
+ seen.add(current);
605
+ const code = current.code;
606
+ if (typeof code === "string") return code;
607
+ current = current.cause;
608
+ }
609
+ return void 0;
610
+ }
611
+ function isUniqueViolation(error) {
612
+ return getSqlState(error) === "23505";
613
+ }
614
+ function isTransientDatabaseError(error) {
615
+ const code = getSqlState(error);
616
+ if (!code) return false;
617
+ if (TRANSIENT_SQLSTATES.has(code)) return true;
618
+ if (TRANSIENT_DRIVER_CODES.has(code)) return true;
619
+ return code.startsWith("08");
620
+ }
621
+ function classifyDatabaseError(error, context) {
622
+ if (isCreditError(error)) return error;
623
+ const sqlState = getSqlState(error);
624
+ const message = error instanceof Error ? error.message : String(error);
625
+ const details = { ...context, ...sqlState ? { sqlState } : {} };
626
+ if (isTransientDatabaseError(error)) {
627
+ return new CreditError(message, CreditErrorCode.TRANSIENT_ERROR, details);
628
+ }
629
+ if (sqlState === NUMERIC_OVERFLOW) {
630
+ return new CreditError(message, CreditErrorCode.INVALID_AMOUNT, {
631
+ ...details,
632
+ reason: "amount_out_of_range"
633
+ });
634
+ }
635
+ return new CreditError(message, CreditErrorCode.DATABASE_ERROR, details);
636
+ }
167
637
 
168
638
  // src/core/deferred.ts
169
639
  var genericDeferred = {
@@ -216,11 +686,13 @@ var creditSystemConfigSchema = import_zod.z.object({
216
686
  /**
217
687
  * Operation costs in credits
218
688
  * Keys are operation type names (any non-empty string)
219
- * Values are positive numbers representing credit cost
689
+ * Values are non-negative numbers representing credit cost. 0 marks a free
690
+ * operation: `withCredits` runs it without touching the ledger. Amounts that
691
+ * move credits stay strictly positive, so `reserveCredits` refuses a 0.
220
692
  */
221
693
  operationCosts: import_zod.z.record(
222
694
  import_zod.z.string().min(1),
223
- import_zod.z.number().positive()
695
+ import_zod.z.number().nonnegative()
224
696
  ),
225
697
  /**
226
698
  * Display labels for operation types (English canonical labels).
@@ -454,78 +926,230 @@ function getOperationLabel(operationType) {
454
926
  }
455
927
  initializeConfig();
456
928
 
457
- // src/core/operations.ts
458
- async function commitReservationWithJournal(repository, userId, reservationId) {
929
+ // src/repository/types.ts
930
+ function supportsCreditsV2(repository) {
931
+ return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
932
+ }
933
+
934
+ // src/repository/flow.ts
935
+ async function reserveThroughRepository(repository, input) {
936
+ const v2 = supportsCreditsV2(repository);
937
+ if (!v2 && input.idempotencyKey !== void 0) {
938
+ throw createUnsupportedOperationError(
939
+ "reserveCredits with an idempotencyKey",
940
+ "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."
941
+ );
942
+ }
943
+ assertValidCreditAmount(input.amount, { userId: input.userId });
944
+ if (v2) {
945
+ return repository.reserveCreditsV2(input);
946
+ }
947
+ try {
948
+ const reservation = await repository.reserveCreditsAtomic(
949
+ input.userId,
950
+ input.amount,
951
+ input.operationType,
952
+ input.expiresAt
953
+ );
954
+ return { outcome: "created", reservation };
955
+ } catch (error) {
956
+ if (!isInsufficientCreditsError(error)) throw error;
957
+ const credits = await repository.getUserCredits(input.userId);
958
+ const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
959
+ return {
960
+ outcome: "insufficient",
961
+ available,
962
+ required: input.amount,
963
+ shortfall: Math.max(sumAmounts(input.amount, -available), 0)
964
+ };
965
+ }
966
+ }
967
+ async function commitThroughRepository(repository, userId, reservationId, options) {
968
+ if (supportsCreditsV2(repository)) {
969
+ return repository.commitReservationV2(userId, reservationId, options);
970
+ }
459
971
  const reservation = await repository.getReservation(userId, reservationId);
460
- if (!reservation) {
461
- throw new Error(`Reservation ${reservationId} not found`);
972
+ if (!reservation) return { outcome: "not_found", reservationId };
973
+ if (reservation.status !== "reserved") {
974
+ return {
975
+ outcome: "already_terminal",
976
+ reservation,
977
+ terminalStatus: reservation.status
978
+ };
462
979
  }
463
980
  await repository.commitReservationAtomic(userId, reservationId);
464
981
  const credits = await repository.getUserCredits(userId);
465
- if (credits) {
466
- await repository.createJournalEntry({
467
- userId,
468
- entryType: "debit",
469
- amount: reservation.amount,
470
- balanceAfter: credits.balance,
471
- source: "operation_commit",
472
- referenceId: reservationId,
473
- referenceType: "reservation",
474
- description: `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
475
- metadata: {
476
- operationType: reservation.operationType
477
- }
478
- });
479
- }
982
+ const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
983
+ const entry = await repository.createJournalEntry({
984
+ userId,
985
+ entryType: "debit",
986
+ amount: reservation.amount,
987
+ balanceAfter,
988
+ source: "operation_commit",
989
+ referenceId: reservationId,
990
+ referenceType: "reservation",
991
+ description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
992
+ // Deterministic fields last: they carry the identity of the event, and a
993
+ // caller who happens to send an `operationType` or an `amount` must not
994
+ // relabel what actually moved.
995
+ metadata: {
996
+ ...options?.metadata,
997
+ operationType: reservation.operationType,
998
+ amount: reservation.amount
999
+ }
1000
+ });
1001
+ return {
1002
+ outcome: "committed",
1003
+ reservation: { ...reservation, status: "committed" },
1004
+ amount: reservation.amount,
1005
+ balanceAfter,
1006
+ journalEntryId: entry.id
1007
+ };
480
1008
  }
481
- async function releaseReservationWithJournal(repository, userId, reservationId) {
1009
+ async function releaseThroughRepository(repository, userId, reservationId, options) {
1010
+ if (supportsCreditsV2(repository)) {
1011
+ return repository.releaseReservationV2(userId, reservationId, options);
1012
+ }
482
1013
  const reservation = await repository.getReservation(userId, reservationId);
1014
+ if (!reservation) {
1015
+ await repository.releaseReservationAtomic(userId, reservationId);
1016
+ return { outcome: "not_found", reservationId };
1017
+ }
1018
+ if (reservation.status !== "reserved") {
1019
+ await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
1020
+ return {
1021
+ outcome: "already_terminal",
1022
+ reservation,
1023
+ terminalStatus: reservation.status
1024
+ };
1025
+ }
483
1026
  await repository.releaseReservationAtomic(userId, reservationId);
484
- if (reservation?.status === "reserved") {
485
- const credits = await repository.getUserCredits(userId);
486
- if (credits) {
487
- await repository.createJournalEntry({
488
- userId,
489
- entryType: "credit",
490
- amount: 0,
491
- // No actual credits returned (they were reserved, not spent)
492
- balanceAfter: credits.balance,
493
- source: "operation_release",
494
- referenceId: reservationId,
495
- referenceType: "reservation",
496
- description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
497
- metadata: {
498
- operationType: reservation.operationType,
499
- amount: reservation.amount
500
- }
501
- });
1027
+ const credits = await repository.getUserCredits(userId);
1028
+ const entry = await repository.createJournalEntry({
1029
+ userId,
1030
+ entryType: "credit",
1031
+ // No credits changed hands — the hold just moved back into "available".
1032
+ amount: 0,
1033
+ balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
1034
+ source: "operation_release",
1035
+ referenceId: reservationId,
1036
+ referenceType: "reservation",
1037
+ description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
1038
+ metadata: {
1039
+ ...options?.metadata,
1040
+ operationType: reservation.operationType,
1041
+ amount: reservation.amount
502
1042
  }
1043
+ });
1044
+ return {
1045
+ outcome: "released",
1046
+ reservation: { ...reservation, status: "released" },
1047
+ amount: reservation.amount,
1048
+ journalEntryId: entry.id
1049
+ };
1050
+ }
1051
+
1052
+ // src/core/operations.ts
1053
+ async function commitReservationWithJournal(repository, userId, reservationId) {
1054
+ const outcome = await commitThroughRepository(repository, userId, reservationId);
1055
+ if (outcome.outcome === "not_found") {
1056
+ throw createReservationNotFoundError(reservationId);
503
1057
  }
1058
+ if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
1059
+ throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
1060
+ }
1061
+ }
1062
+ async function releaseReservationWithJournal(repository, userId, reservationId) {
1063
+ await releaseThroughRepository(repository, userId, reservationId);
504
1064
  }
505
- async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3) {
1065
+ async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3, idempotencyKey) {
506
1066
  const expiresAt = new Date(Date.now() + expiryMs);
507
- return repository.reserveCreditsAtomic(userId, amount, operationType, expiresAt);
1067
+ const outcome = await reserveThroughRepository(repository, {
1068
+ userId,
1069
+ amount,
1070
+ operationType,
1071
+ expiresAt,
1072
+ idempotencyKey
1073
+ });
1074
+ if (outcome.outcome === "created" || outcome.outcome === "replayed") {
1075
+ return outcome.reservation;
1076
+ }
1077
+ if (outcome.outcome === "insufficient") {
1078
+ throw createInsufficientCreditsError(outcome.required, outcome.available);
1079
+ }
1080
+ throw createReservationAlreadyProcessedError(
1081
+ outcome.existing.id,
1082
+ outcome.existing.status
1083
+ );
508
1084
  }
509
1085
  // Annotate the CommonJS export names for ESM import in node:
510
1086
  0 && (module.exports = {
1087
+ CREDIT_AMOUNT_MAX,
1088
+ CREDIT_AMOUNT_SCALE,
511
1089
  CreditError,
512
1090
  CreditErrorCode,
1091
+ RESERVATION_STATUSES,
1092
+ RESERVED_JOURNAL_KEY_PREFIX,
1093
+ TERMINAL_RESERVATION_STATUSES,
1094
+ assertDirectStatusWriteAllowed,
1095
+ assertHoldPlaced,
1096
+ assertKnownReservationStatus,
1097
+ assertPublicJournalKey,
1098
+ assertRepresentableAmount,
1099
+ assertRepresentableFields,
1100
+ assertRepresentableTierAmount,
1101
+ assertUnkeyedDirectReservation,
1102
+ assertUnreferencedDirectTransaction,
1103
+ assertValidCreditAmount,
1104
+ assertValidIdempotencyKey,
1105
+ assertValidStoredAmount,
1106
+ assertValidStoredAmountRaw,
1107
+ backedBalanceFloor,
513
1108
  calculateAvailableCredits,
1109
+ classifyDatabaseError,
514
1110
  commitReservationWithJournal,
515
1111
  createDeferredExecutor,
1112
+ createIdempotencyConflictError,
516
1113
  createInsufficientCreditsError,
1114
+ createInvalidAmountError,
517
1115
  createInvalidOperationTypeError,
1116
+ createPaymentRefConflictError,
518
1117
  createReservationAlreadyProcessedError,
519
1118
  createReservationExpiredError,
520
1119
  createReservationNotFoundError,
1120
+ createTransientError,
1121
+ createUnsupportedOperationError,
521
1122
  createUserNotFoundError,
1123
+ describePaymentMismatch,
522
1124
  genericDeferred,
1125
+ getSqlState,
1126
+ hasPlacedHold,
523
1127
  isCreditError,
1128
+ isCreditedOutcome,
1129
+ isIdempotencyConflictError,
524
1130
  isInsufficientCreditsError,
1131
+ isRepresentableAmount,
1132
+ isReservationStatus,
1133
+ isReservedJournalKey,
1134
+ isReservedOutcome,
1135
+ isTerminalReservationStatus,
1136
+ isTransientDatabaseError,
1137
+ isTransientError,
1138
+ isUniqueViolation,
1139
+ isValidCreditAmount,
1140
+ isValidIdempotencyKey,
1141
+ isWinningOutcome,
525
1142
  noopDeferred,
1143
+ normalizePaymentRef,
1144
+ numericToCents,
526
1145
  releaseReservationWithJournal,
527
1146
  reserveCreditsForOperation,
1147
+ sameAmount,
1148
+ storedMonthlyLimit,
1149
+ sumAmounts,
528
1150
  synchronousDeferred,
1151
+ terminalStatusOf,
1152
+ toCents,
529
1153
  toDate,
530
1154
  toPortableTimestamp
531
1155
  });