Files
simstudioai--sim/apps/sim/lib/billing/core/usage.ts
T
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1002 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { db } from '@sim/db'
import { member, organization, settings, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import {
getEmailSubject,
renderCreditsExhaustedEmail,
renderFreeTierUpgradeEmail,
renderUsageThresholdEmail,
} from '@/components/emails'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import {
getHighestPrioritySubscription,
type HighestPrioritySubscription,
} from '@/lib/billing/core/plan'
import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
import {
computeDailyRefreshConsumed,
getOrgMemberRefreshBounds,
} from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isEnterprise, isFree, isPaid, isPro } from '@/lib/billing/plan-helpers'
import {
canEditUsageLimit,
getFreeTierLimit,
getPerUserMinimumLimit,
getPlanPricing,
hasPaidSubscriptionStatus,
hasUsableSubscriptionAccess,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { DbClient } from '@/lib/db/types'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getEmailPreferences } from '@/lib/messaging/email/unsubscribe'
const logger = createLogger('UsageManagement')
export interface OrgUsageLimitResult {
limit: number
minimum: number
}
/**
* Sum `currentPeriodCost` across all members of an organization.
* The single source of truth for pooled-usage reads so every caller
* applies identical null-handling and query shape. Does NOT apply
* daily-refresh deduction — callers layer that on top themselves
* because refresh math needs the caller's `sub` context (plan,
* period, seats, per-user bounds).
*
* Uses `LEFT JOIN` so members whose `userStats` row is missing still
* appear (contributing 0), which keeps `memberIds` complete for
* downstream refresh / bounds computations.
*/
export async function getPooledOrgCurrentPeriodCost(
organizationId: string,
executor: DbClient = db
): Promise<{ memberIds: string[]; currentPeriodCost: number; lastPeriodCost: number }> {
const rows = await executor
.select({
userId: member.userId,
currentPeriodCost: userStats.currentPeriodCost,
lastPeriodCost: userStats.lastPeriodCost,
})
.from(member)
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
let pooled = new Decimal(0)
let lastPeriodCost = new Decimal(0)
const memberIds: string[] = []
for (const row of rows) {
memberIds.push(row.userId)
pooled = pooled.plus(toDecimal(row.currentPeriodCost))
lastPeriodCost = lastPeriodCost.plus(toDecimal(row.lastPeriodCost))
}
return {
memberIds,
currentPeriodCost: toNumber(pooled),
lastPeriodCost: toNumber(lastPeriodCost),
}
}
/**
* Calculates the effective usage limit for an organization-scoped plan.
* Enterprise uses the configured orgUsageLimit directly; every other
* paid plan uses `basePrice × seats` (Stripe's `price × quantity`) as a
* floor. Returns `{ limit, minimum }` where `limit = max(configured, minimum)`.
*/
export async function getOrgUsageLimit(
organizationId: string,
plan: string,
seats: number | null,
executor: DbClient = db
): Promise<OrgUsageLimitResult> {
const orgData = await executor
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
const configured =
orgData.length > 0 && orgData[0].orgUsageLimit
? toNumber(toDecimal(orgData[0].orgUsageLimit))
: null
if (isEnterprise(plan)) {
// Enterprise: Use configured limit directly (no per-seat minimum)
if (configured !== null) {
return { limit: configured, minimum: configured }
}
logger.warn('Enterprise org missing usage limit', { orgId: organizationId })
return { limit: 0, minimum: 0 }
}
const { basePrice } = getPlanPricing(plan)
// `||` not `??` — 0 is never a valid seat count for a paid sub.
const seatCount = seats || 1
const minimum = seatCount * basePrice
if (configured !== null) {
return { limit: Math.max(configured, minimum), minimum }
}
logger.warn('Org missing usage limit, using plan-driven minimum as fallback', {
orgId: organizationId,
plan,
seats: seatCount,
minimum,
})
return { limit: minimum, minimum }
}
/**
* Handle new user setup when they join the platform
* Creates userStats record with default free credits
*/
export async function handleNewUser(userId: string): Promise<void> {
try {
await db.insert(userStats).values({
id: generateId(),
userId: userId,
currentUsageLimit: getFreeTierLimit().toString(),
usageLimitUpdatedAt: new Date(),
})
logger.info('User stats record created for new user', { userId })
} catch (error) {
logger.error('Failed to create user stats record for new user', {
userId,
error,
})
throw error
}
}
/**
* Ensures a userStats record exists for a user.
* Creates one with default values if missing.
* This is a fallback for cases where the user.create.after hook didn't fire
* (e.g., OAuth account linking to existing users).
*
* Always writes to the primary — never takes a read-routing executor.
*/
export async function ensureUserStatsExists(userId: string): Promise<void> {
await db
.insert(userStats)
.values({
id: generateId(),
userId: userId,
currentUsageLimit: getFreeTierLimit().toString(),
usageLimitUpdatedAt: new Date(),
})
.onConflictDoNothing({ target: userStats.userId })
}
/**
* Get comprehensive usage data for a user
*/
export async function getUserUsageData(
userId: string,
executor: DbClient = db
): Promise<UsageData> {
try {
// Write — always on the primary regardless of executor routing.
await ensureUserStatsExists(userId)
const [userStatsData, subscription] = await Promise.all([
// Read-your-write: must see the row ensureUserStatsExists may have just
// inserted, which a lagging replica can miss (this path throws on a
// missing row). Stays on the primary deliberately.
db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1),
getHighestPrioritySubscription(userId, { executor }),
])
if (userStatsData.length === 0) {
logger.error('User stats not found for userId', { userId })
throw new Error(`User stats not found for userId: ${userId}`)
}
const stats = userStatsData[0]
const orgScoped = isOrgScopedSubscription(subscription, userId)
const billingPeriod =
subscription?.periodStart && subscription.periodEnd
? { start: subscription.periodStart, end: subscription.periodEnd }
: defaultBillingPeriod()
let currentUsageDecimal = toDecimal(stats.currentPeriodCost)
if (!orgScoped) {
currentUsageDecimal = currentUsageDecimal.plus(
await getBillingPeriodUsageCost(
{ type: 'user', id: userId },
billingPeriod,
undefined,
executor
)
)
}
// For personally-scoped Pro users, include any snapshotted usage from
// a prior org-join so the display reflects their total Pro usage.
if (subscription && isPro(subscription.plan) && !orgScoped) {
const snapshotUsageDecimal = toDecimal(stats.proPeriodCostSnapshot)
if (snapshotUsageDecimal.greaterThan(0)) {
currentUsageDecimal = currentUsageDecimal.plus(snapshotUsageDecimal)
logger.info('Including Pro snapshot in usage display', {
userId,
currentPeriodCost: stats.currentPeriodCost,
proPeriodCostSnapshot: toNumber(snapshotUsageDecimal),
totalUsage: toNumber(currentUsageDecimal),
})
}
}
let currentUsage = toNumber(currentUsageDecimal)
let lastPeriodCost = toNumber(toDecimal(stats.lastPeriodCost))
let limit: number
// Shared between the pooled-usage and pooled-refresh blocks so we
// don't issue the member lookup twice per org-scoped call.
let orgMemberIds: string[] = []
if (orgScoped && subscription) {
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats,
executor
)
limit = orgLimit.limit
const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId, executor)
orgMemberIds = pooled.memberIds
lastPeriodCost = pooled.lastPeriodCost
const ledgerUsage = await getBillingPeriodUsageCost(
{ type: 'organization', id: subscription.referenceId },
billingPeriod,
undefined,
executor
)
currentUsage = pooled.currentPeriodCost + ledgerUsage
} else {
limit = stats.currentUsageLimit
? toNumber(toDecimal(stats.currentUsageLimit))
: getFreeTierLimit()
}
const billingPeriodStart = subscription?.periodStart ?? null
const billingPeriodEnd = subscription?.periodEnd ?? null
let dailyRefreshConsumed = 0
if (subscription && isPaid(subscription.plan) && billingPeriodStart) {
const planDollars = getPlanTierDollars(subscription.plan)
if (planDollars > 0) {
if (orgScoped) {
if (orgMemberIds.length > 0) {
const userBounds = await getOrgMemberRefreshBounds(
subscription.referenceId,
billingPeriodStart,
executor
)
dailyRefreshConsumed = await computeDailyRefreshConsumed(
{
userIds: orgMemberIds,
periodStart: billingPeriodStart,
periodEnd: billingPeriodEnd,
planDollars,
seats: subscription.seats || 1,
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
billingEntity: { type: 'organization', id: subscription.referenceId },
},
executor
)
}
} else {
dailyRefreshConsumed = await computeDailyRefreshConsumed(
{
userIds: [userId],
periodStart: billingPeriodStart,
periodEnd: billingPeriodEnd,
planDollars,
billingEntity: { type: 'user', id: userId },
},
executor
)
}
}
}
const effectiveUsage = Math.max(0, currentUsage - dailyRefreshConsumed)
const percentUsed = limit > 0 ? Math.min((effectiveUsage / limit) * 100, 100) : 0
const isWarning = percentUsed >= 80
const isExceeded = effectiveUsage >= limit
return {
currentUsage: effectiveUsage,
limit,
percentUsed,
isWarning,
isExceeded,
billingPeriodStart,
billingPeriodEnd,
lastPeriodCost,
}
} catch (error) {
logger.error('Failed to get user usage data', { userId, error })
throw error
}
}
/**
* Get usage limit information for a user
*/
export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitInfo> {
try {
const [subscription, userStatsRecord] = await Promise.all([
getHighestPrioritySubscription(userId),
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
])
if (userStatsRecord.length === 0) {
throw new Error(`User stats not found for userId: ${userId}`)
}
const stats = userStatsRecord[0]
const orgScoped = isOrgScopedSubscription(subscription, userId)
let currentLimit: number
let minimumLimit: number
let canEdit: boolean
if (orgScoped && subscription) {
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats
)
currentLimit = orgLimit.limit
minimumLimit = orgLimit.minimum
canEdit = false
} else {
currentLimit = stats.currentUsageLimit
? toNumber(toDecimal(stats.currentUsageLimit))
: getFreeTierLimit()
minimumLimit = getPerUserMinimumLimit(subscription)
canEdit = canEditUsageLimit(subscription)
}
return {
currentLimit,
canEdit,
minimumLimit,
plan: subscription?.plan || 'free',
updatedAt: stats.usageLimitUpdatedAt,
scope: orgScoped ? 'organization' : 'user',
organizationId: orgScoped && subscription ? subscription.referenceId : null,
}
} catch (error) {
logger.error('Failed to get usage limit info', { userId, error })
throw error
}
}
/**
* Initialize usage limits for a new user
*/
async function initializeUserUsageLimit(userId: string): Promise<void> {
// Check if user already has usage stats
const existingStats = await db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (existingStats.length > 0) {
return
}
const subscription = await getHighestPrioritySubscription(userId)
const orgScoped = isOrgScopedSubscription(subscription, userId)
await db.insert(userStats).values({
id: generateId(),
userId,
currentUsageLimit: orgScoped ? null : getFreeTierLimit().toString(),
usageLimitUpdatedAt: new Date(),
})
logger.info('Initialized user stats', {
userId,
plan: subscription?.plan || 'free',
hasIndividualLimit: !orgScoped,
})
}
/**
* Update a user's custom usage limit
*/
export async function updateUserUsageLimit(
userId: string,
newLimit: number,
setBy?: string // For team admin tracking
): Promise<{ success: boolean; error?: string }> {
try {
const subscription = await getHighestPrioritySubscription(userId)
if (isOrgScopedSubscription(subscription, userId)) {
return {
success: false,
error:
'This subscription is managed at the organization level. Update the organization usage limit instead.',
}
}
// Only pro users can edit limits (free users cannot)
if (!subscription || isFree(subscription.plan)) {
return { success: false, error: 'Free plan users cannot edit usage limits' }
}
const billingStatus = await getEffectiveBillingStatus(userId)
if (!hasUsableSubscriptionAccess(subscription.status, billingStatus.billingBlocked)) {
return { success: false, error: 'An active subscription is required to edit usage limits' }
}
const minimumLimit = getPerUserMinimumLimit(subscription)
logger.info('Applying plan-based validation', {
userId,
newLimit,
minimumLimit,
plan: subscription?.plan,
})
// Validate new limit is not below minimum
if (newLimit < minimumLimit) {
return {
success: false,
error: `Usage limit cannot be below plan minimum of $${minimumLimit}`,
}
}
await db
.update(userStats)
.set({
currentUsageLimit: newLimit.toString(),
usageLimitUpdatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
logger.info('Updated user usage limit', {
userId,
newLimit,
setBy: setBy || userId,
planMinimum: minimumLimit,
plan: subscription?.plan,
})
return { success: true }
} catch (error) {
logger.error('Failed to update usage limit', { userId, newLimit, error })
return { success: false, error: 'Failed to update usage limit' }
}
}
/**
* Get usage limit for a user (used by checkUsageStatus for server-side
* checks). Org-scoped subs return the organization limit;
* personally-scoped subs return the individual user limit from userStats.
*
* Org-scoped members carry a null `currentUsageLimit` by design (see
* `syncUsageLimitsFromSubscription`). A user whose subscription stops being
* org-scoped without a resync would otherwise stay null and fail closed on
* every execution, so a null limit self-heals to the plan default here. The
* write-back is best-effort: a limit written concurrently wins, and a failed
* write still resolves to the fallback instead of blocking execution.
*/
export async function getUserUsageLimit(
userId: string,
preloadedSubscription?: HighestPrioritySubscription
): Promise<number> {
const subscription =
preloadedSubscription !== undefined
? preloadedSubscription
: await getHighestPrioritySubscription(userId)
if (isOrgScopedSubscription(subscription, userId) && subscription) {
const orgExists = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, subscription.referenceId))
.limit(1)
if (orgExists.length === 0) {
throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`)
}
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats
)
return orgLimit.limit
}
const userStatsQuery = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (userStatsQuery.length === 0) {
throw new Error(
`No user stats record found for userId: ${userId}. User must be properly initialized before execution.`
)
}
if (!userStatsQuery[0].currentUsageLimit) {
const fallbackLimit =
subscription && hasPaidSubscriptionStatus(subscription.status)
? getPerUserMinimumLimit(subscription)
: getFreeTierLimit()
try {
const healed = await db
.update(userStats)
.set({
currentUsageLimit: fallbackLimit.toString(),
usageLimitUpdatedAt: new Date(),
})
.where(and(eq(userStats.userId, userId), isNull(userStats.currentUsageLimit)))
.returning({ currentUsageLimit: userStats.currentUsageLimit })
if (healed.length === 0) {
const concurrent = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (concurrent[0]?.currentUsageLimit) {
return toNumber(toDecimal(concurrent[0].currentUsageLimit))
}
}
logger.warn('Healed null usage limit to plan default', {
userId,
plan: subscription?.plan || 'free',
fallbackLimit,
})
} catch (error) {
logger.error('Failed to heal null usage limit', { userId, fallbackLimit, error })
}
return fallbackLimit
}
return toNumber(toDecimal(userStatsQuery[0].currentUsageLimit))
}
/**
* Check usage status with warning thresholds
*/
export async function checkUsageStatus(userId: string): Promise<{
status: 'ok' | 'warning' | 'exceeded'
usageData: UsageData
}> {
try {
const usageData = await getUserUsageData(userId)
let status: 'ok' | 'warning' | 'exceeded' = 'ok'
if (usageData.isExceeded) {
status = 'exceeded'
} else if (usageData.isWarning) {
status = 'warning'
}
return {
status,
usageData,
}
} catch (error) {
logger.error('Failed to check usage status', { userId, error })
throw error
}
}
/**
* Sync usage limits based on subscription changes
*/
export async function syncUsageLimitsFromSubscription(userId: string): Promise<void> {
const [subscription, currentUserStats] = await Promise.all([
getHighestPrioritySubscription(userId),
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
])
if (currentUserStats.length === 0) {
throw new Error(`User stats not found for userId: ${userId}`)
}
const currentStats = currentUserStats[0]
if (isOrgScopedSubscription(subscription, userId)) {
if (currentStats.currentUsageLimit !== null) {
await db
.update(userStats)
.set({
currentUsageLimit: null,
usageLimitUpdatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
logger.info('Cleared individual limit for org-scoped member', {
userId,
plan: subscription?.plan,
})
}
return
}
const defaultLimit = getPerUserMinimumLimit(subscription)
const currentLimit = currentStats.currentUsageLimit
? toNumber(toDecimal(currentStats.currentUsageLimit))
: 0
if (!subscription || !hasPaidSubscriptionStatus(subscription.status)) {
// Downgraded to free
await db
.update(userStats)
.set({
currentUsageLimit: getFreeTierLimit().toString(),
usageLimitUpdatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
logger.info('Set limit to free tier', { userId })
} else if (currentLimit < defaultLimit) {
await db
.update(userStats)
.set({
currentUsageLimit: defaultLimit.toString(),
usageLimitUpdatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
logger.info('Raised limit to plan minimum', {
userId,
newLimit: defaultLimit,
})
}
// Keep higher custom limits unchanged
}
/**
* Returns the effective current period usage cost for a user, with daily
* refresh credits deducted. Org-scoped subs return the pooled sum across
* all org members; personally-scoped subs return this user's own cost.
*/
export async function getEffectiveCurrentPeriodCost(
userId: string,
executor: DbClient = db
): Promise<number> {
const subscription = await getHighestPrioritySubscription(userId, { executor })
const orgScoped = isOrgScopedSubscription(subscription, userId)
let rawCost: number
let refreshUserIds: string[] = [userId]
if (orgScoped && subscription) {
const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId, executor)
if (pooled.memberIds.length === 0) return 0
refreshUserIds = pooled.memberIds
const billingPeriod =
subscription.periodStart && subscription.periodEnd
? { start: subscription.periodStart, end: subscription.periodEnd }
: defaultBillingPeriod()
rawCost =
pooled.currentPeriodCost +
(await getBillingPeriodUsageCost(
{ type: 'organization', id: subscription.referenceId },
billingPeriod,
undefined,
executor
))
} else {
const rows = await executor
.select({ current: userStats.currentPeriodCost })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (rows.length === 0) return 0
const billingPeriod =
subscription?.periodStart && subscription.periodEnd
? { start: subscription.periodStart, end: subscription.periodEnd }
: defaultBillingPeriod()
rawCost =
toNumber(toDecimal(rows[0].current)) +
(await getBillingPeriodUsageCost(
{ type: 'user', id: userId },
billingPeriod,
undefined,
executor
))
}
if (!subscription || !isPaid(subscription.plan) || !subscription.periodStart) {
return rawCost
}
const planDollars = getPlanTierDollars(subscription.plan)
if (planDollars <= 0) return rawCost
const userBounds =
orgScoped && subscription.periodStart
? await getOrgMemberRefreshBounds(
subscription.referenceId,
subscription.periodStart,
executor
)
: {}
const refreshConsumed = await computeDailyRefreshConsumed(
{
userIds: refreshUserIds,
periodStart: subscription.periodStart,
periodEnd: subscription.periodEnd ?? null,
planDollars,
seats: subscription.seats || 1,
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
billingEntity:
orgScoped && subscription
? { type: 'organization', id: subscription.referenceId }
: { type: 'user', id: userId },
},
executor
)
return Math.max(0, rawCost - refreshConsumed)
}
/**
* Calculate billing projection based on current usage
*/
async function calculateBillingProjection(userId: string): Promise<BillingData> {
try {
const usageData = await getUserUsageData(userId)
if (!usageData.billingPeriodStart || !usageData.billingPeriodEnd) {
return {
currentPeriodCost: usageData.currentUsage,
projectedCost: usageData.currentUsage,
limit: usageData.limit,
billingPeriodStart: null,
billingPeriodEnd: null,
daysRemaining: 0,
}
}
const now = new Date()
const periodStart = new Date(usageData.billingPeriodStart)
const periodEnd = new Date(usageData.billingPeriodEnd)
const totalDays = Math.ceil(
(periodEnd.getTime() - periodStart.getTime()) / (1000 * 60 * 60 * 24)
)
const daysElapsed = Math.ceil((now.getTime() - periodStart.getTime()) / (1000 * 60 * 60 * 24))
const daysRemaining = Math.max(0, totalDays - daysElapsed)
// Project cost based on daily usage rate
const dailyRate = daysElapsed > 0 ? usageData.currentUsage / daysElapsed : 0
const projectedCost = dailyRate * totalDays
return {
currentPeriodCost: usageData.currentUsage,
projectedCost: Math.min(projectedCost, usageData.limit), // Cap at limit
limit: usageData.limit,
billingPeriodStart: usageData.billingPeriodStart,
billingPeriodEnd: usageData.billingPeriodEnd,
daysRemaining,
}
} catch (error) {
logger.error('Failed to calculate billing projection', { userId, error })
throw error
}
}
/**
* Send usage threshold notification when crossing from <80% to ≥80%.
* - Skips when billing is disabled.
* - Respects user-level notifications toggle and unsubscribe preferences.
* - For organization plans, emails owners/admins who have notifications enabled.
*/
export async function maybeSendUsageThresholdEmail(params: {
scope: 'user' | 'organization'
planName: string
percentBefore: number
percentAfter: number
userId?: string
userEmail?: string
userName?: string
organizationId?: string
/** Workspace the usage occurred in, used to build a live upgrade/billing link. */
workspaceId?: string
currentUsageAfter: number
limit: number
}): Promise<void> {
try {
if (!isBillingEnabled) return
if (params.limit <= 0 || params.currentUsageAfter <= 0) return
const baseUrl = getBaseUrl()
const isFreeUser = params.planName === 'Free'
const upgradeCreditsLink = params.workspaceId
? `${baseUrl}${buildUpgradeHref(params.workspaceId, 'credits')}`
: `${baseUrl}/workspace`
const billingSettingsLink = params.workspaceId
? `${baseUrl}/workspace/${params.workspaceId}/settings/billing`
: `${baseUrl}/workspace`
// Check for 80% threshold crossing — used for paid users (budget warning) and free users (upgrade nudge)
const crosses80 = params.percentBefore < 80 && params.percentAfter >= 80
// Check for 100% threshold (free users only — credits exhausted)
const crosses100 = params.percentBefore < 100 && params.percentAfter >= 100
// Skip if no thresholds crossed
if (!crosses80 && !crosses100) return
// For 80% threshold email (paid users only)
if (crosses80 && !isFreeUser) {
const ctaLink = billingSettingsLink
const sendTo = async (email: string, name?: string) => {
const prefs = await getEmailPreferences(email)
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
const html = await renderUsageThresholdEmail({
userName: name,
planName: params.planName,
percentUsed: Math.min(100, Math.round(params.percentAfter)),
currentUsage: params.currentUsageAfter,
limit: params.limit,
ctaLink,
})
await sendEmail({
to: email,
subject: getEmailSubject('usage-threshold'),
html,
emailType: 'notifications',
})
}
if (params.scope === 'user' && params.userId && params.userEmail) {
const rows = await db
.select({ enabled: settings.billingUsageNotificationsEnabled })
.from(settings)
.where(eq(settings.userId, params.userId))
.limit(1)
if (rows.length > 0 && rows[0].enabled === false) return
await sendTo(params.userEmail, params.userName)
} else if (params.scope === 'organization' && params.organizationId) {
const admins = await db
.select({
email: user.email,
name: user.name,
enabled: settings.billingUsageNotificationsEnabled,
role: member.role,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(settings, eq(settings.userId, member.userId))
.where(eq(member.organizationId, params.organizationId))
for (const a of admins) {
const isAdmin = isOrgAdminRole(a.role)
if (!isAdmin) continue
if (a.enabled === false) continue
if (!a.email) continue
await sendTo(a.email, a.name || undefined)
}
}
}
// For 80% threshold email (free users only — skip if they also crossed 100% in same call)
if (crosses80 && isFreeUser && !crosses100) {
const upgradeLink = upgradeCreditsLink
const sendFreeTierEmail = async (email: string, name?: string) => {
const prefs = await getEmailPreferences(email)
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
const html = await renderFreeTierUpgradeEmail({
userName: name,
percentUsed: Math.min(100, Math.round(params.percentAfter)),
currentUsage: params.currentUsageAfter,
limit: params.limit,
upgradeLink,
})
await sendEmail({
to: email,
subject: getEmailSubject('free-tier-upgrade'),
html,
emailType: 'notifications',
})
logger.info('Free tier upgrade email sent', {
email,
percentUsed: Math.round(params.percentAfter),
currentUsage: params.currentUsageAfter,
limit: params.limit,
})
}
// Free users are always individual scope (not organization)
if (params.scope === 'user' && params.userId && params.userEmail) {
const rows = await db
.select({ enabled: settings.billingUsageNotificationsEnabled })
.from(settings)
.where(eq(settings.userId, params.userId))
.limit(1)
if (rows.length > 0 && rows[0].enabled === false) return
await sendFreeTierEmail(params.userEmail, params.userName)
}
}
// For 100% threshold email (free users only — credits exhausted)
if (crosses100 && isFreeUser) {
const upgradeLink = upgradeCreditsLink
const sendExhaustedEmail = async (email: string, name?: string) => {
const prefs = await getEmailPreferences(email)
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
const html = await renderCreditsExhaustedEmail({
userName: name,
limit: params.limit,
upgradeLink,
})
await sendEmail({
to: email,
subject: getEmailSubject('free-tier-exhausted'),
html,
emailType: 'notifications',
})
logger.info('Free tier credits exhausted email sent', {
email,
currentUsage: params.currentUsageAfter,
limit: params.limit,
})
}
if (params.scope === 'user' && params.userId && params.userEmail) {
const rows = await db
.select({ enabled: settings.billingUsageNotificationsEnabled })
.from(settings)
.where(eq(settings.userId, params.userId))
.limit(1)
if (rows.length > 0 && rows[0].enabled === false) return
await sendExhaustedEmail(params.userEmail, params.userName)
}
}
} catch (error) {
logger.error('Failed to send usage threshold email', {
scope: params.scope,
userId: params.userId,
organizationId: params.organizationId,
error,
})
}
}