@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
@@ -21,11 +21,13 @@ var creditSystemConfigSchema = z.object({
21
21
  /**
22
22
  * Operation costs in credits
23
23
  * Keys are operation type names (any non-empty string)
24
- * Values are positive numbers representing credit cost
24
+ * Values are non-negative numbers representing credit cost. 0 marks a free
25
+ * operation: `withCredits` runs it without touching the ledger. Amounts that
26
+ * move credits stay strictly positive, so `reserveCredits` refuses a 0.
25
27
  */
26
28
  operationCosts: z.record(
27
29
  z.string().min(1),
28
- z.number().positive()
30
+ z.number().nonnegative()
29
31
  ),
30
32
  /**
31
33
  * Display labels for operation types (English canonical labels).
@@ -259,57 +261,315 @@ function getOperationLabel(operationType) {
259
261
  }
260
262
  initializeConfig();
261
263
 
262
- // src/core/operations.ts
263
- async function commitReservationWithJournal(repository, userId, reservationId) {
264
+ // src/core/errors.ts
265
+ var CreditErrorCode = {
266
+ INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
267
+ RESERVATION_NOT_FOUND: "RESERVATION_NOT_FOUND",
268
+ RESERVATION_EXPIRED: "RESERVATION_EXPIRED",
269
+ RESERVATION_ALREADY_PROCESSED: "RESERVATION_ALREADY_PROCESSED",
270
+ USER_NOT_FOUND: "USER_NOT_FOUND",
271
+ INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
272
+ CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
273
+ DATABASE_ERROR: "DATABASE_ERROR",
274
+ /** Caller reused an idempotency key with a different immutable payload. */
275
+ IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT",
276
+ /**
277
+ * Retryable infrastructure failure — serialization/deadlock, lock timeout,
278
+ * connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
279
+ * a deterministic failure that will fail again identically on retry.
280
+ */
281
+ TRANSIENT_ERROR: "TRANSIENT_ERROR",
282
+ /** The configured repository adapter does not implement the requested V2 method. */
283
+ UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
284
+ /** Amount was not a finite positive number. */
285
+ INVALID_AMOUNT: "INVALID_AMOUNT",
286
+ /** Idempotency key was supplied but empty or whitespace-only. */
287
+ INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY",
288
+ /**
289
+ * A persisted reservation holds a status this library does not define, so no
290
+ * transition can reason about it. Quarantined rather than cast; nothing moved.
291
+ */
292
+ CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS",
293
+ /**
294
+ * A persisted reservation does not record that its hold was placed
295
+ * atomically, so the credits it claims are not known to be reserved. Moving
296
+ * it would spend another hold's coverage; nothing moved.
297
+ */
298
+ UNBACKED_RESERVATION: "UNBACKED_RESERVATION"
299
+ };
300
+ var CreditError = class _CreditError extends Error {
301
+ code;
302
+ details;
303
+ constructor(message, code, details) {
304
+ super(message);
305
+ this.name = "CreditError";
306
+ this.code = code;
307
+ this.details = details;
308
+ if (Error.captureStackTrace) {
309
+ Error.captureStackTrace(this, _CreditError);
310
+ }
311
+ }
312
+ /**
313
+ * Convert to JSON-serializable object
314
+ */
315
+ toJSON() {
316
+ return {
317
+ name: this.name,
318
+ code: this.code,
319
+ message: this.message,
320
+ ...this.details && { details: this.details }
321
+ };
322
+ }
323
+ };
324
+ function isCreditError(error) {
325
+ return error instanceof CreditError;
326
+ }
327
+ function isInsufficientCreditsError(error) {
328
+ if (isCreditError(error)) {
329
+ return error.code === CreditErrorCode.INSUFFICIENT_CREDITS;
330
+ }
331
+ if (error instanceof Error) {
332
+ return error.message.toLowerCase().includes("insufficient credits");
333
+ }
334
+ return false;
335
+ }
336
+ function createInsufficientCreditsError(required, available) {
337
+ return new CreditError(
338
+ `Insufficient credits: available ${available}, required ${required}`,
339
+ CreditErrorCode.INSUFFICIENT_CREDITS,
340
+ { required, available, shortfall: sumAmounts(required, -available) }
341
+ );
342
+ }
343
+ function createReservationNotFoundError(reservationId) {
344
+ return new CreditError(
345
+ `Reservation ${reservationId} not found`,
346
+ CreditErrorCode.RESERVATION_NOT_FOUND,
347
+ { reservationId }
348
+ );
349
+ }
350
+ function createReservationAlreadyProcessedError(reservationId, status) {
351
+ return new CreditError(
352
+ `Reservation ${reservationId} has already been ${status}`,
353
+ CreditErrorCode.RESERVATION_ALREADY_PROCESSED,
354
+ { reservationId, status }
355
+ );
356
+ }
357
+ function createUnsupportedOperationError(operation, hint) {
358
+ return new CreditError(
359
+ hint ? `Repository does not support ${operation}. ${hint}` : `Repository does not support ${operation}`,
360
+ CreditErrorCode.UNSUPPORTED_OPERATION,
361
+ { operation, ...hint ? { hint } : {} }
362
+ );
363
+ }
364
+
365
+ // src/core/amount.ts
366
+ var CREDIT_AMOUNT_SCALE = 2;
367
+ var CREDIT_AMOUNT_MAX = 999999999999e-2;
368
+ var MAX_CENTS = 999999999999;
369
+ function toCents(value) {
370
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
371
+ const cents = Math.round(value * 100);
372
+ if (!Number.isSafeInteger(cents)) return null;
373
+ return value === cents / 100 ? cents : null;
374
+ }
375
+ function sumAmounts(...values) {
376
+ let cents = 0;
377
+ for (const value of values) {
378
+ const valueCents = toCents(value);
379
+ if (valueCents === null) return floatSum(values);
380
+ cents += valueCents;
381
+ }
382
+ return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);
383
+ }
384
+ function floatSum(values) {
385
+ let total = 0;
386
+ for (const value of values) total += value;
387
+ return total;
388
+ }
389
+ function isValidCreditAmount(value) {
390
+ if (typeof value !== "number") return false;
391
+ const cents = toCents(value);
392
+ return cents !== null && cents > 0 && cents <= MAX_CENTS;
393
+ }
394
+ function assertValidCreditAmount(value, context) {
395
+ if (isValidCreditAmount(value)) return;
396
+ throw new CreditError(
397
+ creditAmountReason(value),
398
+ CreditErrorCode.INVALID_AMOUNT,
399
+ { amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
400
+ );
401
+ }
402
+ function creditAmountReason(value) {
403
+ if (typeof value !== "number") {
404
+ return `Credit amount must be a number (got ${typeof value})`;
405
+ }
406
+ if (!Number.isFinite(value)) {
407
+ return `Credit amount must be finite (got ${String(value)})`;
408
+ }
409
+ if (value <= 0) {
410
+ return `Credit amount must be greater than zero (got ${value})`;
411
+ }
412
+ if (value > CREDIT_AMOUNT_MAX) {
413
+ return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;
414
+ }
415
+ return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;
416
+ }
417
+
418
+ // src/repository/types.ts
419
+ function supportsCreditsV2(repository) {
420
+ return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
421
+ }
422
+
423
+ // src/repository/flow.ts
424
+ async function reserveThroughRepository(repository, input) {
425
+ const v2 = supportsCreditsV2(repository);
426
+ if (!v2 && input.idempotencyKey !== void 0) {
427
+ throw createUnsupportedOperationError(
428
+ "reserveCredits with an idempotencyKey",
429
+ "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."
430
+ );
431
+ }
432
+ assertValidCreditAmount(input.amount, { userId: input.userId });
433
+ if (v2) {
434
+ return repository.reserveCreditsV2(input);
435
+ }
436
+ try {
437
+ const reservation = await repository.reserveCreditsAtomic(
438
+ input.userId,
439
+ input.amount,
440
+ input.operationType,
441
+ input.expiresAt
442
+ );
443
+ return { outcome: "created", reservation };
444
+ } catch (error) {
445
+ if (!isInsufficientCreditsError(error)) throw error;
446
+ const credits = await repository.getUserCredits(input.userId);
447
+ const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
448
+ return {
449
+ outcome: "insufficient",
450
+ available,
451
+ required: input.amount,
452
+ shortfall: Math.max(sumAmounts(input.amount, -available), 0)
453
+ };
454
+ }
455
+ }
456
+ async function commitThroughRepository(repository, userId, reservationId, options) {
457
+ if (supportsCreditsV2(repository)) {
458
+ return repository.commitReservationV2(userId, reservationId, options);
459
+ }
264
460
  const reservation = await repository.getReservation(userId, reservationId);
265
- if (!reservation) {
266
- throw new Error(`Reservation ${reservationId} not found`);
461
+ if (!reservation) return { outcome: "not_found", reservationId };
462
+ if (reservation.status !== "reserved") {
463
+ return {
464
+ outcome: "already_terminal",
465
+ reservation,
466
+ terminalStatus: reservation.status
467
+ };
267
468
  }
268
469
  await repository.commitReservationAtomic(userId, reservationId);
269
470
  const credits = await repository.getUserCredits(userId);
270
- if (credits) {
271
- await repository.createJournalEntry({
272
- userId,
273
- entryType: "debit",
274
- amount: reservation.amount,
275
- balanceAfter: credits.balance,
276
- source: "operation_commit",
277
- referenceId: reservationId,
278
- referenceType: "reservation",
279
- description: `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
280
- metadata: {
281
- operationType: reservation.operationType
282
- }
283
- });
284
- }
471
+ const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
472
+ const entry = await repository.createJournalEntry({
473
+ userId,
474
+ entryType: "debit",
475
+ amount: reservation.amount,
476
+ balanceAfter,
477
+ source: "operation_commit",
478
+ referenceId: reservationId,
479
+ referenceType: "reservation",
480
+ description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
481
+ // Deterministic fields last: they carry the identity of the event, and a
482
+ // caller who happens to send an `operationType` or an `amount` must not
483
+ // relabel what actually moved.
484
+ metadata: {
485
+ ...options?.metadata,
486
+ operationType: reservation.operationType,
487
+ amount: reservation.amount
488
+ }
489
+ });
490
+ return {
491
+ outcome: "committed",
492
+ reservation: { ...reservation, status: "committed" },
493
+ amount: reservation.amount,
494
+ balanceAfter,
495
+ journalEntryId: entry.id
496
+ };
285
497
  }
286
- async function releaseReservationWithJournal(repository, userId, reservationId) {
498
+ async function releaseThroughRepository(repository, userId, reservationId, options) {
499
+ if (supportsCreditsV2(repository)) {
500
+ return repository.releaseReservationV2(userId, reservationId, options);
501
+ }
287
502
  const reservation = await repository.getReservation(userId, reservationId);
503
+ if (!reservation) {
504
+ await repository.releaseReservationAtomic(userId, reservationId);
505
+ return { outcome: "not_found", reservationId };
506
+ }
507
+ if (reservation.status !== "reserved") {
508
+ await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
509
+ return {
510
+ outcome: "already_terminal",
511
+ reservation,
512
+ terminalStatus: reservation.status
513
+ };
514
+ }
288
515
  await repository.releaseReservationAtomic(userId, reservationId);
289
- if (reservation?.status === "reserved") {
290
- const credits = await repository.getUserCredits(userId);
291
- if (credits) {
292
- await repository.createJournalEntry({
293
- userId,
294
- entryType: "credit",
295
- amount: 0,
296
- // No actual credits returned (they were reserved, not spent)
297
- balanceAfter: credits.balance,
298
- source: "operation_release",
299
- referenceId: reservationId,
300
- referenceType: "reservation",
301
- description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
302
- metadata: {
303
- operationType: reservation.operationType,
304
- amount: reservation.amount
305
- }
306
- });
516
+ const credits = await repository.getUserCredits(userId);
517
+ const entry = await repository.createJournalEntry({
518
+ userId,
519
+ entryType: "credit",
520
+ // No credits changed hands — the hold just moved back into "available".
521
+ amount: 0,
522
+ balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
523
+ source: "operation_release",
524
+ referenceId: reservationId,
525
+ referenceType: "reservation",
526
+ description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
527
+ metadata: {
528
+ ...options?.metadata,
529
+ operationType: reservation.operationType,
530
+ amount: reservation.amount
307
531
  }
532
+ });
533
+ return {
534
+ outcome: "released",
535
+ reservation: { ...reservation, status: "released" },
536
+ amount: reservation.amount,
537
+ journalEntryId: entry.id
538
+ };
539
+ }
540
+
541
+ // src/core/operations.ts
542
+ async function commitReservationWithJournal(repository, userId, reservationId) {
543
+ const outcome = await commitThroughRepository(repository, userId, reservationId);
544
+ if (outcome.outcome === "not_found") {
545
+ throw createReservationNotFoundError(reservationId);
546
+ }
547
+ if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
548
+ throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
308
549
  }
309
550
  }
310
- async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3) {
551
+ async function releaseReservationWithJournal(repository, userId, reservationId) {
552
+ await releaseThroughRepository(repository, userId, reservationId);
553
+ }
554
+ async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3, idempotencyKey) {
311
555
  const expiresAt = new Date(Date.now() + expiryMs);
312
- return repository.reserveCreditsAtomic(userId, amount, operationType, expiresAt);
556
+ const outcome = await reserveThroughRepository(repository, {
557
+ userId,
558
+ amount,
559
+ operationType,
560
+ expiresAt,
561
+ idempotencyKey
562
+ });
563
+ if (outcome.outcome === "created" || outcome.outcome === "replayed") {
564
+ return outcome.reservation;
565
+ }
566
+ if (outcome.outcome === "insufficient") {
567
+ throw createInsufficientCreditsError(outcome.required, outcome.available);
568
+ }
569
+ throw createReservationAlreadyProcessedError(
570
+ outcome.existing.id,
571
+ outcome.existing.status
572
+ );
313
573
  }
314
574
 
315
575
  // src/adapters/generic.ts
@@ -357,9 +617,11 @@ function createGenericAdapter(config) {
357
617
  return handler(user, data, dummyReservation);
358
618
  }
359
619
  let reservation;
620
+ let isFree = false;
360
621
  try {
361
622
  const cost = options.customCost ?? getOperationCost(options.operationType);
362
- reservation = await reserveCreditsForOperation(
623
+ isFree = options.customCost === void 0 && cost === 0;
624
+ reservation = isFree ? { ...createDummyReservation(user.id, options.operationType), id: "free-operation" } : await reserveCreditsForOperation(
363
625
  repository,
364
626
  user.id,
365
627
  cost,
@@ -382,7 +644,7 @@ function createGenericAdapter(config) {
382
644
  try {
383
645
  const result = await handler(user, data, reservation);
384
646
  if (result.success) {
385
- await commitReservationWithJournal(repository, user.id, reservation.id);
647
+ if (!isFree) await commitReservationWithJournal(repository, user.id, reservation.id);
386
648
  logUsage({
387
649
  userId: user.id,
388
650
  operationType: options.operationType,
@@ -393,7 +655,7 @@ function createGenericAdapter(config) {
393
655
  requestId
394
656
  });
395
657
  } else {
396
- await releaseReservationWithJournal(repository, user.id, reservation.id);
658
+ if (!isFree) await releaseReservationWithJournal(repository, user.id, reservation.id);
397
659
  logUsage({
398
660
  userId: user.id,
399
661
  operationType: options.operationType,
@@ -408,7 +670,7 @@ function createGenericAdapter(config) {
408
670
  return result;
409
671
  } catch (error) {
410
672
  const message = error instanceof Error ? error.message : "An unexpected error occurred";
411
- await releaseReservationWithJournal(repository, user.id, reservation.id);
673
+ if (!isFree) await releaseReservationWithJournal(repository, user.id, reservation.id);
412
674
  logUsage({
413
675
  userId: user.id,
414
676
  operationType: options.operationType,