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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+217
View File
@@ -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)
})
})
+136
View File
@@ -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
}
+233
View File
@@ -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 }
}
}