d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
137 lines
5.2 KiB
TypeScript
137 lines
5.2 KiB
TypeScript
/**
|
|
* Credit conversion utilities.
|
|
* All DB values remain in dollars; these helpers convert at API/UI boundaries only.
|
|
* 1 credit = $0.005 (i.e. $1 = 200 credits)
|
|
*/
|
|
|
|
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
|
|
|
|
export const CREDIT_MULTIPLIER = 200
|
|
|
|
export function dollarsToCredits(dollars: number): number {
|
|
return Math.round(dollars * CREDIT_MULTIPLIER)
|
|
}
|
|
|
|
/**
|
|
* Convert a credit amount (the API/UI unit) into stored dollars.
|
|
* Inverse of {@link dollarsToCredits}; use at write boundaries that accept
|
|
* credit-denominated input (e.g. per-member usage limits).
|
|
*/
|
|
export function creditsToDollars(credits: number): number {
|
|
return credits / CREDIT_MULTIPLIER
|
|
}
|
|
|
|
/**
|
|
* Pluralizes an already credit-denominated integer, e.g. `1234` -> `"1,234
|
|
* credits"`, `1` -> `"1 credit"`. Low-level building block for
|
|
* {@link formatCreditCost} — also useful directly for consumers that already
|
|
* hold precise integer credits (e.g. a server-converted `creditCost` value),
|
|
* which would otherwise double-convert by round-tripping back through
|
|
* dollars.
|
|
*/
|
|
export function formatCreditsLabel(credits: number): string {
|
|
return `${credits.toLocaleString()} ${credits === 1 ? 'credit' : 'credits'}`
|
|
}
|
|
|
|
/**
|
|
* Single source of truth for rendering a dollar cost as a credit label.
|
|
*
|
|
* Both the billing cost breakdown and the trace view derive their credit
|
|
* strings from here so the two surfaces can never diverge in rounding,
|
|
* thresholds, or pluralization. The dollar amount passed in is expected to
|
|
* already carry any cost multiplier (the value is converted with one — and
|
|
* only one — round via `dollarsToCredits`, i.e. multiply-then-round; never
|
|
* round per-line then multiply).
|
|
*
|
|
* `emptyForZeroOrLess` controls the zero/empty behavior so existing call sites
|
|
* keep their contracts: the breakdown wants a concrete "0 credits"/"—" string,
|
|
* the trace view wants `undefined` so it can hide the chip entirely.
|
|
*/
|
|
export function formatCreditCost(
|
|
dollars: number | null | undefined,
|
|
opts?: { emptyForZeroOrLess?: boolean }
|
|
): string | undefined {
|
|
if (dollars === undefined || dollars === null || !Number.isFinite(dollars)) {
|
|
return opts?.emptyForZeroOrLess ? undefined : '—'
|
|
}
|
|
|
|
const credits = dollarsToCredits(dollars)
|
|
|
|
if (credits <= 0) {
|
|
if (dollars > 0) return '<1 credit'
|
|
return opts?.emptyForZeroOrLess ? undefined : '0 credits'
|
|
}
|
|
|
|
return formatCreditsLabel(credits)
|
|
}
|
|
|
|
/**
|
|
* Renders an already-apportioned integer `creditCost` (see {@link apportionCredits})
|
|
* alongside its raw `dollarCost`, so a row can legitimately apportion to 0
|
|
* credits — once a sibling absorbs the shared rounding remainder — without
|
|
* reading as a flat, misleading "0 credits" for an event that had a real,
|
|
* positive charge. Mirrors {@link formatCreditCost}'s zero/sub-credit
|
|
* wording, but never recomputes credits from `dollarCost` (that would
|
|
* double-convert a value the caller already apportioned).
|
|
*/
|
|
export function formatApportionedCreditCost(creditCost: number, dollarCost: number): string {
|
|
if (creditCost > 0) return formatCreditsLabel(creditCost)
|
|
return dollarCost > 0 ? '<1 credit' : '0 credits'
|
|
}
|
|
|
|
/**
|
|
* Splits a set of cost components into integer credits that sum *exactly* to
|
|
* the credits of their combined total.
|
|
*
|
|
* This is the fix for the "line items don't add up to the total" class of bug:
|
|
* rounding each line independently (round-then-sum) drifts from the real charge
|
|
* (e.g. 1 + 2 + 1 = 4, or 1 + 4 + 2 = 7, when the true total is 6). Instead we
|
|
* convert the *summed* dollars to credits with a single round (multiply-then-
|
|
* round) and distribute that figure across the components via the largest-
|
|
* remainder method, so every component is multiplier-applied, internally
|
|
* consistent, and the rows always reconcile with the total.
|
|
*
|
|
* Each component's `dollars` is expected to already include any cost multiplier.
|
|
*/
|
|
export function apportionCredits<K extends string>(
|
|
components: { key: K; dollars: number }[]
|
|
): Record<K, number> {
|
|
const result = {} as Record<K, number>
|
|
|
|
const sanitized = components.map((c) => ({
|
|
key: c.key,
|
|
dollars: Number.isFinite(c.dollars) && c.dollars > 0 ? c.dollars : 0,
|
|
}))
|
|
|
|
const totalDollars = sanitized.reduce((sum, c) => sum + c.dollars, 0)
|
|
const targetCredits = dollarsToCredits(totalDollars)
|
|
|
|
const exact = sanitized.map((c) => ({
|
|
key: c.key,
|
|
floor: Math.floor(c.dollars * CREDIT_MULTIPLIER),
|
|
frac: c.dollars * CREDIT_MULTIPLIER - Math.floor(c.dollars * CREDIT_MULTIPLIER),
|
|
}))
|
|
|
|
for (const c of exact) result[c.key] = c.floor
|
|
|
|
let remainder = targetCredits - exact.reduce((sum, c) => sum + c.floor, 0)
|
|
const byFraction = [...exact].sort((a, b) => b.frac - a.frac)
|
|
for (let i = 0; i < byFraction.length && remainder > 0; i++) {
|
|
result[byFraction[i].key] += 1
|
|
remainder--
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Format a dollar amount as a comma-separated credit string.
|
|
* Values at or above the on-demand unlimited threshold display as ∞.
|
|
* @example formatCredits(20) => "2,000"
|
|
* @example formatCredits(999999) => "∞"
|
|
*/
|
|
export function formatCredits(dollars: number): string {
|
|
if (dollars >= ON_DEMAND_UNLIMITED) return '∞'
|
|
return dollarsToCredits(dollars).toLocaleString()
|
|
}
|