@nehorai/credits 2.2.0 → 3.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 (41) hide show
  1. package/dist/adapters/index.cjs +1 -0
  2. package/dist/adapters/index.cjs.map +1 -1
  3. package/dist/adapters/index.d.cts +2 -2
  4. package/dist/adapters/index.d.ts +2 -2
  5. package/dist/adapters/index.js +1 -0
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/core/index.cjs +139 -23
  8. package/dist/core/index.cjs.map +1 -1
  9. package/dist/core/index.d.cts +113 -22
  10. package/dist/core/index.d.ts +113 -22
  11. package/dist/core/index.js +128 -21
  12. package/dist/core/index.js.map +1 -1
  13. package/dist/index.cjs +246 -61
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +4 -4
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +235 -59
  18. package/dist/index.js.map +1 -1
  19. package/dist/repository/index.cjs +183 -41
  20. package/dist/repository/index.cjs.map +1 -1
  21. package/dist/repository/index.d.cts +6 -5
  22. package/dist/repository/index.d.ts +6 -5
  23. package/dist/repository/index.js +183 -41
  24. package/dist/repository/index.js.map +1 -1
  25. package/dist/sdk/index.cjs +1 -0
  26. package/dist/sdk/index.cjs.map +1 -1
  27. package/dist/sdk/index.d.cts +2 -2
  28. package/dist/sdk/index.d.ts +2 -2
  29. package/dist/sdk/index.js +1 -0
  30. package/dist/sdk/index.js.map +1 -1
  31. package/dist/service/index.cjs +25 -16
  32. package/dist/service/index.cjs.map +1 -1
  33. package/dist/service/index.d.cts +2 -2
  34. package/dist/service/index.d.ts +2 -2
  35. package/dist/service/index.js +25 -16
  36. package/dist/service/index.js.map +1 -1
  37. package/dist/{types-MaViK5tD.d.cts → types-C3sxDvNb.d.cts} +29 -6
  38. package/dist/{types-MaViK5tD.d.ts → types-C3sxDvNb.d.ts} +29 -6
  39. package/dist/{types-3c-IAwZI.d.ts → types-CY-oHIMA.d.ts} +19 -3
  40. package/dist/{types-XSjNXO5l.d.cts → types-DOjguhEX.d.cts} +19 -3
  41. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/sdk/types.ts","../../src/sdk/errors.ts","../../src/core/amount.ts","../../src/sdk/client.ts","../../src/sdk/admin-client.ts"],"sourcesContent":["/**\r\n * SDK types for the credits system\r\n *\r\n * These types are used by the client SDKs for REST API consumption.\r\n */\r\n\r\n// Re-export core types for SDK consumers\r\nexport type {\r\n PortableUserCredits as UserCredits,\r\n PortableReservation as CreditReservation,\r\n CreditCheckResult,\r\n SubscriptionTier,\r\n} from \"../core/types.js\";\r\n\r\n/**\r\n * Configuration for CreditsClient\r\n */\r\nexport interface CreditsClientConfig {\r\n /** Base URL of the credits API (e.g., \"https://api.example.com\") */\r\n baseUrl: string;\r\n /** Function to get the auth token for session-based auth */\r\n getAuthToken?: () => Promise<string>;\r\n /** Optional custom fetch function for testing */\r\n fetch?: typeof fetch;\r\n}\r\n\r\n/**\r\n * Configuration for AdminCreditsClient\r\n */\r\nexport interface AdminCreditsClientConfig {\r\n /** Base URL of the credits API */\r\n baseUrl: string;\r\n /** Admin API key */\r\n apiKey: string;\r\n /** Optional custom fetch function for testing */\r\n fetch?: typeof fetch;\r\n}\r\n\r\n/**\r\n * Result of a credit reservation\r\n */\r\nexport interface ReservationResult {\r\n /** The reservation ID */\r\n reservationId: string;\r\n /** Amount of credits reserved */\r\n amount: number;\r\n /** When the reservation expires (ISO 8601) */\r\n expiresAt: string;\r\n}\r\n\r\n/**\r\n * Pagination options for API requests\r\n */\r\nexport interface PaginationOptions {\r\n /** Page number (1-indexed) */\r\n page?: number;\r\n /** Number of items per page */\r\n limit?: number;\r\n}\r\n\r\n/**\r\n * Usage history entry\r\n */\r\nexport interface UsageHistoryEntry {\r\n /** Unique ID of the usage log */\r\n id: string;\r\n /** Type of operation */\r\n operationType: string;\r\n /** AI provider used */\r\n provider: string;\r\n /** Credits consumed */\r\n creditsUsed: number;\r\n /** Whether the operation was successful */\r\n success: boolean;\r\n /** Error message if unsuccessful */\r\n errorMessage?: string;\r\n /** ID of the related resource */\r\n resourceId?: string;\r\n /** Type of the related resource */\r\n resourceType?: string;\r\n /** When the operation occurred (ISO 8601) */\r\n createdAt: string;\r\n}\r\n\r\n/**\r\n * Response from usage history endpoint\r\n */\r\nexport interface UsageHistoryResponse {\r\n /** List of usage entries */\r\n entries: UsageHistoryEntry[];\r\n /** Whether there are more results */\r\n hasMore: boolean;\r\n /** Total count (if available) */\r\n total?: number;\r\n}\r\n\r\n/**\r\n * Generic API response wrapper\r\n */\r\nexport interface ApiResponse<T> {\r\n success: boolean;\r\n data?: T;\r\n error?: {\r\n code: string;\r\n message: string;\r\n details?: unknown;\r\n };\r\n}\r\n\r\n/**\r\n * SDK error codes\r\n */\r\nexport const SDKErrorCode = {\r\n NETWORK_ERROR: \"NETWORK_ERROR\",\r\n AUTHENTICATION_ERROR: \"AUTHENTICATION_ERROR\",\r\n AUTHORIZATION_ERROR: \"AUTHORIZATION_ERROR\",\r\n INSUFFICIENT_CREDITS: \"INSUFFICIENT_CREDITS\",\r\n RESERVATION_NOT_FOUND: \"RESERVATION_NOT_FOUND\",\r\n RESERVATION_EXPIRED: \"RESERVATION_EXPIRED\",\r\n VALIDATION_ERROR: \"VALIDATION_ERROR\",\r\n SERVER_ERROR: \"SERVER_ERROR\",\r\n UNKNOWN_ERROR: \"UNKNOWN_ERROR\",\r\n} as const;\r\n\r\nexport type SDKErrorCodeType = (typeof SDKErrorCode)[keyof typeof SDKErrorCode];\r\n","/**\r\n * SDK error classes\r\n *\r\n * Provides typed error handling for SDK operations.\r\n */\r\n\r\nimport { SDKErrorCode, type SDKErrorCodeType } from \"./types.js\";\r\n\r\n/**\r\n * Base error class for SDK errors\r\n */\r\nexport class CreditsSDKError extends Error {\r\n public readonly code: SDKErrorCodeType;\r\n public readonly details?: Record<string, unknown>;\r\n public readonly statusCode?: number;\r\n\r\n constructor(\r\n message: string,\r\n code: SDKErrorCodeType,\r\n options?: {\r\n details?: Record<string, unknown>;\r\n statusCode?: number;\r\n cause?: Error;\r\n }\r\n ) {\r\n super(message, { cause: options?.cause });\r\n this.name = \"CreditsSDKError\";\r\n this.code = code;\r\n this.details = options?.details;\r\n this.statusCode = options?.statusCode;\r\n }\r\n\r\n /**\r\n * Check if this is a specific error type\r\n */\r\n is(code: SDKErrorCodeType): boolean {\r\n return this.code === code;\r\n }\r\n\r\n /**\r\n * Create a JSON representation for logging\r\n */\r\n toJSON(): Record<string, unknown> {\r\n return {\r\n name: this.name,\r\n message: this.message,\r\n code: this.code,\r\n details: this.details,\r\n statusCode: this.statusCode,\r\n };\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when a network request fails\r\n */\r\nexport class NetworkError extends CreditsSDKError {\r\n constructor(message: string, cause?: Error) {\r\n super(message, SDKErrorCode.NETWORK_ERROR, { cause });\r\n this.name = \"NetworkError\";\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when authentication fails\r\n */\r\nexport class AuthenticationError extends CreditsSDKError {\r\n constructor(message: string = \"Authentication required\") {\r\n super(message, SDKErrorCode.AUTHENTICATION_ERROR, { statusCode: 401 });\r\n this.name = \"AuthenticationError\";\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when authorization fails\r\n */\r\nexport class AuthorizationError extends CreditsSDKError {\r\n constructor(message: string = \"Access denied\") {\r\n super(message, SDKErrorCode.AUTHORIZATION_ERROR, { statusCode: 403 });\r\n this.name = \"AuthorizationError\";\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when credits are insufficient\r\n */\r\nexport class InsufficientCreditsError extends CreditsSDKError {\r\n public readonly available: number;\r\n public readonly required: number;\r\n\r\n constructor(available: number, required: number) {\r\n super(\r\n `Insufficient credits: available ${available}, required ${required}`,\r\n SDKErrorCode.INSUFFICIENT_CREDITS,\r\n {\r\n statusCode: 402,\r\n details: { available, required },\r\n }\r\n );\r\n this.name = \"InsufficientCreditsError\";\r\n this.available = available;\r\n this.required = required;\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when a reservation is not found\r\n */\r\nexport class ReservationNotFoundError extends CreditsSDKError {\r\n public readonly reservationId: string;\r\n\r\n constructor(reservationId: string) {\r\n super(\r\n `Reservation not found: ${reservationId}`,\r\n SDKErrorCode.RESERVATION_NOT_FOUND,\r\n {\r\n statusCode: 404,\r\n details: { reservationId },\r\n }\r\n );\r\n this.name = \"ReservationNotFoundError\";\r\n this.reservationId = reservationId;\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when a reservation has expired\r\n */\r\nexport class ReservationExpiredError extends CreditsSDKError {\r\n public readonly reservationId: string;\r\n\r\n constructor(reservationId: string) {\r\n super(\r\n `Reservation expired: ${reservationId}`,\r\n SDKErrorCode.RESERVATION_EXPIRED,\r\n {\r\n statusCode: 410,\r\n details: { reservationId },\r\n }\r\n );\r\n this.name = \"ReservationExpiredError\";\r\n this.reservationId = reservationId;\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown for validation failures\r\n */\r\nexport class ValidationError extends CreditsSDKError {\r\n public readonly field?: string;\r\n\r\n constructor(message: string, field?: string) {\r\n super(message, SDKErrorCode.VALIDATION_ERROR, {\r\n statusCode: 400,\r\n details: field ? { field } : undefined,\r\n });\r\n this.name = \"ValidationError\";\r\n this.field = field;\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown for server errors\r\n */\r\nexport class ServerError extends CreditsSDKError {\r\n constructor(message: string = \"Internal server error\") {\r\n super(message, SDKErrorCode.SERVER_ERROR, { statusCode: 500 });\r\n this.name = \"ServerError\";\r\n }\r\n}\r\n\r\n/**\r\n * Parse an API error response into the appropriate SDK error\r\n */\r\nexport function parseApiError(\r\n statusCode: number,\r\n body: { error?: { code?: string; message?: string; details?: unknown } }\r\n): CreditsSDKError {\r\n const errorCode = body.error?.code || \"\";\r\n const errorMessage = body.error?.message || \"An error occurred\";\r\n const details = body.error?.details as Record<string, unknown> | undefined;\r\n\r\n switch (statusCode) {\r\n case 401:\r\n return new AuthenticationError(errorMessage);\r\n case 403:\r\n return new AuthorizationError(errorMessage);\r\n case 402:\r\n // Parse insufficient credits details\r\n if (details?.available !== undefined && details?.required !== undefined) {\r\n return new InsufficientCreditsError(\r\n details.available as number,\r\n details.required as number\r\n );\r\n }\r\n return new CreditsSDKError(errorMessage, SDKErrorCode.INSUFFICIENT_CREDITS, {\r\n statusCode,\r\n details,\r\n });\r\n case 404:\r\n if (errorCode === \"RESERVATION_NOT_FOUND\" && details?.reservationId) {\r\n return new ReservationNotFoundError(details.reservationId as string);\r\n }\r\n return new CreditsSDKError(errorMessage, SDKErrorCode.RESERVATION_NOT_FOUND, {\r\n statusCode,\r\n details,\r\n });\r\n case 410:\r\n if (errorCode === \"RESERVATION_EXPIRED\" && details?.reservationId) {\r\n return new ReservationExpiredError(details.reservationId as string);\r\n }\r\n return new CreditsSDKError(errorMessage, SDKErrorCode.RESERVATION_EXPIRED, {\r\n statusCode,\r\n details,\r\n });\r\n case 400:\r\n case 422:\r\n return new ValidationError(errorMessage, details?.field as string | undefined);\r\n case 500:\r\n default:\r\n if (statusCode >= 500) {\r\n return new ServerError(errorMessage);\r\n }\r\n return new CreditsSDKError(errorMessage, SDKErrorCode.UNKNOWN_ERROR, {\r\n statusCode,\r\n details,\r\n });\r\n }\r\n}\r\n","/**\n * Validation and exact comparison for credit amounts.\n *\n * Every amount ends up in a `numeric(12, 2)` column, so the ledger can only\n * represent values on the cent grid, with at most ten integer digits. A value\n * outside that grid is not \"close enough\" — PostgreSQL silently rounds it on\n * write, and the row that comes back no longer equals what the caller asked\n * for. Rejecting it before any row is touched is the only way the ledger and\n * the caller can agree on what happened.\n *\n * These live in core rather than in an adapter so every repository — shipped\n * or third-party — validates identically.\n */\n\nimport { CreditError, CreditErrorCode } from \"./errors.js\";\nimport type { PortableReservation } from \"./types.js\";\n\n/** `numeric(12, 2)`: 12 significant digits, 2 of them after the point. */\nexport const CREDIT_AMOUNT_SCALE = 2;\n/** Largest representable amount: ten integer digits plus two decimals. */\nexport const CREDIT_AMOUNT_MAX = 9_999_999_999.99;\nconst MAX_CENTS = 999_999_999_999;\n\n/**\n * Convert an amount to whole cents, or `null` if it is not exactly on the grid.\n *\n * `Math.round(value * 100) === value * 100` is the tempting check and it is\n * wrong: `1.005 * 100` is `100.49999999999999` in binary float, so a genuinely\n * invalid value can pass and a valid one can fail. Rounding first and then\n * confirming the value round-trips (`value === cents / 100`) is exact for\n * everything in range, because a cent count below 2^53 divided by 100 is the\n * nearest double to that decimal, which is what the literal parsed to.\n */\nexport function toCents(value: number): number | null {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return null;\n const cents = Math.round(value * 100);\n if (!Number.isSafeInteger(cents)) return null;\n return value === cents / 100 ? cents : null;\n}\n\n/**\n * Add amounts on the cent grid, exactly.\n *\n * Deriving a total with `+` is not safe in a ledger that validates against the\n * grid: `0.10 + 0.20` is `0.30000000000000004` and `0.30 - 0.10` is\n * `0.19999999999999998`, neither of which is a value `numeric(12, 2)` can hold.\n * Two perfectly legal columns would therefore produce a total the validator\n * rejects with `INVALID_AMOUNT`, blaming the caller for a rounding artefact.\n * Summing whole cents is exact for every value in range, and `cents / 100` is\n * the nearest double to that decimal — the same one the literal would parse to.\n *\n * Subtract by negating: `sumAmounts(total, -amount)`. Negation is exact.\n *\n * This is arithmetic, not validation. If any input is itself off the grid the\n * float sum is returned unchanged — deliberately, so a corrupt value is never\n * rounded into legitimacy here — but that is not a guarantee the result will be\n * rejected downstream: two off-grid inputs can cancel, and `sumAmounts(0.005,\n * -0.005)` is a perfectly representable `0`. Inputs read from storage must be\n * validated as inputs, with `assertValidStoredAmount` or\n * `assertValidStoredAmountRaw`, before they are summed. Checking only the\n * derived result is not enough and never was.\n */\nexport function sumAmounts(...values: number[]): number {\n let cents = 0;\n for (const value of values) {\n const valueCents = toCents(value);\n if (valueCents === null) return floatSum(values);\n cents += valueCents;\n }\n return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);\n}\n\nfunction floatSum(values: number[]): number {\n let total = 0;\n for (const value of values) total += value;\n return total;\n}\n\n/** Amounts that may be *moved*: strictly positive and on the cent grid. */\nexport function isValidCreditAmount(value: unknown): value is number {\n if (typeof value !== \"number\") return false;\n const cents = toCents(value);\n return cents !== null && cents > 0 && cents <= MAX_CENTS;\n}\n\n/**\n * Reject an unusable amount before any row is written.\n *\n * @param context - merged into the error details, e.g. `{ userId }`.\n */\nexport function assertValidCreditAmount(\n value: unknown,\n context?: Record<string, unknown>\n): asserts value is number {\n if (isValidCreditAmount(value)) return;\n throw new CreditError(\n creditAmountReason(value),\n CreditErrorCode.INVALID_AMOUNT,\n { amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }\n );\n}\n\n/** Say precisely which rule the value broke, so the caller can fix it. */\nfunction creditAmountReason(value: unknown): string {\n if (typeof value !== \"number\") {\n return `Credit amount must be a number (got ${typeof value})`;\n }\n if (!Number.isFinite(value)) {\n return `Credit amount must be finite (got ${String(value)})`;\n }\n if (value <= 0) {\n return `Credit amount must be greater than zero (got ${value})`;\n }\n if (value > CREDIT_AMOUNT_MAX) {\n return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;\n }\n return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;\n}\n\n/**\n * Amounts the ledger can *record*: on the cent grid and in range, but free to\n * be zero or negative.\n *\n * Not every numeric column holds something spendable. A release journal entry\n * records `amount: 0` because no credits changed hands, and `balanceAfter` goes\n * negative for an account that was corrected or allowed to overdraw. Forcing\n * those through {@link isValidCreditAmount} would reject legitimate ledger\n * rows; skipping validation entirely would let `Infinity` or a third decimal\n * reach the column and be silently rounded or rejected by the driver.\n */\nexport function isRepresentableAmount(value: unknown): value is number {\n if (typeof value !== \"number\") return false;\n const cents = toCents(value);\n return cents !== null && Math.abs(cents) <= MAX_CENTS;\n}\n\n/**\n * Reject a ledger value the `numeric(12, 2)` column cannot hold exactly.\n *\n * @param field - the column being written, so the error names the culprit.\n */\nexport function assertRepresentableAmount(\n value: unknown,\n field: string,\n context?: Record<string, unknown>\n): asserts value is number {\n if (isRepresentableAmount(value)) return;\n throw new CreditError(\n `${field} must be a finite number exact to ${CREDIT_AMOUNT_SCALE} decimal places ` +\n `within ±${CREDIT_AMOUNT_MAX} (got ${String(value)})`,\n CreditErrorCode.INVALID_AMOUNT,\n { field, value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }\n );\n}\n\n/**\n * Check a whole set of optional ledger fields at once.\n *\n * The balance writers each take a handful of independently optional numbers,\n * and validating them one `if` at a time is where one quietly gets missed.\n * Undefined entries are skipped — they mean \"leave this column alone\".\n */\nexport function assertRepresentableFields(\n fields: Record<string, unknown>,\n context?: Record<string, unknown>\n): void {\n for (const [field, value] of Object.entries(fields)) {\n if (value === undefined) continue;\n assertRepresentableAmount(value, field, context);\n }\n}\n\n/**\n * The lowest `balance` that keeps every outstanding hold committable.\n *\n * Commit's funding guard is `balance + bonusCredits >= amount`, and every live\n * hold is counted in `reserved`, so any write that lowers `balance` must keep\n * `balance + bonusCredits >= reserved` — otherwise a hold that was fully backed\n * when it was placed throws `INSUFFICIENT_CREDITS` at commit time and is\n * stranded. Monthly resets, tier downgrades and explicit tier writes all lower\n * `balance` to a configured target; this is the floor they may not cut through.\n *\n * The floor is `reserved - bonusCredits`, never below zero: bonus credits also\n * fund commits, so they count toward the backing.\n */\nexport function backedBalanceFloor(reserved: number, bonusCredits: number): number {\n const floor = Math.max(0, sumAmounts(reserved, -bonusCredits));\n // A floor beyond the representable range means the row itself is corrupt —\n // e.g. a large negative `bonusCredits` pushing `reserved - bonusCredits`\n // past the `numeric(12,2)` cap. Refuse loudly: writing it would succeed in\n // memory and be rejected by the SQL column, splitting the adapters.\n assertRepresentableAmount(floor, \"backedBalanceFloor\", { reserved, bonusCredits });\n return floor;\n}\n\n/**\n * The balance left by a cap: lowered to `cap` at most, never raised, and never\n * cut through the credits backing outstanding holds (see\n * {@link backedBalanceFloor}). A balance already under `cap` is left alone,\n * which is the point of computing this from the stored row inside a transaction\n * rather than writing a value the caller read earlier.\n */\nexport function cappedBalance(\n balance: number,\n cap: number,\n reserved: number,\n bonusCredits: number\n): number {\n return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));\n}\n\n/** A balance a lowering write lands on, and how much of it only backs holds. */\nexport interface PlannedBalance {\n balance: number;\n expiredHoldBacking: number;\n}\n\n/** The part of a balance not kept only for holds. A missing field reads as 0. */\nexport function spendableBalance(balance: number, expiredHoldBacking = 0): number {\n return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));\n}\n\n/**\n * An absolute balance write: `target`, floored at what backs outstanding holds.\n * Whatever the floor kept above `target` is not the account's to spend.\n */\nexport function planBalanceTarget(\n target: number,\n reserved: number,\n bonusCredits: number\n): PlannedBalance {\n const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));\n return { balance, expiredHoldBacking: sumAmounts(balance, -target) };\n}\n\n/**\n * A cap on the spendable part of a balance. Like {@link cappedBalance} it never\n * raises the balance and never cuts through what backs holds, and it records the\n * part kept above the cap so a later release cannot hand it back. Credits already\n * marked stay marked, so applying a cap twice changes nothing.\n */\nexport function planBalanceCap(\n balance: number,\n cap: number,\n reserved: number,\n bonusCredits: number,\n expiredHoldBacking = 0\n): PlannedBalance {\n const spendable = spendableBalance(balance, expiredHoldBacking);\n const target = Math.min(spendable, cap);\n // Marked credits belong to the holds they back. A cap adds to them when the floor\n // needs it and never takes them away, even if later bonus credits lowered the floor.\n const kept = Math.max(\n sumAmounts(balance, -spendable),\n sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)\n );\n const next = Math.min(balance, sumAmounts(target, kept));\n return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };\n}\n\n/**\n * What a closing hold takes out of the credits kept only for holds: its own share\n * and nothing of another hold's. `holdBacking` comes from\n * {@link attributeExpiredBacking}.\n */\nexport function settleExpiredHoldBacking(\n balance: number,\n expiredHoldBacking: number,\n holdBacking: number\n): { used: number; remaining: number } {\n const held = Math.min(expiredHoldBacking, balance);\n const used = Math.min(held, holdBacking);\n return { used, remaining: sumAmounts(held, -used) };\n}\n\n/** The balances a commit lands on, and whether the account could pay it. */\nexport interface PlannedHoldCommit {\n covered: boolean;\n balance: number;\n bonusCredits: number;\n expiredHoldBacking: number;\n}\n\n/**\n * Pay a hold: first its own share of the marked credits, then spendable balance,\n * then bonus. Marked credits backing other holds are never touched, so what this\n * commit costs does not depend on which holds closed before it.\n */\nexport function planHoldCommit(\n balance: number,\n bonusCredits: number,\n expiredHoldBacking: number,\n holdBacking: number,\n amount: number\n): PlannedHoldCommit {\n const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);\n const rest = sumAmounts(amount, -backing.used);\n const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);\n const fromBonus = sumAmounts(rest, -fromSpendable);\n return {\n covered: fromBonus <= bonusCredits,\n balance: sumAmounts(balance, -backing.used, -fromSpendable),\n bonusCredits: sumAmounts(bonusCredits, -fromBonus),\n expiredHoldBacking: backing.remaining,\n };\n}\n\n/** The fields of an open hold that decide its share of the marked credits. */\nexport type ExpiredBackingHold = Pick<\n PortableReservation,\n \"id\" | \"amount\" | \"expiredBacking\" | \"holdPlacedAt\"\n>;\n\n/**\n * Split `total` marked credits across open holds, returning each hold's share.\n *\n * Shares a hold already has are kept first, oldest hold first, so lowering `total`\n * trims the newest and raising it never moves credits off a hold. Only what is left\n * goes to holds with room, again oldest first, which makes the split a function of\n * the holds and not of the order they later close in. A hold never carries more than\n * its amount. A row without `holdPlacedAt` is a record, not a hold, and gets nothing.\n *\n * The account's `expiredHoldBacking` is written as the sum of these shares, in the\n * same write, so what `total` could not attribute is not marked. Such credits stay out\n * of reach anyway, because the `reserved` that kept them still counts against them.\n */\nexport function attributeExpiredBacking(\n holds: readonly ExpiredBackingHold[],\n total: number\n): Map<string, number> {\n const placed = holds\n .filter((hold) => hold.holdPlacedAt)\n .sort((a, b) =>\n a.holdPlacedAt === b.holdPlacedAt\n ? a.id < b.id ? -1 : 1\n : (a.holdPlacedAt as string) < (b.holdPlacedAt as string) ? -1 : 1\n );\n const shares = new Map<string, number>();\n let left = total;\n for (const hold of placed) {\n const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);\n shares.set(hold.id, kept);\n left = sumAmounts(left, -kept);\n }\n for (const hold of placed) {\n const share = shares.get(hold.id) as number;\n const added = Math.min(sumAmounts(hold.amount, -share), left);\n shares.set(hold.id, sumAmounts(share, added));\n left = sumAmounts(left, -added);\n }\n return shares;\n}\n\n/**\n * The field to write for a new backing value: nothing when it was and stays 0, so\n * an account that never had any keeps a document without the field.\n */\nexport function expiredHoldBackingPatch(\n next: number,\n stored = 0\n): { expiredHoldBacking?: number } {\n return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};\n}\n\n/**\n * Validate a balance target supplied by the application: a cap or the result of\n * a monthly-reset decision. It must be storable and cannot be negative.\n */\nexport function assertValidBalanceTarget(\n value: unknown,\n field: string,\n context?: Record<string, unknown>\n): asserts value is number {\n assertRepresentableAmount(value, field, context);\n if (value < 0) {\n throw new CreditError(\n `${field} cannot be negative (got ${value})`,\n CreditErrorCode.INVALID_AMOUNT,\n { field, value, ...context }\n );\n }\n}\n\n/**\n * Validate how a tier write changes the balance. `balance` sets a value the\n * caller computed and `balanceCap` lowers the stored value, so naming both has\n * no single meaning and is refused.\n */\nexport function assertValidTierBalanceInput(\n input: { balance?: number; balanceCap?: number },\n context?: Record<string, unknown>\n): void {\n if (input.balanceCap === undefined) return;\n if (input.balance !== undefined) {\n throw new CreditError(\n \"A tier update cannot set both balance and balanceCap\",\n CreditErrorCode.INVALID_AMOUNT,\n { balance: input.balance, balanceCap: input.balanceCap, ...context }\n );\n }\n assertValidBalanceTarget(input.balanceCap, \"balanceCap\", context);\n}\n\n/**\n * Validate a limit derived from tier configuration.\n *\n * `Infinity` is this codebase's sentinel for an unlimited tier, so it is\n * accepted here; {@link storedMonthlyLimit} decides what actually reaches the\n * column. Every other value ends up in `numeric(12, 2)` and has to fit, so a\n * tier configured with `monthlyCredits: 1.005` is caught here rather than\n * silently rounded to `1.01` by PostgreSQL.\n */\nexport function assertRepresentableTierAmount(\n value: unknown,\n field: string,\n context?: Record<string, unknown>\n): void {\n if (value === Number.POSITIVE_INFINITY) return;\n assertRepresentableAmount(value, field, context);\n}\n\n/**\n * Validate a stored amount in the representation storage actually returned.\n *\n * Checking the mapped float is not enough, because the mapping is lossy in both\n * directions. A `numeric` column with no scale constraint can hold\n * `9999999999.9900001`; `Number()` rounds that to `9999999999.99`, which is a\n * perfectly valid amount, so a validator looking at the mapped value sees\n * nothing wrong and the transition proceeds — while PostgreSQL goes on doing\n * exact arithmetic with the digits JavaScript threw away. `NaN` and values past\n * double range are worse: the mapper turns them into `0`, which misreports what\n * the row holds.\n *\n * So this runs on the driver's own output — a string, for `numeric` — and uses\n * exact integer arithmetic. It returns the amount as a float only after\n * establishing that the float is a faithful representation of it.\n *\n * @returns the stored amount, exactly representable as a JS number.\n */\n/**\n * How an unlimited tier's monthly limit is stored.\n *\n * `getConfigMonthlyLimit` returns `Infinity` for an unlimited tier, and no\n * numeric column can hold that. Both adapters therefore store the ceiling of\n * the representable range instead: any real usage compares below it, so\n * \"unlimited\" keeps behaving like unlimited, and a reader that knows nothing\n * about the sentinel sees a very large allowance rather than a wrong one.\n *\n * This exists so the two adapters cannot drift. The SQL adapter previously\n * stored `0` here, which reads as *no* allowance — the exact opposite — while\n * the in-memory adapter kept `Infinity`.\n */\nexport function storedMonthlyLimit(limit: number): number {\n return Number.isFinite(limit) ? limit : CREDIT_AMOUNT_MAX;\n}\n\nexport function assertValidStoredAmountRaw(\n raw: unknown,\n context: Record<string, unknown>\n): number {\n const cents = numericToCents(raw);\n if (cents !== null && cents > 0n && cents <= BigInt(MAX_CENTS)) {\n return Number(cents) / 100;\n }\n throw new CreditError(\n `Stored credit amount ${String(raw)} is not a valid amount, so this ` +\n \"transition was refused before any state changed. The row must be \" +\n \"repaired before it can be committed, released or expired.\",\n CreditErrorCode.INVALID_AMOUNT,\n {\n amount: typeof raw === \"string\" ? raw : String(raw),\n reason: \"corrupt_stored_amount\",\n max: CREDIT_AMOUNT_MAX,\n scale: CREDIT_AMOUNT_SCALE,\n ...context,\n }\n );\n}\n\n/**\n * Reject a *persisted* amount that the ledger cannot honour.\n *\n * A reservation row is written once and read back on every transition, and\n * nothing stops a direct SQL write, a botched migration, or an older version of\n * this library from leaving an amount the invariants cannot reason about. A\n * negative one is the dangerous case: `reserved >= amount` is trivially true\n * for it, and `balance - (-10)` *adds* credits, so a transition that trusts the\n * stored value mints money. Every transition therefore re-validates what it\n * locked before it changes anything.\n *\n * Deliberately fails release and expire as well as commit. Returning a hold of\n * an unknown size is not obviously safer than spending one — it hands back\n * coverage that may never have been taken — so a corrupt row stops moving\n * entirely and waits for an operator, rather than being partially honoured.\n *\n * This is the float-typed form, used by the in-memory adapter where the stored\n * value *is* a JS number and no conversion has happened. A repository whose\n * storage returns some other representation must use\n * {@link assertValidStoredAmountRaw} instead, or the conversion will hide the\n * corruption from the check.\n */\nexport function assertValidStoredAmount(\n value: unknown,\n context: Record<string, unknown>\n): asserts value is number {\n if (isValidCreditAmount(value)) return;\n throw new CreditError(\n `Stored credit amount ${String(value)} is not a valid amount, so this ` +\n \"transition was refused before any state changed. The row must be \" +\n \"repaired before it can be committed, released or expired.\",\n CreditErrorCode.INVALID_AMOUNT,\n { amount: value, reason: \"corrupt_stored_amount\", max: CREDIT_AMOUNT_MAX, ...context }\n );\n}\n\n/**\n * Parse a value read back from a `numeric` column into exact cents.\n *\n * Drivers return `numeric` as a string precisely so no precision is lost in\n * transit; comparing the parsed floats would throw that away again. Returns\n * `null` for anything unparseable so callers can treat it as \"not equal\"\n * rather than crashing on malformed ledger data.\n */\nexport function numericToCents(value: unknown): bigint | null {\n if (typeof value === \"number\") {\n const cents = toCents(value);\n return cents === null ? null : BigInt(cents);\n }\n if (typeof value !== \"string\") return null;\n\n const match = /^([+-]?)(\\d+)(?:\\.(\\d*))?$/.exec(value.trim());\n if (!match) return null;\n\n const [, sign, whole, fraction = \"\"] = match;\n // More than two decimals is only acceptable when the extra digits are zeros;\n // anything else never came from a numeric(12,2) column.\n if (/[1-9]/.test(fraction.slice(CREDIT_AMOUNT_SCALE))) return null;\n\n const cents =\n BigInt(whole) * 100n + BigInt(fraction.slice(0, CREDIT_AMOUNT_SCALE).padEnd(CREDIT_AMOUNT_SCALE, \"0\"));\n return sign === \"-\" ? -cents : cents;\n}\n\n/**\n * Exact equality for two amounts that may be numbers or `numeric` strings.\n *\n * Unparseable input is *not* equal to anything, including itself — a\n * comparison against corrupt data must never come back \"matches\".\n */\nexport function sameAmount(a: unknown, b: unknown): boolean {\n const left = numericToCents(a);\n const right = numericToCents(b);\n return left !== null && right !== null && left === right;\n}\n","/**\r\n * CreditsClient - REST API client for the credits system\r\n *\r\n * Provides a type-safe interface for consuming credits API endpoints\r\n * from external services or other applications.\r\n *\r\n * @example\r\n * ```typescript\r\n * const client = new CreditsClient({\r\n * baseUrl: process.env.CREDITS_API_URL,\r\n * getAuthToken: async () => session.accessToken,\r\n * });\r\n *\r\n * // Check if user has sufficient credits\r\n * const check = await client.checkAvailability(\"story_generation\");\r\n * if (!check.hasCredits) {\r\n * console.log(\"Insufficient credits\");\r\n * return;\r\n * }\r\n *\r\n * // Two-phase commit pattern\r\n * const reservation = await client.reserve(\"story_generation\");\r\n * try {\r\n * const result = await doExpensiveWork();\r\n * await client.commit(reservation.reservationId);\r\n * return result;\r\n * } catch (error) {\r\n * await client.release(reservation.reservationId);\r\n * throw error;\r\n * }\r\n * ```\r\n */\r\n\r\nimport type {\r\n CreditsClientConfig,\r\n UserCredits,\r\n CreditCheckResult,\r\n ReservationResult,\r\n UsageHistoryResponse,\r\n PaginationOptions,\r\n} from \"./types.js\";\r\nimport { parseApiError, NetworkError } from \"./errors.js\";\r\nimport { sumAmounts } from \"../core/amount.js\";\r\n\r\nexport class CreditsClient {\r\n private readonly baseUrl: string;\r\n private readonly getAuthToken?: () => Promise<string>;\r\n private readonly fetchFn: typeof fetch;\r\n\r\n constructor(config: CreditsClientConfig) {\r\n // Ensure baseUrl doesn't have trailing slash\r\n this.baseUrl = config.baseUrl.replace(/\\/$/, \"\");\r\n this.getAuthToken = config.getAuthToken;\r\n this.fetchFn = config.fetch ?? fetch;\r\n }\r\n\r\n /**\r\n * Make an authenticated request to the API\r\n */\r\n private async request<T>(\r\n path: string,\r\n options: {\r\n method?: \"GET\" | \"POST\" | \"PUT\" | \"DELETE\";\r\n body?: unknown;\r\n params?: Record<string, string | number | undefined>;\r\n } = {}\r\n ): Promise<T> {\r\n const { method = \"GET\", body, params } = options;\r\n\r\n // Build URL with query params\r\n let url = `${this.baseUrl}${path}`;\r\n if (params) {\r\n const searchParams = new URLSearchParams();\r\n for (const [key, value] of Object.entries(params)) {\r\n if (value !== undefined) {\r\n searchParams.set(key, String(value));\r\n }\r\n }\r\n const queryString = searchParams.toString();\r\n if (queryString) {\r\n url += `?${queryString}`;\r\n }\r\n }\r\n\r\n // Build headers\r\n const headers: Record<string, string> = {\r\n \"Content-Type\": \"application/json\",\r\n };\r\n\r\n // Add auth token if available\r\n if (this.getAuthToken) {\r\n try {\r\n const token = await this.getAuthToken();\r\n if (token) {\r\n headers[\"Authorization\"] = `Bearer ${token}`;\r\n }\r\n } catch {\r\n // Auth token not available, proceed without it\r\n }\r\n }\r\n\r\n // Make the request\r\n let response: Response;\r\n try {\r\n response = await this.fetchFn(url, {\r\n method,\r\n headers,\r\n body: body ? JSON.stringify(body) : undefined,\r\n });\r\n } catch (error) {\r\n throw new NetworkError(\r\n error instanceof Error ? error.message : \"Network request failed\",\r\n error instanceof Error ? error : undefined\r\n );\r\n }\r\n\r\n // Parse response\r\n const responseBody = (await response.json()) as {\r\n data?: T;\r\n error?: { code?: string; message?: string; details?: unknown };\r\n };\r\n\r\n // Handle errors\r\n if (!response.ok) {\r\n throw parseApiError(response.status, responseBody);\r\n }\r\n\r\n // Return data\r\n return responseBody.data as T;\r\n }\r\n\r\n /**\r\n * Get the current user's credit balance\r\n *\r\n * @returns User's credit information\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async getBalance(): Promise<UserCredits> {\r\n return this.request<UserCredits>(\"/api/v1/credits/balance\");\r\n }\r\n\r\n /**\r\n * Check if the user has sufficient credits for an operation\r\n *\r\n * @param operationType - Type of operation to check\r\n * @param customCost - Optional custom cost to check against\r\n * @returns Credit availability status\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async checkAvailability(\r\n operationType: string,\r\n customCost?: number\r\n ): Promise<CreditCheckResult> {\r\n return this.request<CreditCheckResult>(\"/api/v1/credits/availability\", {\r\n params: {\r\n operationType,\r\n customCost: customCost?.toString(),\r\n },\r\n });\r\n }\r\n\r\n /**\r\n * Reserve credits for an operation\r\n *\r\n * This is the first step in the two-phase commit pattern.\r\n * Reserved credits are held for 5 minutes before expiring.\r\n *\r\n * @param operationType - Type of operation\r\n * @param options - Optional reservation options\r\n * @returns Reservation details including ID and expiry\r\n * @throws InsufficientCreditsError if not enough credits\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async reserve(\r\n operationType: string,\r\n options?: {\r\n customCost?: number;\r\n resourceId?: string;\r\n resourceType?: string;\r\n }\r\n ): Promise<ReservationResult> {\r\n return this.request<ReservationResult>(\"/api/v1/credits/reserve\", {\r\n method: \"POST\",\r\n body: {\r\n operationType,\r\n ...options,\r\n },\r\n });\r\n }\r\n\r\n /**\r\n * Commit a reservation (deduct credits)\r\n *\r\n * This is the second step in the two-phase commit pattern.\r\n * Call this after your operation succeeds.\r\n *\r\n * @param reservationId - ID of the reservation to commit\r\n * @throws ReservationNotFoundError if reservation doesn't exist\r\n * @throws ReservationExpiredError if reservation has expired\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async commit(reservationId: string): Promise<void> {\r\n await this.request<{ message: string }>(\"/api/v1/credits/commit\", {\r\n method: \"POST\",\r\n body: { reservationId },\r\n });\r\n }\r\n\r\n /**\r\n * Release a reservation (refund credits)\r\n *\r\n * Call this if your operation fails after reserving credits.\r\n *\r\n * @param reservationId - ID of the reservation to release\r\n * @throws ReservationNotFoundError if reservation doesn't exist\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async release(reservationId: string): Promise<void> {\r\n await this.request<{ message: string }>(\"/api/v1/credits/release\", {\r\n method: \"POST\",\r\n body: { reservationId },\r\n });\r\n }\r\n\r\n /**\r\n * Get usage history for the current user\r\n *\r\n * @param options - Optional pagination options\r\n * @returns Paginated list of usage entries\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async getHistory(options?: PaginationOptions): Promise<UsageHistoryResponse> {\r\n return this.request<UsageHistoryResponse>(\"/api/v1/credits/history\", {\r\n params: {\r\n page: options?.page,\r\n limit: options?.limit,\r\n },\r\n });\r\n }\r\n\r\n /**\r\n * Get the available credits (balance + bonus - reserved)\r\n *\r\n * Convenience method that calculates available credits from balance.\r\n *\r\n * @returns Number of available credits\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async getAvailableCredits(): Promise<number> {\r\n const balance = await this.getBalance();\r\n return sumAmounts(balance.balance, balance.bonusCredits, -balance.reserved);\r\n }\r\n\r\n /**\r\n * Execute an operation with automatic credit handling\r\n *\r\n * This is a convenience method that handles the full two-phase commit\r\n * pattern automatically.\r\n *\r\n * @param operationType - Type of operation\r\n * @param operation - The operation to execute\r\n * @param options - Optional reservation options\r\n * @returns Result of the operation\r\n * @throws InsufficientCreditsError if not enough credits\r\n * @throws AuthenticationError if not authenticated\r\n *\r\n * @example\r\n * ```typescript\r\n * const result = await client.withCredits(\r\n * \"story_generation\",\r\n * async () => {\r\n * return generateStory(data);\r\n * }\r\n * );\r\n * ```\r\n */\r\n async withCredits<T>(\r\n operationType: string,\r\n operation: () => Promise<T>,\r\n options?: {\r\n customCost?: number;\r\n resourceId?: string;\r\n resourceType?: string;\r\n }\r\n ): Promise<T> {\r\n const reservation = await this.reserve(operationType, options);\r\n\r\n try {\r\n const result = await operation();\r\n await this.commit(reservation.reservationId);\r\n return result;\r\n } catch (error) {\r\n // Always attempt to release on failure\r\n try {\r\n await this.release(reservation.reservationId);\r\n } catch {\r\n // Ignore release errors, original error is more important\r\n }\r\n throw error;\r\n }\r\n }\r\n}\r\n","/**\r\n * AdminCreditsClient - Admin REST API client for the credits system\r\n *\r\n * Provides administrative access to the credits system for managing\r\n * user credits, configurations, and subscriptions.\r\n *\r\n * @example\r\n * ```typescript\r\n * const adminClient = new AdminCreditsClient({\r\n * baseUrl: process.env.CREDITS_API_URL,\r\n * apiKey: process.env.CREDITS_ADMIN_API_KEY,\r\n * });\r\n *\r\n * // Add credits to a user\r\n * await adminClient.addCredits(\"user-123\", 100, \"Support credit grant\");\r\n *\r\n * // Update subscription tier\r\n * await adminClient.updateSubscription(\"user-123\", \"premium\", \"2027-01-01T00:00:00Z\");\r\n *\r\n * // Update operation costs\r\n * await adminClient.updateCosts({\r\n * story_generation: 8,\r\n * image_generation: 12,\r\n * });\r\n * ```\r\n */\r\n\r\nimport type { AdminCreditsClientConfig, UserCredits } from \"./types.js\";\r\nimport { parseApiError, NetworkError } from \"./errors.js\";\r\n\r\n/**\r\n * Credits configuration returned by the API\r\n */\r\nexport interface CreditsConfig {\r\n operationCosts: Record<string, number>;\r\n tierConfigs: Record<\r\n string,\r\n {\r\n monthlyLimit: number;\r\n features: string[];\r\n }\r\n >;\r\n}\r\n\r\n/**\r\n * Response from listing users\r\n */\r\nexport interface ListUsersResponse {\r\n users: Array<{\r\n userId: string;\r\n balance: number;\r\n bonusCredits?: number;\r\n tier: string;\r\n monthlyUsed?: number;\r\n }>;\r\n hasMore: boolean;\r\n total?: number;\r\n}\r\n\r\n/**\r\n * Options for listing users\r\n */\r\nexport interface ListUsersOptions {\r\n page?: number;\r\n limit?: number;\r\n tier?: string;\r\n search?: string;\r\n}\r\n\r\nexport class AdminCreditsClient {\r\n private readonly baseUrl: string;\r\n private readonly apiKey: string;\r\n private readonly fetchFn: typeof fetch;\r\n\r\n constructor(config: AdminCreditsClientConfig) {\r\n // Ensure baseUrl doesn't have trailing slash\r\n this.baseUrl = config.baseUrl.replace(/\\/$/, \"\");\r\n this.apiKey = config.apiKey;\r\n this.fetchFn = config.fetch ?? fetch;\r\n }\r\n\r\n /**\r\n * Make an authenticated admin request to the API\r\n */\r\n private async request<T>(\r\n path: string,\r\n options: {\r\n method?: \"GET\" | \"POST\" | \"PUT\" | \"DELETE\";\r\n body?: unknown;\r\n params?: Record<string, string | number | undefined>;\r\n } = {}\r\n ): Promise<T> {\r\n const { method = \"GET\", body, params } = options;\r\n\r\n // Build URL with query params\r\n let url = `${this.baseUrl}${path}`;\r\n if (params) {\r\n const searchParams = new URLSearchParams();\r\n for (const [key, value] of Object.entries(params)) {\r\n if (value !== undefined) {\r\n searchParams.set(key, String(value));\r\n }\r\n }\r\n const queryString = searchParams.toString();\r\n if (queryString) {\r\n url += `?${queryString}`;\r\n }\r\n }\r\n\r\n // Build headers with admin API key\r\n const headers: Record<string, string> = {\r\n \"Content-Type\": \"application/json\",\r\n \"x-api-key\": this.apiKey,\r\n };\r\n\r\n // Make the request\r\n let response: Response;\r\n try {\r\n response = await this.fetchFn(url, {\r\n method,\r\n headers,\r\n body: body ? JSON.stringify(body) : undefined,\r\n });\r\n } catch (error) {\r\n throw new NetworkError(\r\n error instanceof Error ? error.message : \"Network request failed\",\r\n error instanceof Error ? error : undefined\r\n );\r\n }\r\n\r\n // Parse response\r\n const responseBody = (await response.json()) as {\r\n data?: T;\r\n error?: { code?: string; message?: string; details?: unknown };\r\n };\r\n\r\n // Handle errors\r\n if (!response.ok) {\r\n throw parseApiError(response.status, responseBody);\r\n }\r\n\r\n // Return data\r\n return responseBody.data as T;\r\n }\r\n\r\n /**\r\n * Get a specific user's credits\r\n *\r\n * @param userId - ID of the user\r\n * @returns User's credit information\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async getUserCredits(userId: string): Promise<UserCredits> {\r\n return this.request<UserCredits>(`/api/admin/credits/users/${userId}`);\r\n }\r\n\r\n /**\r\n * Add credits to a user's account\r\n *\r\n * Credits are added to bonusCredits, which don't expire on monthly reset.\r\n *\r\n * @param userId - ID of the user\r\n * @param amount - Amount of credits to add\r\n * @param description - Reason for adding credits\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async addCredits(\r\n userId: string,\r\n amount: number,\r\n description: string\r\n ): Promise<void> {\r\n await this.request<{ message: string; newBalance: number }>(\r\n `/api/admin/credits/users/${userId}/add`,\r\n {\r\n method: \"POST\",\r\n body: { amount, description },\r\n }\r\n );\r\n }\r\n\r\n /**\r\n * Update a user's subscription tier\r\n *\r\n * @param userId - ID of the user\r\n * @param tier - New subscription tier\r\n * @param expiresAt - Optional expiration date (ISO 8601)\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async updateSubscription(\r\n userId: string,\r\n tier: string,\r\n expiresAt?: string\r\n ): Promise<void> {\r\n const body: { tier: string; expiresAt?: string } = { tier };\r\n if (expiresAt) {\r\n body.expiresAt = expiresAt;\r\n }\r\n\r\n await this.request<{ message: string }>(\r\n `/api/admin/credits/users/${userId}/subscription`,\r\n {\r\n method: \"PUT\",\r\n body,\r\n }\r\n );\r\n }\r\n\r\n /**\r\n * Get the current credits configuration\r\n *\r\n * @returns Credits system configuration\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async getConfig(): Promise<CreditsConfig> {\r\n return this.request<CreditsConfig>(\"/api/admin/credits/config\");\r\n }\r\n\r\n /**\r\n * Update operation costs\r\n *\r\n * @param costs - New operation costs (operation type -> cost)\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async updateCosts(costs: Record<string, number>): Promise<void> {\r\n await this.request<{ message: string }>(\"/api/admin/credits/config/costs\", {\r\n method: \"PUT\",\r\n body: { operationCosts: costs },\r\n });\r\n }\r\n\r\n /**\r\n * List all users with their credits\r\n *\r\n * @param options - Pagination and filtering options\r\n * @returns Paginated list of users\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async listUsers(options?: ListUsersOptions): Promise<ListUsersResponse> {\r\n return this.request<ListUsersResponse>(\"/api/admin/credits/users\", {\r\n params: {\r\n page: options?.page,\r\n limit: options?.limit,\r\n tier: options?.tier,\r\n search: options?.search,\r\n },\r\n });\r\n }\r\n\r\n /**\r\n * Deduct credits from a user's account\r\n *\r\n * @param userId - ID of the user\r\n * @param amount - Amount of credits to deduct\r\n * @param description - Reason for deducting credits\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async deductCredits(\r\n userId: string,\r\n amount: number,\r\n description: string\r\n ): Promise<void> {\r\n await this.request<{ message: string; newBalance: number }>(\r\n `/api/admin/credits/users/${userId}/deduct`,\r\n {\r\n method: \"POST\",\r\n body: { amount, description },\r\n }\r\n );\r\n }\r\n\r\n /**\r\n * Get usage statistics for a user\r\n *\r\n * @param userId - ID of the user\r\n * @param options - Optional date range\r\n * @returns Usage statistics\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async getUserStats(\r\n userId: string,\r\n options?: { startDate?: string; endDate?: string }\r\n ): Promise<{\r\n totalCreditsUsed: number;\r\n operationBreakdown: Record<string, number>;\r\n successRate: number;\r\n }> {\r\n return this.request<{\r\n totalCreditsUsed: number;\r\n operationBreakdown: Record<string, number>;\r\n successRate: number;\r\n }>(`/api/admin/credits/users/${userId}/stats`, {\r\n params: {\r\n startDate: options?.startDate,\r\n endDate: options?.endDate,\r\n },\r\n });\r\n }\r\n}\r\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/sdk/types.ts","../../src/sdk/errors.ts","../../src/core/amount.ts","../../src/sdk/client.ts","../../src/sdk/admin-client.ts"],"sourcesContent":["/**\r\n * SDK types for the credits system\r\n *\r\n * These types are used by the client SDKs for REST API consumption.\r\n */\r\n\r\n// Re-export core types for SDK consumers\r\nexport type {\r\n PortableUserCredits as UserCredits,\r\n PortableReservation as CreditReservation,\r\n CreditCheckResult,\r\n SubscriptionTier,\r\n} from \"../core/types.js\";\r\n\r\n/**\r\n * Configuration for CreditsClient\r\n */\r\nexport interface CreditsClientConfig {\r\n /** Base URL of the credits API (e.g., \"https://api.example.com\") */\r\n baseUrl: string;\r\n /** Function to get the auth token for session-based auth */\r\n getAuthToken?: () => Promise<string>;\r\n /** Optional custom fetch function for testing */\r\n fetch?: typeof fetch;\r\n}\r\n\r\n/**\r\n * Configuration for AdminCreditsClient\r\n */\r\nexport interface AdminCreditsClientConfig {\r\n /** Base URL of the credits API */\r\n baseUrl: string;\r\n /** Admin API key */\r\n apiKey: string;\r\n /** Optional custom fetch function for testing */\r\n fetch?: typeof fetch;\r\n}\r\n\r\n/**\r\n * Result of a credit reservation\r\n */\r\nexport interface ReservationResult {\r\n /** The reservation ID */\r\n reservationId: string;\r\n /** Amount of credits reserved */\r\n amount: number;\r\n /** When the reservation expires (ISO 8601) */\r\n expiresAt: string;\r\n}\r\n\r\n/**\r\n * Pagination options for API requests\r\n */\r\nexport interface PaginationOptions {\r\n /** Page number (1-indexed) */\r\n page?: number;\r\n /** Number of items per page */\r\n limit?: number;\r\n}\r\n\r\n/**\r\n * Usage history entry\r\n */\r\nexport interface UsageHistoryEntry {\r\n /** Unique ID of the usage log */\r\n id: string;\r\n /** Type of operation */\r\n operationType: string;\r\n /** AI provider used */\r\n provider: string;\r\n /** Credits consumed */\r\n creditsUsed: number;\r\n /** Whether the operation was successful */\r\n success: boolean;\r\n /** Error message if unsuccessful */\r\n errorMessage?: string;\r\n /** ID of the related resource */\r\n resourceId?: string;\r\n /** Type of the related resource */\r\n resourceType?: string;\r\n /** When the operation occurred (ISO 8601) */\r\n createdAt: string;\r\n}\r\n\r\n/**\r\n * Response from usage history endpoint\r\n */\r\nexport interface UsageHistoryResponse {\r\n /** List of usage entries */\r\n entries: UsageHistoryEntry[];\r\n /** Whether there are more results */\r\n hasMore: boolean;\r\n /** Total count (if available) */\r\n total?: number;\r\n}\r\n\r\n/**\r\n * Generic API response wrapper\r\n */\r\nexport interface ApiResponse<T> {\r\n success: boolean;\r\n data?: T;\r\n error?: {\r\n code: string;\r\n message: string;\r\n details?: unknown;\r\n };\r\n}\r\n\r\n/**\r\n * SDK error codes\r\n */\r\nexport const SDKErrorCode = {\r\n NETWORK_ERROR: \"NETWORK_ERROR\",\r\n AUTHENTICATION_ERROR: \"AUTHENTICATION_ERROR\",\r\n AUTHORIZATION_ERROR: \"AUTHORIZATION_ERROR\",\r\n INSUFFICIENT_CREDITS: \"INSUFFICIENT_CREDITS\",\r\n RESERVATION_NOT_FOUND: \"RESERVATION_NOT_FOUND\",\r\n RESERVATION_EXPIRED: \"RESERVATION_EXPIRED\",\r\n VALIDATION_ERROR: \"VALIDATION_ERROR\",\r\n SERVER_ERROR: \"SERVER_ERROR\",\r\n UNKNOWN_ERROR: \"UNKNOWN_ERROR\",\r\n} as const;\r\n\r\nexport type SDKErrorCodeType = (typeof SDKErrorCode)[keyof typeof SDKErrorCode];\r\n","/**\r\n * SDK error classes\r\n *\r\n * Provides typed error handling for SDK operations.\r\n */\r\n\r\nimport { SDKErrorCode, type SDKErrorCodeType } from \"./types.js\";\r\n\r\n/**\r\n * Base error class for SDK errors\r\n */\r\nexport class CreditsSDKError extends Error {\r\n public readonly code: SDKErrorCodeType;\r\n public readonly details?: Record<string, unknown>;\r\n public readonly statusCode?: number;\r\n\r\n constructor(\r\n message: string,\r\n code: SDKErrorCodeType,\r\n options?: {\r\n details?: Record<string, unknown>;\r\n statusCode?: number;\r\n cause?: Error;\r\n }\r\n ) {\r\n super(message, { cause: options?.cause });\r\n this.name = \"CreditsSDKError\";\r\n this.code = code;\r\n this.details = options?.details;\r\n this.statusCode = options?.statusCode;\r\n }\r\n\r\n /**\r\n * Check if this is a specific error type\r\n */\r\n is(code: SDKErrorCodeType): boolean {\r\n return this.code === code;\r\n }\r\n\r\n /**\r\n * Create a JSON representation for logging\r\n */\r\n toJSON(): Record<string, unknown> {\r\n return {\r\n name: this.name,\r\n message: this.message,\r\n code: this.code,\r\n details: this.details,\r\n statusCode: this.statusCode,\r\n };\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when a network request fails\r\n */\r\nexport class NetworkError extends CreditsSDKError {\r\n constructor(message: string, cause?: Error) {\r\n super(message, SDKErrorCode.NETWORK_ERROR, { cause });\r\n this.name = \"NetworkError\";\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when authentication fails\r\n */\r\nexport class AuthenticationError extends CreditsSDKError {\r\n constructor(message: string = \"Authentication required\") {\r\n super(message, SDKErrorCode.AUTHENTICATION_ERROR, { statusCode: 401 });\r\n this.name = \"AuthenticationError\";\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when authorization fails\r\n */\r\nexport class AuthorizationError extends CreditsSDKError {\r\n constructor(message: string = \"Access denied\") {\r\n super(message, SDKErrorCode.AUTHORIZATION_ERROR, { statusCode: 403 });\r\n this.name = \"AuthorizationError\";\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when credits are insufficient\r\n */\r\nexport class InsufficientCreditsError extends CreditsSDKError {\r\n public readonly available: number;\r\n public readonly required: number;\r\n\r\n constructor(available: number, required: number) {\r\n super(\r\n `Insufficient credits: available ${available}, required ${required}`,\r\n SDKErrorCode.INSUFFICIENT_CREDITS,\r\n {\r\n statusCode: 402,\r\n details: { available, required },\r\n }\r\n );\r\n this.name = \"InsufficientCreditsError\";\r\n this.available = available;\r\n this.required = required;\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when a reservation is not found\r\n */\r\nexport class ReservationNotFoundError extends CreditsSDKError {\r\n public readonly reservationId: string;\r\n\r\n constructor(reservationId: string) {\r\n super(\r\n `Reservation not found: ${reservationId}`,\r\n SDKErrorCode.RESERVATION_NOT_FOUND,\r\n {\r\n statusCode: 404,\r\n details: { reservationId },\r\n }\r\n );\r\n this.name = \"ReservationNotFoundError\";\r\n this.reservationId = reservationId;\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown when a reservation has expired\r\n */\r\nexport class ReservationExpiredError extends CreditsSDKError {\r\n public readonly reservationId: string;\r\n\r\n constructor(reservationId: string) {\r\n super(\r\n `Reservation expired: ${reservationId}`,\r\n SDKErrorCode.RESERVATION_EXPIRED,\r\n {\r\n statusCode: 410,\r\n details: { reservationId },\r\n }\r\n );\r\n this.name = \"ReservationExpiredError\";\r\n this.reservationId = reservationId;\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown for validation failures\r\n */\r\nexport class ValidationError extends CreditsSDKError {\r\n public readonly field?: string;\r\n\r\n constructor(message: string, field?: string) {\r\n super(message, SDKErrorCode.VALIDATION_ERROR, {\r\n statusCode: 400,\r\n details: field ? { field } : undefined,\r\n });\r\n this.name = \"ValidationError\";\r\n this.field = field;\r\n }\r\n}\r\n\r\n/**\r\n * Error thrown for server errors\r\n */\r\nexport class ServerError extends CreditsSDKError {\r\n constructor(message: string = \"Internal server error\") {\r\n super(message, SDKErrorCode.SERVER_ERROR, { statusCode: 500 });\r\n this.name = \"ServerError\";\r\n }\r\n}\r\n\r\n/**\r\n * Parse an API error response into the appropriate SDK error\r\n */\r\nexport function parseApiError(\r\n statusCode: number,\r\n body: { error?: { code?: string; message?: string; details?: unknown } }\r\n): CreditsSDKError {\r\n const errorCode = body.error?.code || \"\";\r\n const errorMessage = body.error?.message || \"An error occurred\";\r\n const details = body.error?.details as Record<string, unknown> | undefined;\r\n\r\n switch (statusCode) {\r\n case 401:\r\n return new AuthenticationError(errorMessage);\r\n case 403:\r\n return new AuthorizationError(errorMessage);\r\n case 402:\r\n // Parse insufficient credits details\r\n if (details?.available !== undefined && details?.required !== undefined) {\r\n return new InsufficientCreditsError(\r\n details.available as number,\r\n details.required as number\r\n );\r\n }\r\n return new CreditsSDKError(errorMessage, SDKErrorCode.INSUFFICIENT_CREDITS, {\r\n statusCode,\r\n details,\r\n });\r\n case 404:\r\n if (errorCode === \"RESERVATION_NOT_FOUND\" && details?.reservationId) {\r\n return new ReservationNotFoundError(details.reservationId as string);\r\n }\r\n return new CreditsSDKError(errorMessage, SDKErrorCode.RESERVATION_NOT_FOUND, {\r\n statusCode,\r\n details,\r\n });\r\n case 410:\r\n if (errorCode === \"RESERVATION_EXPIRED\" && details?.reservationId) {\r\n return new ReservationExpiredError(details.reservationId as string);\r\n }\r\n return new CreditsSDKError(errorMessage, SDKErrorCode.RESERVATION_EXPIRED, {\r\n statusCode,\r\n details,\r\n });\r\n case 400:\r\n case 422:\r\n return new ValidationError(errorMessage, details?.field as string | undefined);\r\n case 500:\r\n default:\r\n if (statusCode >= 500) {\r\n return new ServerError(errorMessage);\r\n }\r\n return new CreditsSDKError(errorMessage, SDKErrorCode.UNKNOWN_ERROR, {\r\n statusCode,\r\n details,\r\n });\r\n }\r\n}\r\n","/**\r\n * Validation and exact comparison for credit amounts.\r\n *\r\n * Every amount ends up in a `numeric(12, 2)` column, so the ledger can only\r\n * represent values on the cent grid, with at most ten integer digits. A value\r\n * outside that grid is not \"close enough\" — PostgreSQL silently rounds it on\r\n * write, and the row that comes back no longer equals what the caller asked\r\n * for. Rejecting it before any row is touched is the only way the ledger and\r\n * the caller can agree on what happened.\r\n *\r\n * These live in core rather than in an adapter so every repository — shipped\r\n * or third-party — validates identically.\r\n */\r\n\r\nimport { CreditError, CreditErrorCode } from \"./errors.js\";\r\nimport type { ExpiredBackingFrontier, PortableReservation } from \"./types.js\";\r\n\r\n/** `numeric(12, 2)`: 12 significant digits, 2 of them after the point. */\r\nexport const CREDIT_AMOUNT_SCALE = 2;\r\n/** Largest representable amount: ten integer digits plus two decimals. */\r\nexport const CREDIT_AMOUNT_MAX = 9_999_999_999.99;\r\nconst MAX_CENTS = 999_999_999_999;\r\n\r\n/**\r\n * Convert an amount to whole cents, or `null` if it is not exactly on the grid.\r\n *\r\n * `Math.round(value * 100) === value * 100` is the tempting check and it is\r\n * wrong: `1.005 * 100` is `100.49999999999999` in binary float, so a genuinely\r\n * invalid value can pass and a valid one can fail. Rounding first and then\r\n * confirming the value round-trips (`value === cents / 100`) is exact for\r\n * everything in range, because a cent count below 2^53 divided by 100 is the\r\n * nearest double to that decimal, which is what the literal parsed to.\r\n */\r\nexport function toCents(value: number): number | null {\r\n if (typeof value !== \"number\" || !Number.isFinite(value)) return null;\r\n const cents = Math.round(value * 100);\r\n if (!Number.isSafeInteger(cents)) return null;\r\n return value === cents / 100 ? cents : null;\r\n}\r\n\r\n/**\r\n * Add amounts on the cent grid, exactly.\r\n *\r\n * Deriving a total with `+` is not safe in a ledger that validates against the\r\n * grid: `0.10 + 0.20` is `0.30000000000000004` and `0.30 - 0.10` is\r\n * `0.19999999999999998`, neither of which is a value `numeric(12, 2)` can hold.\r\n * Two perfectly legal columns would therefore produce a total the validator\r\n * rejects with `INVALID_AMOUNT`, blaming the caller for a rounding artefact.\r\n * Summing whole cents is exact for every value in range, and `cents / 100` is\r\n * the nearest double to that decimal — the same one the literal would parse to.\r\n *\r\n * Subtract by negating: `sumAmounts(total, -amount)`. Negation is exact.\r\n *\r\n * This is arithmetic, not validation. If any input is itself off the grid the\r\n * float sum is returned unchanged — deliberately, so a corrupt value is never\r\n * rounded into legitimacy here — but that is not a guarantee the result will be\r\n * rejected downstream: two off-grid inputs can cancel, and `sumAmounts(0.005,\r\n * -0.005)` is a perfectly representable `0`. Inputs read from storage must be\r\n * validated as inputs, with `assertValidStoredAmount` or\r\n * `assertValidStoredAmountRaw`, before they are summed. Checking only the\r\n * derived result is not enough and never was.\r\n */\r\nexport function sumAmounts(...values: number[]): number {\r\n let cents = 0;\r\n for (const value of values) {\r\n const valueCents = toCents(value);\r\n if (valueCents === null) return floatSum(values);\r\n cents += valueCents;\r\n }\r\n return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);\r\n}\r\n\r\nfunction floatSum(values: number[]): number {\r\n let total = 0;\r\n for (const value of values) total += value;\r\n return total;\r\n}\r\n\r\n/**\r\n * Multiply a price by a whole quantity, in cents.\r\n *\r\n * `1.1 * 3` is `3.3000000000000003`, which the amount validators refuse: the price\r\n * was legal, and the float product blames the caller for a rounding artefact. A\r\n * fractional quantity has no exact result on the grid and is refused rather than\r\n * rounded, so the rounding policy stays with whoever prices fractions.\r\n */\r\nexport function multiplyAmount(unit: number, quantity: number): number {\r\n const cents = toCents(unit);\r\n const product = cents === null ? Number.NaN : cents * quantity;\r\n if (\r\n cents === null ||\r\n cents < 0 ||\r\n !Number.isSafeInteger(quantity) ||\r\n quantity < 0 ||\r\n !Number.isSafeInteger(product) ||\r\n product > MAX_CENTS\r\n ) {\r\n throw new CreditError(\r\n `Cannot price ${quantity} units at ${unit}: the price must be a non-negative ` +\r\n `amount exact to ${CREDIT_AMOUNT_SCALE} decimals, the quantity a whole number, ` +\r\n `and the result at most ${CREDIT_AMOUNT_MAX}`,\r\n CreditErrorCode.INVALID_AMOUNT,\r\n { unit, quantity, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE }\r\n );\r\n }\r\n return product / 100;\r\n}\r\n\r\n/** Below this, a difference from the grid is noise at any size. */\r\nconst FLOAT_NOISE_FLOOR = 1e-6;\r\n/** 64 to 128 units in the last place: what a run of float additions leaves behind. */\r\nconst FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;\r\n\r\n/**\r\n * How far from the grid a value can be and still be noise. One unit in the last place is\r\n * 2e-6 at 9e9, so a fixed threshold that suits small amounts calls a sum of two legal\r\n * amounts off the grid at the top of the range. At `CREDIT_AMOUNT_MAX` this is about\r\n * 1.4e-4, 35 times less than the half cent that separates a value from the next one.\r\n */\r\nfunction floatNoise(value: number): number {\r\n return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);\r\n}\r\n\r\n/**\r\n * The cent-grid value a float landed next to, or the value unchanged.\r\n *\r\n * Documents written by older versions can hold the result of IEEE 754 additions\r\n * (`FieldValue.increment` stored `0.30000000000000004`), and an application can pass\r\n * a raw float difference to a ledger record. That is noise, not an amount. A value\r\n * genuinely off the grid, such as `0.005`, is not noise and comes back as it went in.\r\n */\r\nexport function canonicalAmount(value: number): number {\r\n if (typeof value !== \"number\" || !Number.isFinite(value)) return value;\r\n const cents = Math.round(value * 100);\r\n if (!Number.isSafeInteger(cents)) return value;\r\n const onGrid = cents / 100 + 0;\r\n return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;\r\n}\r\n\r\n/**\r\n * Read a stored amount the account owns as a cent-grid value, rounding down.\r\n *\r\n * A stored `24.999` makes every later sum off-grid, the validators then refuse the\r\n * commit or release that needs it, and the holds on that account lock. Rounding down\r\n * unlocks them. A value within {@link canonicalAmount}'s noise of the next cent reads as\r\n * that cent, because a float cannot tell the two apart, so this can add at most that noise:\r\n * 1e-6 up to 7e7, and about 1.4e-4 at the maximum. A non-finite value is left for the\r\n * validators to refuse.\r\n */\r\nexport function storedAmountFloor(value: number): number {\r\n const canonical = canonicalAmount(value);\r\n if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;\r\n return Math.floor(canonical * 100) / 100;\r\n}\r\n\r\n/**\r\n * Read a stored amount the account owes as a cent-grid value, rounding up, so what\r\n * stands against the balance is never understated. See {@link storedAmountFloor}.\r\n */\r\nexport function storedAmountCeil(value: number): number {\r\n const canonical = canonicalAmount(value);\r\n if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;\r\n return Math.ceil(canonical * 100) / 100;\r\n}\r\n\r\n/** Amounts that may be *moved*: strictly positive and on the cent grid. */\r\nexport function isValidCreditAmount(value: unknown): value is number {\r\n if (typeof value !== \"number\") return false;\r\n const cents = toCents(value);\r\n return cents !== null && cents > 0 && cents <= MAX_CENTS;\r\n}\r\n\r\n/**\r\n * Reject an unusable amount before any row is written.\r\n *\r\n * @param context - merged into the error details, e.g. `{ userId }`.\r\n */\r\nexport function assertValidCreditAmount(\r\n value: unknown,\r\n context?: Record<string, unknown>\r\n): asserts value is number {\r\n if (isValidCreditAmount(value)) return;\r\n throw new CreditError(\r\n creditAmountReason(value),\r\n CreditErrorCode.INVALID_AMOUNT,\r\n { amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }\r\n );\r\n}\r\n\r\n/** Say precisely which rule the value broke, so the caller can fix it. */\r\nfunction creditAmountReason(value: unknown): string {\r\n if (typeof value !== \"number\") {\r\n return `Credit amount must be a number (got ${typeof value})`;\r\n }\r\n if (!Number.isFinite(value)) {\r\n return `Credit amount must be finite (got ${String(value)})`;\r\n }\r\n if (value <= 0) {\r\n return `Credit amount must be greater than zero (got ${value})`;\r\n }\r\n if (value > CREDIT_AMOUNT_MAX) {\r\n return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;\r\n }\r\n return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;\r\n}\r\n\r\n/**\r\n * Amounts the ledger can *record*: on the cent grid and in range, but free to\r\n * be zero or negative.\r\n *\r\n * Not every numeric column holds something spendable. A release journal entry\r\n * records `amount: 0` because no credits changed hands, and `balanceAfter` goes\r\n * negative for an account that was corrected or allowed to overdraw. Forcing\r\n * those through {@link isValidCreditAmount} would reject legitimate ledger\r\n * rows; skipping validation entirely would let `Infinity` or a third decimal\r\n * reach the column and be silently rounded or rejected by the driver.\r\n */\r\nexport function isRepresentableAmount(value: unknown): value is number {\r\n if (typeof value !== \"number\") return false;\r\n const cents = toCents(value);\r\n return cents !== null && Math.abs(cents) <= MAX_CENTS;\r\n}\r\n\r\n/**\r\n * Reject a ledger value the `numeric(12, 2)` column cannot hold exactly.\r\n *\r\n * @param field - the column being written, so the error names the culprit.\r\n */\r\nexport function assertRepresentableAmount(\r\n value: unknown,\r\n field: string,\r\n context?: Record<string, unknown>\r\n): asserts value is number {\r\n if (isRepresentableAmount(value)) return;\r\n throw new CreditError(\r\n `${field} must be a finite number exact to ${CREDIT_AMOUNT_SCALE} decimal places ` +\r\n `within ±${CREDIT_AMOUNT_MAX} (got ${String(value)})`,\r\n CreditErrorCode.INVALID_AMOUNT,\r\n { field, value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }\r\n );\r\n}\r\n\r\n/**\r\n * Check a whole set of optional ledger fields at once.\r\n *\r\n * The balance writers each take a handful of independently optional numbers,\r\n * and validating them one `if` at a time is where one quietly gets missed.\r\n * Undefined entries are skipped — they mean \"leave this column alone\".\r\n */\r\nexport function assertRepresentableFields(\r\n fields: Record<string, unknown>,\r\n context?: Record<string, unknown>\r\n): void {\r\n for (const [field, value] of Object.entries(fields)) {\r\n if (value === undefined) continue;\r\n assertRepresentableAmount(value, field, context);\r\n }\r\n}\r\n\r\n/**\r\n * The lowest `balance` that keeps every outstanding hold committable.\r\n *\r\n * Commit's funding guard is `balance + bonusCredits >= amount`, and every live\r\n * hold is counted in `reserved`, so any write that lowers `balance` must keep\r\n * `balance + bonusCredits >= reserved` — otherwise a hold that was fully backed\r\n * when it was placed throws `INSUFFICIENT_CREDITS` at commit time and is\r\n * stranded. Monthly resets, tier downgrades and explicit tier writes all lower\r\n * `balance` to a configured target; this is the floor they may not cut through.\r\n *\r\n * The floor is `reserved - bonusCredits`, never below zero: bonus credits also\r\n * fund commits, so they count toward the backing.\r\n */\r\nexport function backedBalanceFloor(reserved: number, bonusCredits: number): number {\r\n const floor = Math.max(0, sumAmounts(reserved, -bonusCredits));\r\n // A floor beyond the representable range means the row itself is corrupt —\r\n // e.g. a large negative `bonusCredits` pushing `reserved - bonusCredits`\r\n // past the `numeric(12,2)` cap. Refuse loudly: writing it would succeed in\r\n // memory and be rejected by the SQL column, splitting the adapters.\r\n assertRepresentableAmount(floor, \"backedBalanceFloor\", { reserved, bonusCredits });\r\n return floor;\r\n}\r\n\r\n/**\r\n * The balance left by a cap: lowered to `cap` at most, never raised, and never\r\n * cut through the credits backing outstanding holds (see\r\n * {@link backedBalanceFloor}). A balance already under `cap` is left alone,\r\n * which is the point of computing this from the stored row inside a transaction\r\n * rather than writing a value the caller read earlier.\r\n */\r\nexport function cappedBalance(\r\n balance: number,\r\n cap: number,\r\n reserved: number,\r\n bonusCredits: number\r\n): number {\r\n return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));\r\n}\r\n\r\n/** A balance a lowering write lands on, and how much of it only backs holds. */\r\nexport interface PlannedBalance {\r\n balance: number;\r\n expiredHoldBacking: number;\r\n}\r\n\r\n/**\r\n * The expired credits actually held in a balance: `0 <= held <= balance`, whatever\r\n * is stored. Every read of `expiredHoldBacking` goes through this. Trusting the stored\r\n * value made a negative one mint credits: `balance - (-10)` read as spendable, and a\r\n * close that \"used\" -10 of backing added 10 to the balance.\r\n */\r\nexport function heldExpiredBacking(balance: number, stored?: number): number {\r\n if (!(stored !== undefined && stored > 0)) return 0;\r\n return Math.max(0, Math.min(stored, balance));\r\n}\r\n\r\n/** The part of a balance not kept only for holds. A missing field reads as 0. */\r\nexport function spendableBalance(balance: number, expiredHoldBacking?: number): number {\r\n return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));\r\n}\r\n\r\n/**\r\n * An absolute balance write: `target`, floored at what backs outstanding holds.\r\n * Whatever the floor kept above `target` is not the account's to spend.\r\n */\r\nexport function planBalanceTarget(\r\n target: number,\r\n reserved: number,\r\n bonusCredits: number\r\n): PlannedBalance {\r\n // A negative target would be floored at 0 and leave `-target` marked on a balance\r\n // of 0, and the rest of the ledger cannot honour an unrepresentable one.\r\n assertValidBalanceTarget(target, \"balance target\", { reserved, bonusCredits });\r\n const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));\r\n return { balance, expiredHoldBacking: sumAmounts(balance, -target) };\r\n}\r\n\r\n/**\r\n * A cap on the spendable part of a balance. Like {@link cappedBalance} it never\r\n * raises the balance and never cuts through what backs holds, and it records the\r\n * part kept above the cap so a later release cannot hand it back. Credits already\r\n * marked stay marked, so applying a cap twice changes nothing.\r\n */\r\nexport function planBalanceCap(\r\n balance: number,\r\n cap: number,\r\n reserved: number,\r\n bonusCredits: number,\r\n expiredHoldBacking?: number\r\n): PlannedBalance {\r\n const spendable = spendableBalance(balance, expiredHoldBacking);\r\n const target = Math.min(spendable, cap);\r\n // Marked credits belong to the holds they back. A cap adds to them when the floor\r\n // needs it and never takes them away, even if later bonus credits lowered the floor.\r\n const kept = Math.max(\r\n sumAmounts(balance, -spendable),\r\n sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)\r\n );\r\n const next = Math.min(balance, sumAmounts(target, kept));\r\n return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };\r\n}\r\n\r\n/**\r\n * What a closing hold takes out of the credits kept only for holds: its own share\r\n * and nothing of another hold's. `holdBacking` comes from {@link holdExpiredBacking}.\r\n */\r\nexport function settleExpiredHoldBacking(\r\n balance: number,\r\n expiredHoldBacking: number,\r\n holdBacking: number\r\n): { used: number; remaining: number } {\r\n const held = heldExpiredBacking(balance, expiredHoldBacking);\r\n const used = Math.max(0, Math.min(held, holdBacking));\r\n return { used, remaining: sumAmounts(held, -used) };\r\n}\r\n\r\n/** The balances a commit lands on, and whether the account could pay it. */\r\nexport interface PlannedHoldCommit {\r\n covered: boolean;\r\n balance: number;\r\n bonusCredits: number;\r\n expiredHoldBacking: number;\r\n}\r\n\r\n/**\r\n * Pay a hold: first its own share of the marked credits, then spendable balance,\r\n * then bonus. Marked credits backing other holds are never touched, so what this\r\n * commit costs does not depend on which holds closed before it.\r\n */\r\nexport function planHoldCommit(\r\n balance: number,\r\n bonusCredits: number,\r\n expiredHoldBacking: number,\r\n holdBacking: number,\r\n amount: number\r\n): PlannedHoldCommit {\r\n const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);\r\n const rest = sumAmounts(amount, -backing.used);\r\n const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);\r\n const fromBonus = sumAmounts(rest, -fromSpendable);\r\n return {\r\n covered: fromBonus <= bonusCredits,\r\n balance: sumAmounts(balance, -backing.used, -fromSpendable),\r\n bonusCredits: sumAmounts(bonusCredits, -fromBonus),\r\n expiredHoldBacking: backing.remaining,\r\n };\r\n}\r\n\r\n/** The fields of an open hold that decide its share of the marked credits. */\r\nexport type ExpiredBackingHold = Pick<\r\n PortableReservation,\r\n \"id\" | \"amount\" | \"holdPlacedAt\" | \"expiredBacking\"\r\n>;\r\n\r\n/** A hold whose stored share has to change for a new total. */\r\nexport interface ExpiredBackingRewrite {\r\n id: string;\r\n expiredBacking: number;\r\n}\r\n\r\n/** The marked credits on an account and where they end among its open holds. */\r\nexport interface ExpiredBackingAllocation {\r\n total: number;\r\n frontier: ExpiredBackingFrontier | null;\r\n /** Stored shares to write with this total. Absent when none changes. */\r\n rewrites?: ExpiredBackingRewrite[];\r\n}\r\n\r\n/**\r\n * A frontier before every hold: no hold shares through it. It records that the account's\r\n * marks are placed, so the next close does not walk the holds again.\r\n */\r\nexport const NO_EXPIRED_BACKING_PREFIX: ExpiredBackingFrontier = {\r\n holdPlacedAt: \"\",\r\n id: \"\",\r\n share: 0,\r\n};\r\n\r\nfunction compareHolds(\r\n a: { holdPlacedAt: string; id: string },\r\n b: { holdPlacedAt: string; id: string }\r\n): number {\r\n if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;\r\n if (a.id === b.id) return 0;\r\n return a.id < b.id ? -1 : 1;\r\n}\r\n\r\n/**\r\n * The share a 2.2.0 hold document recorded for itself: the order of record for that hold.\r\n * Clocks are not, since the server that placed a hold may lag the one before it. Rounded\r\n * up, like every amount that stands against the balance, and never above the hold. Zero\r\n * means no record, which leaves the hold to the frontier.\r\n */\r\nexport function recordedExpiredBacking(hold: ExpiredBackingHold): number {\r\n const stored = hold.expiredBacking;\r\n if (typeof stored !== \"number\" || !Number.isFinite(stored) || stored <= 0) return 0;\r\n if (!isValidCreditAmount(hold.amount)) return 0;\r\n return Math.min(storedAmountCeil(stored), hold.amount);\r\n}\r\n\r\n/**\r\n * Place `total` marked credits on the open holds, so that what each hold takes does not\r\n * depend on the order they close in.\r\n *\r\n * Shares a 2.2.0 hold document stored stand as recorded: they need not be a prefix of\r\n * anything, and they sit on documents that already exist. The rest goes to the other\r\n * holds oldest first, each up to its amount. That part is a prefix, so one\r\n * {@link ExpiredBackingFrontier} on the account describes it and no hold document changes\r\n * however many there are. A new hold carries no share, so no record is ever added.\r\n *\r\n * A total below the records trims them newest first, and only when every other hold has\r\n * nothing, and a total the other holds cannot take fills what the records left free. Those\r\n * are the only hold writes: `rewrites`, at most one per record, each record being an open\r\n * hold from before the upgrade.\r\n *\r\n * Marks no hold can carry stay marked. They were out of reach before this ran, and\r\n * dropping them would make them spendable; the next absolute balance write replaces\r\n * them. A row without `holdPlacedAt` is a record, not a hold, and a row with an unusable\r\n * amount cannot close, so neither takes a share.\r\n */\r\nexport function allocateExpiredBacking(\r\n holds: readonly ExpiredBackingHold[],\r\n total: number\r\n): ExpiredBackingAllocation {\r\n const placed = holds\r\n .filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount))\r\n .map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt as string }))\r\n .sort(compareHolds);\r\n const wanted = Math.max(0, total);\r\n if (wanted === 0) return { total: 0, frontier: null };\r\n\r\n const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);\r\n const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));\r\n let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));\r\n\r\n for (const hold of [...recorded].reverse()) {\r\n if (left >= 0) break;\r\n const share = shares.get(hold.id) as number;\r\n const cut = Math.min(share, -left);\r\n shares.set(hold.id, sumAmounts(share, -cut));\r\n left = sumAmounts(left, cut);\r\n }\r\n\r\n let frontier: ExpiredBackingFrontier = NO_EXPIRED_BACKING_PREFIX;\r\n for (const hold of placed) {\r\n if (left <= 0) break;\r\n if (shares.has(hold.id)) continue;\r\n const share = Math.min(hold.amount, left);\r\n frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };\r\n left = sumAmounts(left, -share);\r\n }\r\n\r\n for (const hold of recorded) {\r\n if (left <= 0) break;\r\n const share = shares.get(hold.id) as number;\r\n const added = Math.min(sumAmounts(hold.amount, -share), left);\r\n shares.set(hold.id, sumAmounts(share, added));\r\n left = sumAmounts(left, -added);\r\n }\r\n\r\n const rewrites = recorded\r\n .filter((hold) => shares.get(hold.id) !== hold.expiredBacking)\r\n .map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) as number }));\r\n return { total: wanted, frontier, ...(rewrites.length > 0 ? { rewrites } : {}) };\r\n}\r\n\r\n/**\r\n * This hold's share of the marked credits: its own record if it has one, otherwise where\r\n * it falls against the account's frontier. Pass the `rewrites` of an allocation made in\r\n * this same step, since the document does not hold them yet.\r\n */\r\nexport function holdExpiredBacking(\r\n hold: ExpiredBackingHold,\r\n frontier?: ExpiredBackingFrontier | null,\r\n rewrites?: readonly ExpiredBackingRewrite[]\r\n): number {\r\n const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);\r\n const recorded = recordedExpiredBacking(\r\n rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold\r\n );\r\n if (recorded > 0) return recorded;\r\n if (!frontier || !hold.holdPlacedAt) return 0;\r\n const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);\r\n if (order > 0) return 0;\r\n return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);\r\n}\r\n\r\n/**\r\n * When a new hold counts as placed: `now`, or just after the frontier if this server's\r\n * clock is behind the server that placed it. A hold that sorted before the frontier\r\n * would read as fully marked, taking expired credits that belong to older holds.\r\n */\r\nexport function holdPlacedAtAfter(\r\n now: string,\r\n frontier?: ExpiredBackingFrontier | null\r\n): string {\r\n if (!frontier?.holdPlacedAt) return now;\r\n const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();\r\n return now > earliest ? now : earliest;\r\n}\r\n\r\n/** The account fields that hold the marked credits. */\r\nexport interface ExpiredBackingFields {\r\n expiredHoldBacking?: number;\r\n expiredBackingFrontier?: ExpiredBackingFrontier | null;\r\n}\r\n\r\n/**\r\n * The fields to write for a new marked total: nothing when it was and stays 0, so an\r\n * account that never had any keeps a document without the fields. A stored value that\r\n * is not the new one is overwritten, which is what repairs a bad one. A `frontier` left\r\n * undefined keeps the stored one.\r\n */\r\nexport function expiredBackingPatch(\r\n next: { total: number; frontier?: ExpiredBackingFrontier | null },\r\n stored: ExpiredBackingFields\r\n): ExpiredBackingFields {\r\n const total = Math.max(0, next.total);\r\n const patch: ExpiredBackingFields = {};\r\n const storedTotal = stored.expiredHoldBacking;\r\n if (total > 0 || (storedTotal !== undefined && storedTotal !== 0)) {\r\n patch.expiredHoldBacking = total;\r\n }\r\n if (total <= 0) {\r\n if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;\r\n } else if (next.frontier) {\r\n patch.expiredBackingFrontier = next.frontier;\r\n }\r\n return patch;\r\n}\r\n\r\n/**\r\n * Validate a balance target supplied by the application: a cap or the result of\r\n * a monthly-reset decision. It must be storable and cannot be negative.\r\n */\r\nexport function assertValidBalanceTarget(\r\n value: unknown,\r\n field: string,\r\n context?: Record<string, unknown>\r\n): asserts value is number {\r\n assertRepresentableAmount(value, field, context);\r\n if (value < 0) {\r\n throw new CreditError(\r\n `${field} cannot be negative (got ${value})`,\r\n CreditErrorCode.INVALID_AMOUNT,\r\n { field, value, ...context }\r\n );\r\n }\r\n}\r\n\r\n/**\r\n * Validate how a tier write changes the balance. `balance` sets a value the\r\n * caller computed and `balanceCap` lowers the stored value, so naming both has\r\n * no single meaning and is refused.\r\n */\r\nexport function assertValidTierBalanceInput(\r\n input: { balance?: number; balanceCap?: number },\r\n context?: Record<string, unknown>\r\n): void {\r\n if (input.balanceCap === undefined) {\r\n if (input.balance !== undefined) assertValidBalanceTarget(input.balance, \"balance\", context);\r\n return;\r\n }\r\n if (input.balance !== undefined) {\r\n throw new CreditError(\r\n \"A tier update cannot set both balance and balanceCap\",\r\n CreditErrorCode.INVALID_AMOUNT,\r\n { balance: input.balance, balanceCap: input.balanceCap, ...context }\r\n );\r\n }\r\n assertValidBalanceTarget(input.balanceCap, \"balanceCap\", context);\r\n}\r\n\r\n/**\r\n * Validate a limit derived from tier configuration.\r\n *\r\n * `Infinity` is this codebase's sentinel for an unlimited tier, so it is\r\n * accepted here; {@link storedMonthlyLimit} decides what actually reaches the\r\n * column. Every other value ends up in `numeric(12, 2)` and has to fit, so a\r\n * tier configured with `monthlyCredits: 1.005` is caught here rather than\r\n * silently rounded to `1.01` by PostgreSQL.\r\n */\r\nexport function assertRepresentableTierAmount(\r\n value: unknown,\r\n field: string,\r\n context?: Record<string, unknown>\r\n): void {\r\n if (value === Number.POSITIVE_INFINITY) return;\r\n assertRepresentableAmount(value, field, context);\r\n}\r\n\r\n/**\r\n * Validate a stored amount in the representation storage actually returned.\r\n *\r\n * Checking the mapped float is not enough, because the mapping is lossy in both\r\n * directions. A `numeric` column with no scale constraint can hold\r\n * `9999999999.9900001`; `Number()` rounds that to `9999999999.99`, which is a\r\n * perfectly valid amount, so a validator looking at the mapped value sees\r\n * nothing wrong and the transition proceeds — while PostgreSQL goes on doing\r\n * exact arithmetic with the digits JavaScript threw away. `NaN` and values past\r\n * double range are worse: the mapper turns them into `0`, which misreports what\r\n * the row holds.\r\n *\r\n * So this runs on the driver's own output — a string, for `numeric` — and uses\r\n * exact integer arithmetic. It returns the amount as a float only after\r\n * establishing that the float is a faithful representation of it.\r\n *\r\n * @returns the stored amount, exactly representable as a JS number.\r\n */\r\n/**\r\n * How an unlimited tier's monthly limit is stored.\r\n *\r\n * `getConfigMonthlyLimit` returns `Infinity` for an unlimited tier, and no\r\n * numeric column can hold that. Both adapters therefore store the ceiling of\r\n * the representable range instead: any real usage compares below it, so\r\n * \"unlimited\" keeps behaving like unlimited, and a reader that knows nothing\r\n * about the sentinel sees a very large allowance rather than a wrong one.\r\n *\r\n * This exists so the two adapters cannot drift. The SQL adapter previously\r\n * stored `0` here, which reads as *no* allowance — the exact opposite — while\r\n * the in-memory adapter kept `Infinity`.\r\n */\r\nexport function storedMonthlyLimit(limit: number): number {\r\n return Number.isFinite(limit) ? limit : CREDIT_AMOUNT_MAX;\r\n}\r\n\r\nexport function assertValidStoredAmountRaw(\r\n raw: unknown,\r\n context: Record<string, unknown>\r\n): number {\r\n const cents = numericToCents(raw);\r\n if (cents !== null && cents > 0n && cents <= BigInt(MAX_CENTS)) {\r\n return Number(cents) / 100;\r\n }\r\n throw new CreditError(\r\n `Stored credit amount ${String(raw)} is not a valid amount, so this ` +\r\n \"transition was refused before any state changed. The row must be \" +\r\n \"repaired before it can be committed, released or expired.\",\r\n CreditErrorCode.INVALID_AMOUNT,\r\n {\r\n amount: typeof raw === \"string\" ? raw : String(raw),\r\n reason: \"corrupt_stored_amount\",\r\n max: CREDIT_AMOUNT_MAX,\r\n scale: CREDIT_AMOUNT_SCALE,\r\n ...context,\r\n }\r\n );\r\n}\r\n\r\n/**\r\n * Reject a *persisted* amount that the ledger cannot honour.\r\n *\r\n * A reservation row is written once and read back on every transition, and\r\n * nothing stops a direct SQL write, a botched migration, or an older version of\r\n * this library from leaving an amount the invariants cannot reason about. A\r\n * negative one is the dangerous case: `reserved >= amount` is trivially true\r\n * for it, and `balance - (-10)` *adds* credits, so a transition that trusts the\r\n * stored value mints money. Every transition therefore re-validates what it\r\n * locked before it changes anything.\r\n *\r\n * Deliberately fails release and expire as well as commit. Returning a hold of\r\n * an unknown size is not obviously safer than spending one — it hands back\r\n * coverage that may never have been taken — so a corrupt row stops moving\r\n * entirely and waits for an operator, rather than being partially honoured.\r\n *\r\n * This is the float-typed form, used by the in-memory adapter where the stored\r\n * value *is* a JS number and no conversion has happened. A repository whose\r\n * storage returns some other representation must use\r\n * {@link assertValidStoredAmountRaw} instead, or the conversion will hide the\r\n * corruption from the check.\r\n */\r\nexport function assertValidStoredAmount(\r\n value: unknown,\r\n context: Record<string, unknown>\r\n): asserts value is number {\r\n if (isValidCreditAmount(value)) return;\r\n throw new CreditError(\r\n `Stored credit amount ${String(value)} is not a valid amount, so this ` +\r\n \"transition was refused before any state changed. The row must be \" +\r\n \"repaired before it can be committed, released or expired.\",\r\n CreditErrorCode.INVALID_AMOUNT,\r\n { amount: value, reason: \"corrupt_stored_amount\", max: CREDIT_AMOUNT_MAX, ...context }\r\n );\r\n}\r\n\r\n/**\r\n * Parse a value read back from a `numeric` column into exact cents.\r\n *\r\n * Drivers return `numeric` as a string precisely so no precision is lost in\r\n * transit; comparing the parsed floats would throw that away again. Returns\r\n * `null` for anything unparseable so callers can treat it as \"not equal\"\r\n * rather than crashing on malformed ledger data.\r\n */\r\nexport function numericToCents(value: unknown): bigint | null {\r\n if (typeof value === \"number\") {\r\n const cents = toCents(value);\r\n return cents === null ? null : BigInt(cents);\r\n }\r\n if (typeof value !== \"string\") return null;\r\n\r\n const match = /^([+-]?)(\\d+)(?:\\.(\\d*))?$/.exec(value.trim());\r\n if (!match) return null;\r\n\r\n const [, sign, whole, fraction = \"\"] = match;\r\n // More than two decimals is only acceptable when the extra digits are zeros;\r\n // anything else never came from a numeric(12,2) column.\r\n if (/[1-9]/.test(fraction.slice(CREDIT_AMOUNT_SCALE))) return null;\r\n\r\n const cents =\r\n BigInt(whole) * 100n + BigInt(fraction.slice(0, CREDIT_AMOUNT_SCALE).padEnd(CREDIT_AMOUNT_SCALE, \"0\"));\r\n return sign === \"-\" ? -cents : cents;\r\n}\r\n\r\n/**\r\n * Exact equality for two amounts that may be numbers or `numeric` strings.\r\n *\r\n * Unparseable input is *not* equal to anything, including itself — a\r\n * comparison against corrupt data must never come back \"matches\".\r\n */\r\nexport function sameAmount(a: unknown, b: unknown): boolean {\r\n const left = numericToCents(a);\r\n const right = numericToCents(b);\r\n return left !== null && right !== null && left === right;\r\n}\r\n","/**\r\n * CreditsClient - REST API client for the credits system\r\n *\r\n * Provides a type-safe interface for consuming credits API endpoints\r\n * from external services or other applications.\r\n *\r\n * @example\r\n * ```typescript\r\n * const client = new CreditsClient({\r\n * baseUrl: process.env.CREDITS_API_URL,\r\n * getAuthToken: async () => session.accessToken,\r\n * });\r\n *\r\n * // Check if user has sufficient credits\r\n * const check = await client.checkAvailability(\"story_generation\");\r\n * if (!check.hasCredits) {\r\n * console.log(\"Insufficient credits\");\r\n * return;\r\n * }\r\n *\r\n * // Two-phase commit pattern\r\n * const reservation = await client.reserve(\"story_generation\");\r\n * try {\r\n * const result = await doExpensiveWork();\r\n * await client.commit(reservation.reservationId);\r\n * return result;\r\n * } catch (error) {\r\n * await client.release(reservation.reservationId);\r\n * throw error;\r\n * }\r\n * ```\r\n */\r\n\r\nimport type {\r\n CreditsClientConfig,\r\n UserCredits,\r\n CreditCheckResult,\r\n ReservationResult,\r\n UsageHistoryResponse,\r\n PaginationOptions,\r\n} from \"./types.js\";\r\nimport { parseApiError, NetworkError } from \"./errors.js\";\r\nimport { sumAmounts } from \"../core/amount.js\";\r\n\r\nexport class CreditsClient {\r\n private readonly baseUrl: string;\r\n private readonly getAuthToken?: () => Promise<string>;\r\n private readonly fetchFn: typeof fetch;\r\n\r\n constructor(config: CreditsClientConfig) {\r\n // Ensure baseUrl doesn't have trailing slash\r\n this.baseUrl = config.baseUrl.replace(/\\/$/, \"\");\r\n this.getAuthToken = config.getAuthToken;\r\n this.fetchFn = config.fetch ?? fetch;\r\n }\r\n\r\n /**\r\n * Make an authenticated request to the API\r\n */\r\n private async request<T>(\r\n path: string,\r\n options: {\r\n method?: \"GET\" | \"POST\" | \"PUT\" | \"DELETE\";\r\n body?: unknown;\r\n params?: Record<string, string | number | undefined>;\r\n } = {}\r\n ): Promise<T> {\r\n const { method = \"GET\", body, params } = options;\r\n\r\n // Build URL with query params\r\n let url = `${this.baseUrl}${path}`;\r\n if (params) {\r\n const searchParams = new URLSearchParams();\r\n for (const [key, value] of Object.entries(params)) {\r\n if (value !== undefined) {\r\n searchParams.set(key, String(value));\r\n }\r\n }\r\n const queryString = searchParams.toString();\r\n if (queryString) {\r\n url += `?${queryString}`;\r\n }\r\n }\r\n\r\n // Build headers\r\n const headers: Record<string, string> = {\r\n \"Content-Type\": \"application/json\",\r\n };\r\n\r\n // Add auth token if available\r\n if (this.getAuthToken) {\r\n try {\r\n const token = await this.getAuthToken();\r\n if (token) {\r\n headers[\"Authorization\"] = `Bearer ${token}`;\r\n }\r\n } catch {\r\n // Auth token not available, proceed without it\r\n }\r\n }\r\n\r\n // Make the request\r\n let response: Response;\r\n try {\r\n response = await this.fetchFn(url, {\r\n method,\r\n headers,\r\n body: body ? JSON.stringify(body) : undefined,\r\n });\r\n } catch (error) {\r\n throw new NetworkError(\r\n error instanceof Error ? error.message : \"Network request failed\",\r\n error instanceof Error ? error : undefined\r\n );\r\n }\r\n\r\n // Parse response\r\n const responseBody = (await response.json()) as {\r\n data?: T;\r\n error?: { code?: string; message?: string; details?: unknown };\r\n };\r\n\r\n // Handle errors\r\n if (!response.ok) {\r\n throw parseApiError(response.status, responseBody);\r\n }\r\n\r\n // Return data\r\n return responseBody.data as T;\r\n }\r\n\r\n /**\r\n * Get the current user's credit balance\r\n *\r\n * @returns User's credit information\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async getBalance(): Promise<UserCredits> {\r\n return this.request<UserCredits>(\"/api/v1/credits/balance\");\r\n }\r\n\r\n /**\r\n * Check if the user has sufficient credits for an operation\r\n *\r\n * @param operationType - Type of operation to check\r\n * @param customCost - Optional custom cost to check against\r\n * @returns Credit availability status\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async checkAvailability(\r\n operationType: string,\r\n customCost?: number\r\n ): Promise<CreditCheckResult> {\r\n return this.request<CreditCheckResult>(\"/api/v1/credits/availability\", {\r\n params: {\r\n operationType,\r\n customCost: customCost?.toString(),\r\n },\r\n });\r\n }\r\n\r\n /**\r\n * Reserve credits for an operation\r\n *\r\n * This is the first step in the two-phase commit pattern.\r\n * Reserved credits are held for 5 minutes before expiring.\r\n *\r\n * @param operationType - Type of operation\r\n * @param options - Optional reservation options\r\n * @returns Reservation details including ID and expiry\r\n * @throws InsufficientCreditsError if not enough credits\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async reserve(\r\n operationType: string,\r\n options?: {\r\n customCost?: number;\r\n resourceId?: string;\r\n resourceType?: string;\r\n }\r\n ): Promise<ReservationResult> {\r\n return this.request<ReservationResult>(\"/api/v1/credits/reserve\", {\r\n method: \"POST\",\r\n body: {\r\n operationType,\r\n ...options,\r\n },\r\n });\r\n }\r\n\r\n /**\r\n * Commit a reservation (deduct credits)\r\n *\r\n * This is the second step in the two-phase commit pattern.\r\n * Call this after your operation succeeds.\r\n *\r\n * @param reservationId - ID of the reservation to commit\r\n * @throws ReservationNotFoundError if reservation doesn't exist\r\n * @throws ReservationExpiredError if reservation has expired\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async commit(reservationId: string): Promise<void> {\r\n await this.request<{ message: string }>(\"/api/v1/credits/commit\", {\r\n method: \"POST\",\r\n body: { reservationId },\r\n });\r\n }\r\n\r\n /**\r\n * Release a reservation (refund credits)\r\n *\r\n * Call this if your operation fails after reserving credits.\r\n *\r\n * @param reservationId - ID of the reservation to release\r\n * @throws ReservationNotFoundError if reservation doesn't exist\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async release(reservationId: string): Promise<void> {\r\n await this.request<{ message: string }>(\"/api/v1/credits/release\", {\r\n method: \"POST\",\r\n body: { reservationId },\r\n });\r\n }\r\n\r\n /**\r\n * Get usage history for the current user\r\n *\r\n * @param options - Optional pagination options\r\n * @returns Paginated list of usage entries\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async getHistory(options?: PaginationOptions): Promise<UsageHistoryResponse> {\r\n return this.request<UsageHistoryResponse>(\"/api/v1/credits/history\", {\r\n params: {\r\n page: options?.page,\r\n limit: options?.limit,\r\n },\r\n });\r\n }\r\n\r\n /**\r\n * Get the available credits (balance + bonus - reserved)\r\n *\r\n * Convenience method that calculates available credits from balance.\r\n *\r\n * @returns Number of available credits\r\n * @throws AuthenticationError if not authenticated\r\n */\r\n async getAvailableCredits(): Promise<number> {\r\n const balance = await this.getBalance();\r\n return sumAmounts(balance.balance, balance.bonusCredits, -balance.reserved);\r\n }\r\n\r\n /**\r\n * Execute an operation with automatic credit handling\r\n *\r\n * This is a convenience method that handles the full two-phase commit\r\n * pattern automatically.\r\n *\r\n * @param operationType - Type of operation\r\n * @param operation - The operation to execute\r\n * @param options - Optional reservation options\r\n * @returns Result of the operation\r\n * @throws InsufficientCreditsError if not enough credits\r\n * @throws AuthenticationError if not authenticated\r\n *\r\n * @example\r\n * ```typescript\r\n * const result = await client.withCredits(\r\n * \"story_generation\",\r\n * async () => {\r\n * return generateStory(data);\r\n * }\r\n * );\r\n * ```\r\n */\r\n async withCredits<T>(\r\n operationType: string,\r\n operation: () => Promise<T>,\r\n options?: {\r\n customCost?: number;\r\n resourceId?: string;\r\n resourceType?: string;\r\n }\r\n ): Promise<T> {\r\n const reservation = await this.reserve(operationType, options);\r\n\r\n try {\r\n const result = await operation();\r\n await this.commit(reservation.reservationId);\r\n return result;\r\n } catch (error) {\r\n // Always attempt to release on failure\r\n try {\r\n await this.release(reservation.reservationId);\r\n } catch {\r\n // Ignore release errors, original error is more important\r\n }\r\n throw error;\r\n }\r\n }\r\n}\r\n","/**\r\n * AdminCreditsClient - Admin REST API client for the credits system\r\n *\r\n * Provides administrative access to the credits system for managing\r\n * user credits, configurations, and subscriptions.\r\n *\r\n * @example\r\n * ```typescript\r\n * const adminClient = new AdminCreditsClient({\r\n * baseUrl: process.env.CREDITS_API_URL,\r\n * apiKey: process.env.CREDITS_ADMIN_API_KEY,\r\n * });\r\n *\r\n * // Add credits to a user\r\n * await adminClient.addCredits(\"user-123\", 100, \"Support credit grant\");\r\n *\r\n * // Update subscription tier\r\n * await adminClient.updateSubscription(\"user-123\", \"premium\", \"2027-01-01T00:00:00Z\");\r\n *\r\n * // Update operation costs\r\n * await adminClient.updateCosts({\r\n * story_generation: 8,\r\n * image_generation: 12,\r\n * });\r\n * ```\r\n */\r\n\r\nimport type { AdminCreditsClientConfig, UserCredits } from \"./types.js\";\r\nimport { parseApiError, NetworkError } from \"./errors.js\";\r\n\r\n/**\r\n * Credits configuration returned by the API\r\n */\r\nexport interface CreditsConfig {\r\n operationCosts: Record<string, number>;\r\n tierConfigs: Record<\r\n string,\r\n {\r\n monthlyLimit: number;\r\n features: string[];\r\n }\r\n >;\r\n}\r\n\r\n/**\r\n * Response from listing users\r\n */\r\nexport interface ListUsersResponse {\r\n users: Array<{\r\n userId: string;\r\n balance: number;\r\n bonusCredits?: number;\r\n tier: string;\r\n monthlyUsed?: number;\r\n }>;\r\n hasMore: boolean;\r\n total?: number;\r\n}\r\n\r\n/**\r\n * Options for listing users\r\n */\r\nexport interface ListUsersOptions {\r\n page?: number;\r\n limit?: number;\r\n tier?: string;\r\n search?: string;\r\n}\r\n\r\nexport class AdminCreditsClient {\r\n private readonly baseUrl: string;\r\n private readonly apiKey: string;\r\n private readonly fetchFn: typeof fetch;\r\n\r\n constructor(config: AdminCreditsClientConfig) {\r\n // Ensure baseUrl doesn't have trailing slash\r\n this.baseUrl = config.baseUrl.replace(/\\/$/, \"\");\r\n this.apiKey = config.apiKey;\r\n this.fetchFn = config.fetch ?? fetch;\r\n }\r\n\r\n /**\r\n * Make an authenticated admin request to the API\r\n */\r\n private async request<T>(\r\n path: string,\r\n options: {\r\n method?: \"GET\" | \"POST\" | \"PUT\" | \"DELETE\";\r\n body?: unknown;\r\n params?: Record<string, string | number | undefined>;\r\n } = {}\r\n ): Promise<T> {\r\n const { method = \"GET\", body, params } = options;\r\n\r\n // Build URL with query params\r\n let url = `${this.baseUrl}${path}`;\r\n if (params) {\r\n const searchParams = new URLSearchParams();\r\n for (const [key, value] of Object.entries(params)) {\r\n if (value !== undefined) {\r\n searchParams.set(key, String(value));\r\n }\r\n }\r\n const queryString = searchParams.toString();\r\n if (queryString) {\r\n url += `?${queryString}`;\r\n }\r\n }\r\n\r\n // Build headers with admin API key\r\n const headers: Record<string, string> = {\r\n \"Content-Type\": \"application/json\",\r\n \"x-api-key\": this.apiKey,\r\n };\r\n\r\n // Make the request\r\n let response: Response;\r\n try {\r\n response = await this.fetchFn(url, {\r\n method,\r\n headers,\r\n body: body ? JSON.stringify(body) : undefined,\r\n });\r\n } catch (error) {\r\n throw new NetworkError(\r\n error instanceof Error ? error.message : \"Network request failed\",\r\n error instanceof Error ? error : undefined\r\n );\r\n }\r\n\r\n // Parse response\r\n const responseBody = (await response.json()) as {\r\n data?: T;\r\n error?: { code?: string; message?: string; details?: unknown };\r\n };\r\n\r\n // Handle errors\r\n if (!response.ok) {\r\n throw parseApiError(response.status, responseBody);\r\n }\r\n\r\n // Return data\r\n return responseBody.data as T;\r\n }\r\n\r\n /**\r\n * Get a specific user's credits\r\n *\r\n * @param userId - ID of the user\r\n * @returns User's credit information\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async getUserCredits(userId: string): Promise<UserCredits> {\r\n return this.request<UserCredits>(`/api/admin/credits/users/${userId}`);\r\n }\r\n\r\n /**\r\n * Add credits to a user's account\r\n *\r\n * Credits are added to bonusCredits, which don't expire on monthly reset.\r\n *\r\n * @param userId - ID of the user\r\n * @param amount - Amount of credits to add\r\n * @param description - Reason for adding credits\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async addCredits(\r\n userId: string,\r\n amount: number,\r\n description: string\r\n ): Promise<void> {\r\n await this.request<{ message: string; newBalance: number }>(\r\n `/api/admin/credits/users/${userId}/add`,\r\n {\r\n method: \"POST\",\r\n body: { amount, description },\r\n }\r\n );\r\n }\r\n\r\n /**\r\n * Update a user's subscription tier\r\n *\r\n * @param userId - ID of the user\r\n * @param tier - New subscription tier\r\n * @param expiresAt - Optional expiration date (ISO 8601)\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async updateSubscription(\r\n userId: string,\r\n tier: string,\r\n expiresAt?: string\r\n ): Promise<void> {\r\n const body: { tier: string; expiresAt?: string } = { tier };\r\n if (expiresAt) {\r\n body.expiresAt = expiresAt;\r\n }\r\n\r\n await this.request<{ message: string }>(\r\n `/api/admin/credits/users/${userId}/subscription`,\r\n {\r\n method: \"PUT\",\r\n body,\r\n }\r\n );\r\n }\r\n\r\n /**\r\n * Get the current credits configuration\r\n *\r\n * @returns Credits system configuration\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async getConfig(): Promise<CreditsConfig> {\r\n return this.request<CreditsConfig>(\"/api/admin/credits/config\");\r\n }\r\n\r\n /**\r\n * Update operation costs\r\n *\r\n * @param costs - New operation costs (operation type -> cost)\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async updateCosts(costs: Record<string, number>): Promise<void> {\r\n await this.request<{ message: string }>(\"/api/admin/credits/config/costs\", {\r\n method: \"PUT\",\r\n body: { operationCosts: costs },\r\n });\r\n }\r\n\r\n /**\r\n * List all users with their credits\r\n *\r\n * @param options - Pagination and filtering options\r\n * @returns Paginated list of users\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async listUsers(options?: ListUsersOptions): Promise<ListUsersResponse> {\r\n return this.request<ListUsersResponse>(\"/api/admin/credits/users\", {\r\n params: {\r\n page: options?.page,\r\n limit: options?.limit,\r\n tier: options?.tier,\r\n search: options?.search,\r\n },\r\n });\r\n }\r\n\r\n /**\r\n * Deduct credits from a user's account\r\n *\r\n * @param userId - ID of the user\r\n * @param amount - Amount of credits to deduct\r\n * @param description - Reason for deducting credits\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async deductCredits(\r\n userId: string,\r\n amount: number,\r\n description: string\r\n ): Promise<void> {\r\n await this.request<{ message: string; newBalance: number }>(\r\n `/api/admin/credits/users/${userId}/deduct`,\r\n {\r\n method: \"POST\",\r\n body: { amount, description },\r\n }\r\n );\r\n }\r\n\r\n /**\r\n * Get usage statistics for a user\r\n *\r\n * @param userId - ID of the user\r\n * @param options - Optional date range\r\n * @returns Usage statistics\r\n * @throws AuthorizationError if API key is invalid\r\n */\r\n async getUserStats(\r\n userId: string,\r\n options?: { startDate?: string; endDate?: string }\r\n ): Promise<{\r\n totalCreditsUsed: number;\r\n operationBreakdown: Record<string, number>;\r\n successRate: number;\r\n }> {\r\n return this.request<{\r\n totalCreditsUsed: number;\r\n operationBreakdown: Record<string, number>;\r\n successRate: number;\r\n }>(`/api/admin/credits/users/${userId}/stats`, {\r\n params: {\r\n startDate: options?.startDate,\r\n endDate: options?.endDate,\r\n },\r\n });\r\n }\r\n}\r\n"],"mappings":"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","names":[]}
@@ -157,6 +157,7 @@ function floatSum(values) {
157
157
  for (const value of values) total += value;
158
158
  return total;
159
159
  }
160
+ var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
160
161
  function isValidCreditAmount(value) {
161
162
  if (typeof value !== "number") return false;
162
163
  const cents = toCents(value);
@@ -724,6 +725,7 @@ var CreditsService = class {
724
725
  entryType: "debit",
725
726
  amount: 0,
726
727
  // No credits deducted, just tier change
728
+ balanceBefore: sumAmounts(data.balance, data.bonusCredits),
727
729
  // The ledger balance, matching every other journal writer: the V2
728
730
  // transitions and add/deduct all record `balance + bonusCredits`, so
729
731
  // recording `balance` alone made the audit trail disagree with itself
@@ -767,6 +769,7 @@ var CreditsService = class {
767
769
  userId,
768
770
  entryType: balanceChange > 0 ? "credit" : "debit",
769
771
  amount: Math.abs(balanceChange),
772
+ balanceBefore: sumAmounts(data.balance, data.bonusCredits),
770
773
  balanceAfter: sumAmounts(
771
774
  resetResult.credits.balance,
772
775
  resetResult.credits.bonusCredits
@@ -1001,9 +1004,18 @@ var CreditsService = class {
1001
1004
  */
1002
1005
  async deductCredits(userId, amount, options) {
1003
1006
  assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
1007
+ const operationType = options?.operationType;
1008
+ const journal = {
1009
+ referenceId: options?.requestId ?? `deduct-${Date.now()}`,
1010
+ description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
1011
+ metadata: {
1012
+ ...options?.metadata,
1013
+ ...operationType && { operationType }
1014
+ }
1015
+ };
1004
1016
  let deduction;
1005
1017
  try {
1006
- deduction = await this.repository.deductCreditsAtomic(userId, amount);
1018
+ deduction = await this.repository.deductCreditsAtomic(userId, amount, journal);
1007
1019
  } catch (error) {
1008
1020
  if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
1009
1021
  const credits = await this.repository.getUserCredits(userId);
@@ -1019,21 +1031,18 @@ var CreditsService = class {
1019
1031
  shortfall: sumAmounts(amount, -available)
1020
1032
  };
1021
1033
  }
1022
- const operationType = options?.operationType;
1023
- await this.repository.createJournalEntry({
1024
- userId,
1025
- entryType: "debit",
1026
- amount,
1027
- balanceAfter: deduction.newBalance,
1028
- source: "operation_commit",
1029
- referenceId: options?.requestId ?? `deduct-${Date.now()}`,
1030
- referenceType: "adjustment",
1031
- description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
1032
- metadata: {
1033
- ...options?.metadata,
1034
- ...operationType && { operationType }
1035
- }
1036
- });
1034
+ if (deduction.journalEntryId === void 0) {
1035
+ await this.repository.createJournalEntry({
1036
+ userId,
1037
+ entryType: "debit",
1038
+ amount,
1039
+ balanceBefore: deduction.previousBalance,
1040
+ balanceAfter: deduction.newBalance,
1041
+ source: "operation_commit",
1042
+ referenceType: "adjustment",
1043
+ ...journal
1044
+ });
1045
+ }
1037
1046
  if (operationType) {
1038
1047
  await this.logUsage({
1039
1048
  userId,