chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { db } from '@sim/db'
|
||||
import { organization, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
|
||||
import {
|
||||
hasUsableSubscriptionAccess,
|
||||
isOrgScopedSubscription,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import { Decimal, toDecimal, toFixedString, toNumber } from '@/lib/billing/utils/decimal'
|
||||
import type { DbClient } from '@/lib/db/types'
|
||||
|
||||
const logger = createLogger('CreditBalance')
|
||||
|
||||
export interface CreditBalanceInfo {
|
||||
balance: number
|
||||
entityType: 'user' | 'organization'
|
||||
entityId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Read credit balance directly from a known entity (user or organization).
|
||||
* Use this in webhook / admin paths that already know the target entity —
|
||||
* unlike `getCreditBalance(userId)` it does not route through
|
||||
* `getHighestPrioritySubscription`, so callers don't need to resolve the
|
||||
* org owner as a user-id proxy.
|
||||
*/
|
||||
export async function getCreditBalanceForEntity(
|
||||
entityType: 'user' | 'organization',
|
||||
entityId: string,
|
||||
executor: DbClient = db
|
||||
): Promise<number> {
|
||||
if (entityType === 'organization') {
|
||||
const rows = await executor
|
||||
.select({ creditBalance: organization.creditBalance })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, entityId))
|
||||
.limit(1)
|
||||
return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0
|
||||
}
|
||||
|
||||
const rows = await executor
|
||||
.select({ creditBalance: userStats.creditBalance })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, entityId))
|
||||
.limit(1)
|
||||
return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0
|
||||
}
|
||||
|
||||
export async function getCreditBalance(
|
||||
userId: string,
|
||||
executor: DbClient = db
|
||||
): Promise<CreditBalanceInfo> {
|
||||
const subscription = await getHighestPrioritySubscription(userId, { executor })
|
||||
|
||||
if (isOrgScopedSubscription(subscription, userId) && subscription) {
|
||||
return {
|
||||
balance: await getCreditBalanceForEntity('organization', subscription.referenceId, executor),
|
||||
entityType: 'organization',
|
||||
entityId: subscription.referenceId,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
balance: await getCreditBalanceForEntity('user', userId, executor),
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCredits(
|
||||
entityType: 'user' | 'organization',
|
||||
entityId: string,
|
||||
amount: number
|
||||
): Promise<void> {
|
||||
if (entityType === 'organization') {
|
||||
await db
|
||||
.update(organization)
|
||||
.set({ creditBalance: sql`${organization.creditBalance} + ${amount}` })
|
||||
.where(eq(organization.id, entityId))
|
||||
|
||||
logger.info('Added credits to organization', { organizationId: entityId, amount })
|
||||
} else {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ creditBalance: sql`${userStats.creditBalance} + ${amount}` })
|
||||
.where(eq(userStats.userId, entityId))
|
||||
|
||||
logger.info('Added credits to user', { userId: entityId, amount })
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCredits(
|
||||
entityType: 'user' | 'organization',
|
||||
entityId: string,
|
||||
amount: number
|
||||
): Promise<void> {
|
||||
if (entityType === 'organization') {
|
||||
await db
|
||||
.update(organization)
|
||||
.set({ creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${amount})` })
|
||||
.where(eq(organization.id, entityId))
|
||||
|
||||
logger.info('Removed credits from organization', { organizationId: entityId, amount })
|
||||
} else {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${amount})` })
|
||||
.where(eq(userStats.userId, entityId))
|
||||
|
||||
logger.info('Removed credits from user', { userId: entityId, amount })
|
||||
}
|
||||
}
|
||||
|
||||
interface DeductResult {
|
||||
creditsUsed: number
|
||||
overflow: number
|
||||
}
|
||||
|
||||
async function atomicDeductUserCredits(userId: string, cost: number): Promise<number> {
|
||||
const costDecimal = toDecimal(cost)
|
||||
const costStr = toFixedString(costDecimal)
|
||||
|
||||
// Use raw SQL with CTE to capture old balance before update
|
||||
const result = await db.execute<{ old_balance: string; new_balance: string }>(sql`
|
||||
WITH old_balance AS (
|
||||
SELECT credit_balance FROM user_stats WHERE user_id = ${userId}
|
||||
)
|
||||
UPDATE user_stats
|
||||
SET credit_balance = CASE
|
||||
WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal
|
||||
ELSE 0
|
||||
END
|
||||
WHERE user_id = ${userId} AND credit_balance >= 0
|
||||
RETURNING
|
||||
(SELECT credit_balance FROM old_balance) as old_balance,
|
||||
credit_balance as new_balance
|
||||
`)
|
||||
|
||||
const rows = Array.from(result)
|
||||
if (rows.length === 0) return 0
|
||||
|
||||
const oldBalance = toDecimal(rows[0].old_balance)
|
||||
return toNumber(oldBalance.lessThan(costDecimal) ? oldBalance : costDecimal)
|
||||
}
|
||||
|
||||
async function atomicDeductOrgCredits(orgId: string, cost: number): Promise<number> {
|
||||
const costDecimal = toDecimal(cost)
|
||||
const costStr = toFixedString(costDecimal)
|
||||
|
||||
// Use raw SQL with CTE to capture old balance before update
|
||||
const result = await db.execute<{ old_balance: string; new_balance: string }>(sql`
|
||||
WITH old_balance AS (
|
||||
SELECT credit_balance FROM organization WHERE id = ${orgId}
|
||||
)
|
||||
UPDATE organization
|
||||
SET credit_balance = CASE
|
||||
WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal
|
||||
ELSE 0
|
||||
END
|
||||
WHERE id = ${orgId} AND credit_balance >= 0
|
||||
RETURNING
|
||||
(SELECT credit_balance FROM old_balance) as old_balance,
|
||||
credit_balance as new_balance
|
||||
`)
|
||||
|
||||
const rows = Array.from(result)
|
||||
if (rows.length === 0) return 0
|
||||
|
||||
const oldBalance = toDecimal(rows[0].old_balance)
|
||||
return toNumber(oldBalance.lessThan(costDecimal) ? oldBalance : costDecimal)
|
||||
}
|
||||
|
||||
async function deductFromCredits(userId: string, cost: number): Promise<DeductResult> {
|
||||
if (cost <= 0) {
|
||||
return { creditsUsed: 0, overflow: 0 }
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
const orgScoped = isOrgScopedSubscription(subscription, userId)
|
||||
|
||||
let creditsUsed: number
|
||||
|
||||
if (orgScoped && subscription?.referenceId) {
|
||||
creditsUsed = await atomicDeductOrgCredits(subscription.referenceId, cost)
|
||||
} else {
|
||||
creditsUsed = await atomicDeductUserCredits(userId, cost)
|
||||
}
|
||||
|
||||
const overflow = toNumber(Decimal.max(0, toDecimal(cost).minus(creditsUsed)))
|
||||
|
||||
if (creditsUsed > 0) {
|
||||
logger.info('Deducted credits atomically', {
|
||||
userId,
|
||||
creditsUsed,
|
||||
overflow,
|
||||
entityType: orgScoped ? 'organization' : 'user',
|
||||
})
|
||||
}
|
||||
|
||||
return { creditsUsed, overflow }
|
||||
}
|
||||
|
||||
export async function canPurchaseCredits(userId: string): Promise<boolean> {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
if (!subscription) {
|
||||
return false
|
||||
}
|
||||
const billingStatus = await getEffectiveBillingStatus(userId)
|
||||
if (!hasUsableSubscriptionAccess(subscription.status, billingStatus.billingBlocked)) {
|
||||
return false
|
||||
}
|
||||
// Enterprise users must contact support to purchase credits
|
||||
return isPro(subscription.plan) || isTeam(subscription.plan)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
apportionCredits,
|
||||
creditsToDollars,
|
||||
dollarsToCredits,
|
||||
formatCreditCost,
|
||||
} from '@/lib/billing/credits/conversion'
|
||||
|
||||
describe('creditsToDollars', () => {
|
||||
it('converts credits to dollars at 200 credits per dollar', () => {
|
||||
expect(creditsToDollars(200)).toBe(1)
|
||||
expect(creditsToDollars(0)).toBe(0)
|
||||
expect(creditsToDollars(1)).toBe(0.005)
|
||||
})
|
||||
|
||||
it('round-trips with dollarsToCredits for whole-credit values', () => {
|
||||
expect(dollarsToCredits(creditsToDollars(2000))).toBe(2000)
|
||||
expect(dollarsToCredits(creditsToDollars(1))).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCreditCost', () => {
|
||||
it('renders multiplier-inclusive dollars as a single-rounded credit label', () => {
|
||||
expect(formatCreditCost(0.005)).toBe('1 credit')
|
||||
expect(formatCreditCost(0.03141848)).toBe('6 credits')
|
||||
expect(formatCreditCost(1.234)).toBe('247 credits')
|
||||
})
|
||||
|
||||
it('distinguishes sub-credit charges from zero', () => {
|
||||
expect(formatCreditCost(0.001)).toBe('<1 credit')
|
||||
expect(formatCreditCost(0)).toBe('0 credits')
|
||||
})
|
||||
|
||||
it('honors emptyForZeroOrLess for the trace view contract', () => {
|
||||
expect(formatCreditCost(0, { emptyForZeroOrLess: true })).toBeUndefined()
|
||||
expect(formatCreditCost(undefined, { emptyForZeroOrLess: true })).toBeUndefined()
|
||||
expect(formatCreditCost(undefined)).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('apportionCredits', () => {
|
||||
it('keeps line items summing exactly to the rounded total (no round-then-sum drift)', () => {
|
||||
// Real execution 43ef064d: base + 2x model, multiplier already applied.
|
||||
// Round-then-sum would give 1 + 4 + 2 = 7; the true total is 6.
|
||||
const credits = apportionCredits([
|
||||
{ key: 'base', dollars: 0.005 },
|
||||
{ key: 'input', dollars: 0.018798 },
|
||||
{ key: 'output', dollars: 0.00762 },
|
||||
{ key: 'tool', dollars: 0 },
|
||||
])
|
||||
|
||||
const total = dollarsToCredits(0.005 + 0.018798 + 0.00762 + 0)
|
||||
expect(total).toBe(6)
|
||||
expect(credits.base + credits.input + credits.output + credits.tool).toBe(total)
|
||||
expect(credits.base).toBe(1)
|
||||
})
|
||||
|
||||
it('handles all-zero components', () => {
|
||||
const credits = apportionCredits([
|
||||
{ key: 'base', dollars: 0 },
|
||||
{ key: 'model', dollars: 0 },
|
||||
])
|
||||
expect(credits.base + credits.model).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores negative/non-finite components without throwing', () => {
|
||||
const credits = apportionCredits([
|
||||
{ key: 'base', dollars: 0.005 },
|
||||
{ key: 'model', dollars: Number.NaN },
|
||||
{ key: 'tool', dollars: -1 },
|
||||
])
|
||||
expect(credits.base).toBe(1)
|
||||
expect(credits.model).toBe(0)
|
||||
expect(credits.tool).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns, drizzleOrmMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
vi.mock('drizzle-orm', () => {
|
||||
const sqlTag = () => {
|
||||
const obj: { as: () => typeof obj } = { as: () => obj }
|
||||
return obj
|
||||
}
|
||||
return {
|
||||
...drizzleOrmMock,
|
||||
sql: Object.assign(sqlTag, { raw: sqlTag }),
|
||||
sum: () => ({ as: () => 'sum' }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/billing/constants', () => ({
|
||||
DAILY_REFRESH_RATE: 0.01,
|
||||
}))
|
||||
|
||||
import {
|
||||
computeDailyRefreshConsumed,
|
||||
getDailyRefreshDollars,
|
||||
} from '@/lib/billing/credits/daily-refresh'
|
||||
|
||||
describe('computeDailyRefreshConsumed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 0 when planDollars is 0', async () => {
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1'],
|
||||
periodStart: new Date('2026-03-01'),
|
||||
planDollars: 0,
|
||||
})
|
||||
expect(result).toBe(0)
|
||||
expect(dbChainMockFns.groupBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 0 when userIds is empty', async () => {
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: [],
|
||||
periodStart: new Date('2026-03-01'),
|
||||
planDollars: 25,
|
||||
})
|
||||
expect(result).toBe(0)
|
||||
expect(dbChainMockFns.groupBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 0 when periodEnd is before periodStart', async () => {
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1'],
|
||||
periodStart: new Date('2026-03-10'),
|
||||
periodEnd: new Date('2026-03-01'),
|
||||
planDollars: 25,
|
||||
})
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('caps each day at the daily refresh allowance', async () => {
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([
|
||||
{ dayIndex: 0, dayTotal: '0.50' },
|
||||
{ dayIndex: 1, dayTotal: '0.10' },
|
||||
{ dayIndex: 2, dayTotal: '1.00' },
|
||||
])
|
||||
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1'],
|
||||
periodStart: new Date('2026-03-01'),
|
||||
periodEnd: new Date('2026-03-04'),
|
||||
planDollars: 25,
|
||||
})
|
||||
|
||||
// Daily refresh = $25 * 0.01 = $0.25/day
|
||||
// Day 0: MIN(0.50, 0.25) = 0.25
|
||||
// Day 1: MIN(0.10, 0.25) = 0.10
|
||||
// Day 2: MIN(1.00, 0.25) = 0.25
|
||||
// Total = 0.60
|
||||
expect(result).toBe(0.6)
|
||||
})
|
||||
|
||||
it('returns 0 when no usage rows exist', async () => {
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([])
|
||||
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1'],
|
||||
periodStart: new Date('2026-03-01'),
|
||||
periodEnd: new Date('2026-03-04'),
|
||||
planDollars: 25,
|
||||
})
|
||||
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('multiplies daily refresh by seats', async () => {
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '2.00' }])
|
||||
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1', 'user-2', 'user-3'],
|
||||
periodStart: new Date('2026-03-01'),
|
||||
periodEnd: new Date('2026-03-02'),
|
||||
planDollars: 100,
|
||||
seats: 3,
|
||||
})
|
||||
|
||||
// Daily refresh = $100 * 0.01 * 3 seats = $3.00/day
|
||||
// Day 0: MIN(2.00, 3.00) = 2.00
|
||||
expect(result).toBe(2.0)
|
||||
})
|
||||
|
||||
it('caps at refresh even with high usage and multiple seats', async () => {
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '50.00' }])
|
||||
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1', 'user-2'],
|
||||
periodStart: new Date('2026-03-01'),
|
||||
periodEnd: new Date('2026-03-02'),
|
||||
planDollars: 100,
|
||||
seats: 2,
|
||||
})
|
||||
|
||||
// Daily refresh = $100 * 0.01 * 2 seats = $2.00/day
|
||||
// Day 0: MIN(50.00, 2.00) = 2.00
|
||||
expect(result).toBe(2.0)
|
||||
})
|
||||
|
||||
it('handles null dayTotal gracefully', async () => {
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: null }])
|
||||
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1'],
|
||||
periodStart: new Date('2026-03-01'),
|
||||
periodEnd: new Date('2026-03-02'),
|
||||
planDollars: 25,
|
||||
})
|
||||
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDailyRefreshDollars', () => {
|
||||
it('computes correct daily refresh for Pro ($25)', () => {
|
||||
expect(getDailyRefreshDollars(25)).toBe(0.25)
|
||||
})
|
||||
|
||||
it('computes correct daily refresh for Max ($100)', () => {
|
||||
expect(getDailyRefreshDollars(100)).toBe(1.0)
|
||||
})
|
||||
|
||||
it('returns 0 for $0 plan', () => {
|
||||
expect(getDailyRefreshDollars(0)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Daily Refresh Credits
|
||||
*
|
||||
* Each billing period is divided into 1-day windows starting from `periodStart`.
|
||||
* Users receive `planDollars * DAILY_REFRESH_RATE` in "included" usage per day.
|
||||
* Usage within that allowance does not count toward the plan limit (use-it-or-lose-it).
|
||||
*
|
||||
* The total refresh consumed in a period is:
|
||||
* SUM( MIN(day_usage, daily_refresh_amount) ) for each day
|
||||
*
|
||||
* This is subtracted from `currentPeriodCost` to derive "effective billable usage".
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { member, usageLog, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, gte, inArray, lt, or, sql, sum } from 'drizzle-orm'
|
||||
import { DAILY_REFRESH_RATE } from '@/lib/billing/constants'
|
||||
import type { DbClient } from '@/lib/db/types'
|
||||
|
||||
const logger = createLogger('DailyRefresh')
|
||||
|
||||
const MS_PER_DAY = 86_400_000
|
||||
|
||||
/**
|
||||
* Optional per-user date window. `usageLog` rows outside
|
||||
* `[userStart, userEnd)` are excluded from that user's contribution.
|
||||
* Used to slice refresh around a mid-cycle org join so pre-join and
|
||||
* post-join refresh are billed by the right subscription.
|
||||
*/
|
||||
export interface PerUserBounds {
|
||||
userStart?: Date | null
|
||||
userEnd?: Date | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the total daily refresh credits consumed in the current billing period
|
||||
* using a single aggregating SQL query grouped by day offset.
|
||||
*
|
||||
* For each day from `periodStart`:
|
||||
* consumed_today = MIN(actual_usage_today, daily_refresh_dollars)
|
||||
*
|
||||
* @returns Total dollars of refresh consumed across all days (to subtract from usage)
|
||||
*/
|
||||
export async function computeDailyRefreshConsumed(
|
||||
params: {
|
||||
userIds: string[]
|
||||
periodStart: Date
|
||||
periodEnd?: Date | null
|
||||
planDollars: number
|
||||
seats?: number
|
||||
userBounds?: Record<string, PerUserBounds>
|
||||
billingEntity?: { type: 'user' | 'organization'; id: string }
|
||||
},
|
||||
executor: DbClient = db
|
||||
): Promise<number> {
|
||||
const {
|
||||
userIds,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
planDollars,
|
||||
seats = 1,
|
||||
userBounds,
|
||||
billingEntity,
|
||||
} = params
|
||||
|
||||
if (planDollars <= 0 || userIds.length === 0) return 0
|
||||
|
||||
const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats
|
||||
|
||||
const now = new Date()
|
||||
const cap = periodEnd && periodEnd < now ? periodEnd : now
|
||||
|
||||
if (cap <= periodStart) return 0
|
||||
|
||||
const dayCount = Math.ceil((cap.getTime() - periodStart.getTime()) / MS_PER_DAY)
|
||||
if (dayCount <= 0) return 0
|
||||
|
||||
const unboundedUsers = userBounds ? userIds.filter((id) => !(id in userBounds)) : userIds
|
||||
const billingEntityFilter = billingEntity
|
||||
? and(
|
||||
eq(usageLog.billingEntityType, billingEntity.type),
|
||||
eq(usageLog.billingEntityId, billingEntity.id),
|
||||
eq(usageLog.billingPeriodStart, periodStart)
|
||||
)
|
||||
: undefined
|
||||
|
||||
const boundedClauses = userBounds
|
||||
? Object.entries(userBounds).flatMap(([userId, bounds]) => {
|
||||
if (!userIds.includes(userId)) return []
|
||||
const effectiveStart =
|
||||
bounds.userStart && bounds.userStart > periodStart ? bounds.userStart : periodStart
|
||||
const effectiveEnd = bounds.userEnd && bounds.userEnd < cap ? bounds.userEnd : cap
|
||||
if (effectiveEnd <= effectiveStart) return []
|
||||
return [
|
||||
and(
|
||||
eq(usageLog.userId, userId),
|
||||
billingEntityFilter,
|
||||
gte(usageLog.createdAt, effectiveStart),
|
||||
lt(usageLog.createdAt, effectiveEnd)
|
||||
),
|
||||
]
|
||||
})
|
||||
: []
|
||||
|
||||
const rowFilters =
|
||||
unboundedUsers.length > 0
|
||||
? [
|
||||
and(
|
||||
inArray(usageLog.userId, unboundedUsers),
|
||||
billingEntityFilter,
|
||||
gte(usageLog.createdAt, periodStart),
|
||||
lt(usageLog.createdAt, cap)
|
||||
),
|
||||
...boundedClauses,
|
||||
]
|
||||
: boundedClauses
|
||||
|
||||
if (rowFilters.length === 0) return 0
|
||||
|
||||
const rows = await executor
|
||||
.select({
|
||||
dayIndex:
|
||||
sql<number>`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(periodStart.getTime() / 1000)}) / 86400)`.as(
|
||||
'day_index'
|
||||
),
|
||||
dayTotal: sum(usageLog.cost).as('day_total'),
|
||||
})
|
||||
.from(usageLog)
|
||||
.where(rowFilters.length === 1 ? rowFilters[0] : or(...rowFilters))
|
||||
.groupBy(sql`day_index`)
|
||||
|
||||
let totalConsumed = 0
|
||||
for (const row of rows) {
|
||||
const dayUsage = Number.parseFloat(row.dayTotal ?? '0')
|
||||
totalConsumed += Math.min(dayUsage, dailyRefreshDollars)
|
||||
}
|
||||
|
||||
logger.debug('Daily refresh computed', {
|
||||
userCount: userIds.length,
|
||||
periodStart: periodStart.toISOString(),
|
||||
days: dayCount,
|
||||
dailyRefreshDollars,
|
||||
totalConsumed,
|
||||
hasUserBounds: Boolean(userBounds),
|
||||
})
|
||||
|
||||
return totalConsumed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the daily refresh allowance in dollars for a plan.
|
||||
*/
|
||||
export function getDailyRefreshDollars(planDollars: number): number {
|
||||
return planDollars * DAILY_REFRESH_RATE
|
||||
}
|
||||
|
||||
export async function getOrgMemberRefreshBounds(
|
||||
organizationId: string,
|
||||
periodStart: Date,
|
||||
executor: DbClient = db
|
||||
): Promise<Record<string, { userStart: Date }>> {
|
||||
const rows = await executor
|
||||
.select({
|
||||
userId: member.userId,
|
||||
snapshotAt: userStats.proPeriodCostSnapshotAt,
|
||||
})
|
||||
.from(member)
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const bounds: Record<string, { userStart: Date }> = {}
|
||||
for (const row of rows) {
|
||||
if (row.snapshotAt && row.snapshotAt > periodStart) {
|
||||
bounds[row.userId] = { userStart: row.snapshotAt }
|
||||
}
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { db } from '@sim/db'
|
||||
import { organization, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { canPurchaseCredits } from '@/lib/billing/credits/balance'
|
||||
import { isEnterprise } from '@/lib/billing/plan-helpers'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getCustomerId, resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method'
|
||||
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
|
||||
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
|
||||
|
||||
const logger = createLogger('CreditPurchase')
|
||||
|
||||
/**
|
||||
* Sets usage limit to planBase + creditBalance.
|
||||
* This ensures users can use their plan's included amount plus any prepaid credits.
|
||||
*/
|
||||
export async function setUsageLimitForCredits(
|
||||
entityType: 'user' | 'organization',
|
||||
entityId: string,
|
||||
plan: string,
|
||||
seats: number | null,
|
||||
creditBalance: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { basePrice } = getPlanPricing(plan)
|
||||
|
||||
const seatCount = seats || 1
|
||||
const planBase =
|
||||
entityType === 'organization' ? Number(basePrice) * seatCount : Number(basePrice)
|
||||
const creditBalanceNum = Number(creditBalance)
|
||||
const newLimit = planBase + creditBalanceNum
|
||||
|
||||
if (entityType === 'organization') {
|
||||
const orgRows = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, entityId))
|
||||
.limit(1)
|
||||
|
||||
const currentLimit = orgRows.length > 0 ? toNumber(toDecimal(orgRows[0].orgUsageLimit)) : 0
|
||||
|
||||
if (newLimit > currentLimit) {
|
||||
await db
|
||||
.update(organization)
|
||||
.set({ orgUsageLimit: newLimit.toString() })
|
||||
.where(eq(organization.id, entityId))
|
||||
|
||||
logger.info('Set org usage limit to planBase + credits', {
|
||||
organizationId: entityId,
|
||||
plan,
|
||||
seats,
|
||||
planBase,
|
||||
creditBalance,
|
||||
previousLimit: currentLimit,
|
||||
newLimit,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const userStatsRows = await db
|
||||
.select({ currentUsageLimit: userStats.currentUsageLimit })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, entityId))
|
||||
.limit(1)
|
||||
|
||||
const currentLimit =
|
||||
userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].currentUsageLimit)) : 0
|
||||
|
||||
if (newLimit > currentLimit) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ currentUsageLimit: newLimit.toString() })
|
||||
.where(eq(userStats.userId, entityId))
|
||||
|
||||
logger.info('Set user usage limit to planBase + credits', {
|
||||
userId: entityId,
|
||||
plan,
|
||||
planBase,
|
||||
creditBalance,
|
||||
previousLimit: currentLimit,
|
||||
newLimit,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to set usage limit for credits', { entityType, entityId, error })
|
||||
}
|
||||
}
|
||||
|
||||
export interface PurchaseCreditsParams {
|
||||
userId: string
|
||||
amountDollars: number
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface PurchaseResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function purchaseCredits(params: PurchaseCreditsParams): Promise<PurchaseResult> {
|
||||
const { userId, amountDollars, requestId } = params
|
||||
|
||||
if (amountDollars < 10 || amountDollars > 1000) {
|
||||
return { success: false, error: 'Amount must be between $10 and $1000' }
|
||||
}
|
||||
|
||||
const canPurchase = await canPurchaseCredits(userId)
|
||||
if (!canPurchase) {
|
||||
return { success: false, error: 'Only Pro and Team users can purchase credits' }
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
if (!subscription || !subscription.stripeSubscriptionId) {
|
||||
return { success: false, error: 'No active subscription found' }
|
||||
}
|
||||
|
||||
// Enterprise users must contact support
|
||||
if (isEnterprise(subscription.plan)) {
|
||||
return { success: false, error: 'Enterprise users must contact support to purchase credits' }
|
||||
}
|
||||
|
||||
let entityType: 'user' | 'organization' = 'user'
|
||||
let entityId = userId
|
||||
|
||||
// Org-scoped subs route credit purchases to the organization and must be authorized
|
||||
// by an org owner/admin. We've already rejected enterprise above.
|
||||
if (isOrgScopedSubscription(subscription, userId)) {
|
||||
const isAdmin = await isOrganizationOwnerOrAdmin(userId, subscription.referenceId)
|
||||
if (!isAdmin) {
|
||||
return { success: false, error: 'Only organization owners and admins can purchase credits' }
|
||||
}
|
||||
entityType = 'organization'
|
||||
entityId = subscription.referenceId
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
const stripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId)
|
||||
const customerId = getCustomerId(stripeSub.customer)
|
||||
if (!customerId) {
|
||||
return { success: false, error: 'Subscription missing customer' }
|
||||
}
|
||||
|
||||
const { paymentMethodId: defaultPaymentMethod } = await resolveDefaultPaymentMethod(
|
||||
stripe,
|
||||
subscription.stripeSubscriptionId,
|
||||
customerId
|
||||
)
|
||||
|
||||
if (!defaultPaymentMethod) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No payment method on file. Please update your billing info.',
|
||||
}
|
||||
}
|
||||
|
||||
const amountCents = Math.round(amountDollars * 100)
|
||||
const idempotencyKey = `credit-purchase:${requestId}`
|
||||
|
||||
const creditMetadata = {
|
||||
type: 'credit_purchase',
|
||||
entityType,
|
||||
entityId,
|
||||
amountDollars: amountDollars.toString(),
|
||||
purchasedBy: userId,
|
||||
}
|
||||
|
||||
// Create invoice
|
||||
const invoice = await stripe.invoices.create(
|
||||
{
|
||||
customer: customerId,
|
||||
collection_method: 'charge_automatically',
|
||||
auto_advance: false,
|
||||
description: `Credit purchase - $${amountDollars}`,
|
||||
metadata: creditMetadata,
|
||||
default_payment_method: defaultPaymentMethod,
|
||||
},
|
||||
{ idempotencyKey: `${idempotencyKey}-invoice` }
|
||||
)
|
||||
|
||||
// Add line item
|
||||
await stripe.invoiceItems.create(
|
||||
{
|
||||
customer: customerId,
|
||||
invoice: invoice.id,
|
||||
amount: amountCents,
|
||||
currency: 'usd',
|
||||
description: `Prepaid credits ($${amountDollars})`,
|
||||
metadata: creditMetadata,
|
||||
},
|
||||
{ idempotencyKey: `${idempotencyKey}-item` }
|
||||
)
|
||||
|
||||
// Finalize and pay
|
||||
if (!invoice.id) {
|
||||
return { success: false, error: 'Failed to create invoice' }
|
||||
}
|
||||
|
||||
const finalized = await stripe.invoices.finalizeInvoice(
|
||||
invoice.id,
|
||||
{},
|
||||
{ idempotencyKey: `${idempotencyKey}-finalize` }
|
||||
)
|
||||
|
||||
if (finalized.status === 'open' && finalized.id) {
|
||||
await stripe.invoices.pay(
|
||||
finalized.id,
|
||||
{ payment_method: defaultPaymentMethod },
|
||||
{ idempotencyKey: `${idempotencyKey}-pay` }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Credit purchase invoice created and paid', {
|
||||
invoiceId: invoice.id,
|
||||
entityType,
|
||||
entityId,
|
||||
amountDollars,
|
||||
purchasedBy: userId,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('Failed to purchase credits', { error, userId, amountDollars })
|
||||
const message = getErrorMessage(error, 'Failed to process payment')
|
||||
return { success: false, error: message }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user