d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
1002 lines
32 KiB
TypeScript
1002 lines
32 KiB
TypeScript
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,
|
||
})
|
||
}
|
||
}
|