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

736 lines
26 KiB
TypeScript

import { db } from '@sim/db'
import { member, organization, subscription, userStats } from '@sim/db/schema'
import { and, desc, eq, inArray } from 'drizzle-orm'
import {
getHighestPrioritySubscription,
resolveBillingInterval,
} from '@/lib/billing/core/subscription'
import { getOrgUsageLimit, getUserUsageData } from '@/lib/billing/core/usage'
import { COPILOT_USAGE_SOURCES, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
import { getCreditBalance } from '@/lib/billing/credits/balance'
import {
computeDailyRefreshConsumed,
getOrgMemberRefreshBounds,
} from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
import {
ENTITLED_SUBSCRIPTION_STATUSES,
getFreeTierLimit,
getPlanPricing,
hasPaidSubscriptionStatus,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import type { DbClient } from '@/lib/db/types'
export { getPlanPricing }
import { createLogger } from '@sim/logger'
const logger = createLogger('Billing')
interface GetOrganizationSubscriptionOptions {
onError?: 'return-null' | 'throw'
/** Read-routing client (primary or replica); defaults to the primary. */
executor?: DbClient
}
/**
* Get the organization's subscription row when its status is one of
* `ENTITLED_SUBSCRIPTION_STATUSES` (includes `past_due`). Use this
* when making billing-side decisions (overage math, limit reads,
* webhooks) where `past_due` still counts as an active paid tenant.
* For product-access gating use `getOrganizationSubscriptionUsable`
* (from `core/subscription.ts`), which excludes `past_due`.
* Returns `null` when there is no entitled sub.
*
* `options.executor` exists for replica routing on display/summary read
* paths only. Enforcement and webhook callers must read the primary —
* omit the executor (or pass `db`).
*/
export async function getOrganizationSubscription(
organizationId: string,
options: GetOrganizationSubscriptionOptions = {}
) {
const { onError = 'return-null', executor = db } = options
try {
const orgSubs = await executor
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.orderBy(desc(subscription.periodStart), desc(subscription.id))
.limit(1)
return orgSubs.length > 0 ? orgSubs[0] : null
} catch (error) {
logger.error('Error getting organization subscription', { error, organizationId })
if (onError === 'throw') {
throw error
}
return null
}
}
/**
* BILLING MODEL:
* 1. User purchases $20 Pro plan → Gets charged $20 immediately via Stripe subscription
* 2. User uses $15 during the month → No additional charge (covered by $20)
* 3. User uses $35 during the month → Gets charged $15 overage at month end
* 4. Usage resets, next month they pay $20 again + any overages
*/
/**
* Check if a subscription is scoped to an organization by looking up its
* `referenceId` in the organization table. This is the authoritative
* answer — the plan name alone is unreliable because `pro_*` plans can be
* attached to organizations (and we should treat them as org-scoped).
*
* Use this in server contexts (webhooks, jobs) where we only have the
* subscription row, not a user perspective. If you do have a user id,
* `isOrgScopedSubscription(sub, userId)` is cheaper and equally correct.
*/
export async function isSubscriptionOrgScoped(sub: { referenceId: string }): Promise<boolean> {
const rows = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, sub.referenceId))
.limit(1)
return rows.length > 0
}
/**
* Aggregate raw pooled stats for all members of an organization in a single
* query. Used by org-scoped summary and overage calculations so we don't
* call `getUserUsageData` per-member — that helper now returns the entire
* pool for org-scoped subs, which would N-times-count the usage.
*
* The `currentPeriodCost` sum here is semantically identical to
* `getPooledOrgCurrentPeriodCost` (same `LEFT JOIN` + `toDecimal`
* null handling); this helper bundles the copilot fields in the same
* round-trip. Never fall back to lifetime `totalCost` on nulls — the
* column is `NOT NULL DEFAULT '0'` and mixing scopes would break
* current-period billing math.
*/
async function aggregateOrgMemberStats(
organizationId: string,
executor: DbClient = db
): Promise<{
memberIds: string[]
currentPeriodCost: number
currentPeriodCopilotCost: number
lastPeriodCopilotCost: number
}> {
const rows = await executor
.select({
userId: member.userId,
currentPeriodCost: userStats.currentPeriodCost,
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
})
.from(member)
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
let currentPeriodCost = new Decimal(0)
// Copilot baseline (copilot source). All copilot-family usage (incl. MCP) lives
// in usage_log and is added via the copilot ledger by callers — not a baseline.
let currentPeriodCopilotCost = new Decimal(0)
let lastPeriodCopilotCost = new Decimal(0)
const memberIds: string[] = []
for (const row of rows) {
memberIds.push(row.userId)
currentPeriodCost = currentPeriodCost.plus(toDecimal(row.currentPeriodCost))
currentPeriodCopilotCost = currentPeriodCopilotCost.plus(
toDecimal(row.currentPeriodCopilotCost)
)
lastPeriodCopilotCost = lastPeriodCopilotCost.plus(toDecimal(row.lastPeriodCopilotCost))
}
return {
memberIds,
currentPeriodCost: toNumber(currentPeriodCost),
currentPeriodCopilotCost: toNumber(currentPeriodCopilotCost),
lastPeriodCopilotCost: toNumber(lastPeriodCopilotCost),
}
}
/**
* Compute an org's overage amount from already-fetched pool/departed
* inputs. Internally performs one daily-refresh DB read to subtract
* refresh credits; callers are expected to have already loaded the
* pooled `currentPeriodCost` and `departedMemberUsage` (threshold
* billing passes lock-held values; `calculateSubscriptionOverage`
* passes lockless values from `aggregateOrgMemberStats`). Both
* callers route through this to keep the overage math in one place.
*/
export async function computeOrgOverageAmount(params: {
plan: string | null
seats: number | null
periodStart: Date | null
periodEnd: Date | null
organizationId: string
pooledCurrentPeriodCost: number
departedMemberUsage: number
memberIds: string[]
}): Promise<{
effectiveUsage: number
baseSubscriptionAmount: number
dailyRefreshDeduction: number
totalOverage: number
}> {
const totalUsage = params.pooledCurrentPeriodCost + params.departedMemberUsage
let dailyRefreshDeduction = 0
const planDollars = getPlanTierDollars(params.plan)
if (planDollars > 0 && params.periodStart && params.memberIds.length > 0) {
const userBounds = await getOrgMemberRefreshBounds(params.organizationId, params.periodStart)
dailyRefreshDeduction = await computeDailyRefreshConsumed({
userIds: params.memberIds,
periodStart: params.periodStart,
periodEnd: params.periodEnd ?? null,
planDollars,
seats: params.seats || 1,
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
billingEntity: { type: 'organization', id: params.organizationId },
})
}
const effectiveUsage = Math.max(0, totalUsage - dailyRefreshDeduction)
const { basePrice } = getPlanPricing(params.plan ?? '')
const baseSubscriptionAmount = (params.seats || 1) * basePrice
const totalOverage = Math.max(0, effectiveUsage - baseSubscriptionAmount)
return { effectiveUsage, baseSubscriptionAmount, dailyRefreshDeduction, totalOverage }
}
/**
* Calculate overage amount for a subscription
* Shared logic between invoice.finalized and customer.subscription.deleted handlers
*/
export async function calculateSubscriptionOverage(sub: {
id: string
plan: string | null
referenceId: string
seats?: number | null
periodStart?: Date | null
periodEnd?: Date | null
}): Promise<number> {
// Enterprise plans have no overages
if (isEnterprise(sub.plan)) {
logger.info('Enterprise plan has no overages', {
subscriptionId: sub.id,
plan: sub.plan,
})
return 0
}
let totalOverageDecimal = new Decimal(0)
const isOrgScoped = await isSubscriptionOrgScoped(sub)
if (isOrgScoped) {
const pooled = await aggregateOrgMemberStats(sub.referenceId)
const ledgerUsage =
sub.periodStart && sub.periodEnd
? await getBillingPeriodUsageCost(
{ type: 'organization', id: sub.referenceId },
{ start: sub.periodStart, end: sub.periodEnd }
)
: 0
const orgData = await db
.select({ departedMemberUsage: organization.departedMemberUsage })
.from(organization)
.where(eq(organization.id, sub.referenceId))
.limit(1)
const departedMemberUsage =
orgData.length > 0 ? toNumber(toDecimal(orgData[0].departedMemberUsage)) : 0
const { totalOverage, effectiveUsage, baseSubscriptionAmount } = await computeOrgOverageAmount({
plan: sub.plan,
seats: sub.seats ?? null,
periodStart: sub.periodStart ?? null,
periodEnd: sub.periodEnd ?? null,
organizationId: sub.referenceId,
pooledCurrentPeriodCost: pooled.currentPeriodCost + ledgerUsage,
departedMemberUsage,
memberIds: pooled.memberIds,
})
totalOverageDecimal = toDecimal(totalOverage)
logger.info('Calculated org-scoped overage', {
subscriptionId: sub.id,
plan: sub.plan,
currentMemberUsage: pooled.currentPeriodCost + ledgerUsage,
departedMemberUsage,
ledgerUsage,
totalUsage: pooled.currentPeriodCost + ledgerUsage + departedMemberUsage,
effectiveUsage,
baseSubscriptionAmount,
totalOverage,
})
} else if (isPro(sub.plan)) {
// Read user_stats directly (not via `getUserUsageData`). Priority
// lookup prefers org over personal within tier, so during a
// cancel-at-period-end grace window it would return pooled org usage
// instead of this user's personal period — overbilling the final
// personal Pro invoice.
const [statsRow] = await db
.select({
currentPeriodCost: userStats.currentPeriodCost,
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
proPeriodCostSnapshotAt: userStats.proPeriodCostSnapshotAt,
})
.from(userStats)
.where(eq(userStats.userId, sub.referenceId))
.limit(1)
const personalCurrentUsage = statsRow ? toNumber(toDecimal(statsRow.currentPeriodCost)) : 0
const snapshotUsage = statsRow ? toNumber(toDecimal(statsRow.proPeriodCostSnapshot)) : 0
const snapshotAt = statsRow?.proPeriodCostSnapshotAt ?? null
const ledgerUsage =
sub.periodStart && sub.periodEnd
? await getBillingPeriodUsageCost(
{ type: 'user', id: sub.referenceId },
{ start: sub.periodStart, end: sub.periodEnd }
)
: 0
const joinedOrgMidCycle = snapshotAt !== null || snapshotUsage > 0
const totalProUsageDecimal = joinedOrgMidCycle
? toDecimal(snapshotUsage).plus(ledgerUsage)
: toDecimal(personalCurrentUsage).plus(ledgerUsage)
if (joinedOrgMidCycle) {
logger.info('Billing personal Pro only for pre-join usage (user joined org mid-cycle)', {
userId: sub.referenceId,
preJoinUsage: snapshotUsage,
postJoinUsageOnMemberRow: personalCurrentUsage,
snapshotAt: snapshotAt?.toISOString() ?? null,
subscriptionId: sub.id,
})
}
let dailyRefreshDeduction = 0
const planDollars = getPlanTierDollars(sub.plan)
if (planDollars > 0 && sub.periodStart) {
// If the user joined an org mid-cycle, their usageLog rows after
// `snapshotAt` belong to the org's pooled refresh. Cap refresh
// to [periodStart, snapshotAt) so post-join refresh isn't
// deducted from pre-join personal Pro usage.
const refreshCap = joinedOrgMidCycle && snapshotAt ? snapshotAt : (sub.periodEnd ?? null)
dailyRefreshDeduction = await computeDailyRefreshConsumed({
userIds: [sub.referenceId],
periodStart: sub.periodStart,
periodEnd: refreshCap,
planDollars,
billingEntity: { type: 'user', id: sub.referenceId },
})
}
const effectiveUsageDecimal = Decimal.max(
0,
totalProUsageDecimal.minus(toDecimal(dailyRefreshDeduction))
)
const { basePrice } = getPlanPricing(sub.plan ?? '')
totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(basePrice))
logger.info('Calculated personal pro overage', {
subscriptionId: sub.id,
joinedOrgMidCycle,
personalCurrentUsage,
snapshot: snapshotUsage,
ledgerUsage,
billedUsage: toNumber(totalProUsageDecimal),
dailyRefreshDeduction,
basePrice,
totalOverage: toNumber(totalOverageDecimal),
})
} else {
// Free or unknown plan. Same direct-read rationale as the Pro branch.
const [statsRow] = await db
.select({ currentPeriodCost: userStats.currentPeriodCost })
.from(userStats)
.where(eq(userStats.userId, sub.referenceId))
.limit(1)
const personalCurrentUsage = statsRow ? toNumber(toDecimal(statsRow.currentPeriodCost)) : 0
const ledgerUsage =
sub.periodStart && sub.periodEnd
? await getBillingPeriodUsageCost(
{ type: 'user', id: sub.referenceId },
{ start: sub.periodStart, end: sub.periodEnd }
)
: 0
const { basePrice } = getPlanPricing(sub.plan || 'free')
totalOverageDecimal = Decimal.max(
0,
toDecimal(personalCurrentUsage).plus(ledgerUsage).minus(basePrice)
)
logger.info('Calculated overage for plan', {
subscriptionId: sub.id,
plan: sub.plan || 'free',
usage: personalCurrentUsage + ledgerUsage,
ledgerUsage,
basePrice,
totalOverage: toNumber(totalOverageDecimal),
})
}
return toNumber(totalOverageDecimal)
}
/**
* Get comprehensive billing and subscription summary
*/
export async function getSimplifiedBillingSummary(
userId: string,
organizationId?: string,
executor: DbClient = db
): Promise<{
type: 'individual' | 'organization'
plan: string
currentUsage: number
usageLimit: number
percentUsed: number
isWarning: boolean
isExceeded: boolean
daysRemaining: number
creditBalance: number
billingInterval: 'month' | 'year'
// Subscription details
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
/** True when the subscription's `referenceId` is an organization id. */
isOrgScoped: boolean
/** Present when `isOrgScoped` is true. */
organizationId: string | null
status: string | null
seats: number | null
metadata: any
stripeSubscriptionId: string | null
periodEnd: Date | string | null
cancelAtPeriodEnd?: boolean
// Usage details
usage: {
current: number
limit: number
percentUsed: number
isWarning: boolean
isExceeded: boolean
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
lastPeriodCost: number
lastPeriodCopilotCost: number
daysRemaining: number
copilotCost: number
}
}> {
try {
// Get subscription and usage data upfront
const [subscription, usageData] = await Promise.all([
organizationId
? getOrganizationSubscription(organizationId, { executor })
: getHighestPrioritySubscription(userId, { executor }),
getUserUsageData(userId, executor),
])
const plan = subscription?.plan || 'free'
const hasPaidEntitlement = hasPaidSubscriptionStatus(subscription?.status)
const planIsPaid = hasPaidEntitlement && isPaid(plan)
const planIsPro = hasPaidEntitlement && isPro(plan)
const planIsTeam = hasPaidEntitlement && isTeam(plan)
const planIsEnterprise = hasPaidEntitlement && isEnterprise(plan)
const orgScoped = isOrgScopedSubscription(subscription, userId)
const subscriptionOrgId = orgScoped && subscription ? subscription.referenceId : null
if (organizationId) {
// Organization billing summary
if (!subscription) {
return getDefaultBillingSummary('organization')
}
// Pool usage/copilot across all members in one query. Must not use
// `getUserUsageData` per-member — it now returns the pool itself
// for org-scoped subs, which would N-times-count.
const pooled = await aggregateOrgMemberStats(organizationId, executor)
const rawCurrentUsage = pooled.currentPeriodCost
const totalLastPeriodCopilotCost = pooled.lastPeriodCopilotCost
// Deduct daily-refresh credits against this specific org's pool.
// `usageData` is derived from the caller's priority subscription
// and may not match the requested org (multi-org admins, personal
// priority sub, etc.), so it cannot be reused here.
const orgBillingPeriod =
subscription.periodStart && subscription.periodEnd
? { start: subscription.periodStart, end: subscription.periodEnd }
: null
const ledgerUsage = orgBillingPeriod
? await getBillingPeriodUsageCost(
{ type: 'organization', id: organizationId },
orgBillingPeriod,
undefined,
executor
)
: 0
// Copilot breakdown = member baselines (copilot + MCP) + the copilot-family
// ledger for the period (COPILOT_USAGE_SOURCES: copilot/workspace-chat/
// mcp_copilot/mothership_block); the baseline columns are no longer incremented.
const totalCopilotCost =
pooled.currentPeriodCopilotCost +
(orgBillingPeriod
? await getBillingPeriodUsageCost(
{ type: 'organization', id: organizationId },
orgBillingPeriod,
COPILOT_USAGE_SOURCES,
executor
)
: 0)
let refreshDeduction = 0
if (isPaid(plan) && subscription.periodStart) {
const planDollars = getPlanTierDollars(plan)
if (planDollars > 0) {
const userBounds = await getOrgMemberRefreshBounds(
organizationId,
subscription.periodStart,
executor
)
refreshDeduction = await computeDailyRefreshConsumed(
{
userIds: pooled.memberIds,
periodStart: subscription.periodStart,
periodEnd: subscription.periodEnd ?? null,
planDollars,
seats: subscription.seats || 1,
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
billingEntity: { type: 'organization', id: organizationId },
},
executor
)
}
}
const effectiveCurrentUsage = Math.max(0, rawCurrentUsage + ledgerUsage - refreshDeduction)
const { limit: orgUsageLimit } = await getOrgUsageLimit(
organizationId,
plan,
subscription.seats ?? null,
executor
)
const percentUsed =
orgUsageLimit > 0 ? Math.round((effectiveCurrentUsage / orgUsageLimit) * 100) : 0
const isExceeded = effectiveCurrentUsage >= orgUsageLimit
const isWarning = !isExceeded && percentUsed >= 80
// Calculate days remaining in billing period
const daysRemaining = subscription.periodEnd
? Math.max(
0,
Math.ceil((subscription.periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
)
: 0
const orgCredits = await getCreditBalance(userId, executor)
const orgBillingInterval = resolveBillingInterval(subscription)
return {
type: 'organization',
plan: subscription.plan,
currentUsage: effectiveCurrentUsage,
usageLimit: orgUsageLimit,
percentUsed,
isWarning,
isExceeded,
daysRemaining,
creditBalance: orgCredits.balance,
billingInterval: orgBillingInterval,
// Subscription details
isPaid: planIsPaid,
isPro: planIsPro,
isTeam: planIsTeam,
isEnterprise: planIsEnterprise,
isOrgScoped: true,
organizationId: organizationId,
status: subscription.status || null,
seats: subscription.seats || null,
metadata: subscription.metadata || null,
stripeSubscriptionId: subscription.stripeSubscriptionId || null,
periodEnd: subscription.periodEnd || null,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || undefined,
// Usage details
usage: {
current: effectiveCurrentUsage,
limit: orgUsageLimit,
percentUsed,
isWarning,
isExceeded,
billingPeriodStart: subscription.periodStart ?? null,
billingPeriodEnd: subscription.periodEnd ?? null,
lastPeriodCost: usageData.lastPeriodCost,
lastPeriodCopilotCost: totalLastPeriodCopilotCost,
daysRemaining,
copilotCost: totalCopilotCost,
},
}
}
const userStatsRows = await executor
.select({
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
// Copilot baseline (copilot source). MCP copilot usage lives in usage_log and
// is added via the copilot ledger below, not a userStats baseline.
const copilotCost =
userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].currentPeriodCopilotCost)) : 0
const lastPeriodCopilotCost =
userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].lastPeriodCopilotCost)) : 0
const currentUsage = usageData.currentUsage
let totalCopilotCost = copilotCost
let totalLastPeriodCopilotCost = lastPeriodCopilotCost
if (orgScoped && subscription?.referenceId) {
const pooled = await aggregateOrgMemberStats(subscription.referenceId, executor)
totalCopilotCost = pooled.currentPeriodCopilotCost
totalLastPeriodCopilotCost = pooled.lastPeriodCopilotCost
}
// Add the copilot-family ledger (COPILOT_USAGE_SOURCES: copilot/workspace-chat/
// mcp_copilot/mothership_block) on top of the baseline; those columns are no
// longer incremented per usage.
const copilotBillingPeriod =
usageData.billingPeriodStart && usageData.billingPeriodEnd
? { start: usageData.billingPeriodStart, end: usageData.billingPeriodEnd }
: null
if (copilotBillingPeriod) {
const copilotEntity =
orgScoped && subscription?.referenceId
? ({ type: 'organization', id: subscription.referenceId } as const)
: ({ type: 'user', id: userId } as const)
totalCopilotCost += await getBillingPeriodUsageCost(
copilotEntity,
copilotBillingPeriod,
COPILOT_USAGE_SOURCES,
executor
)
}
const percentUsed = usageData.limit > 0 ? (currentUsage / usageData.limit) * 100 : 0
const daysRemaining = usageData.billingPeriodEnd
? Math.max(
0,
Math.ceil((usageData.billingPeriodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
)
: 0
const userCredits = await getCreditBalance(userId, executor)
const individualBillingInterval = resolveBillingInterval(subscription)
return {
type: 'individual',
plan,
currentUsage,
usageLimit: usageData.limit,
percentUsed,
isWarning: percentUsed >= 80 && percentUsed < 100,
isExceeded: currentUsage >= usageData.limit,
daysRemaining,
creditBalance: userCredits.balance,
billingInterval: individualBillingInterval,
// Subscription details
isPaid: planIsPaid,
isPro: planIsPro,
isTeam: planIsTeam,
isEnterprise: planIsEnterprise,
isOrgScoped: orgScoped,
organizationId: subscriptionOrgId,
status: subscription?.status || null,
seats: subscription?.seats || null,
metadata: subscription?.metadata || null,
stripeSubscriptionId: subscription?.stripeSubscriptionId || null,
periodEnd: subscription?.periodEnd || null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || undefined,
// Usage details
usage: {
current: currentUsage,
limit: usageData.limit,
percentUsed,
isWarning: percentUsed >= 80 && percentUsed < 100,
isExceeded: currentUsage >= usageData.limit,
billingPeriodStart: usageData.billingPeriodStart,
billingPeriodEnd: usageData.billingPeriodEnd,
lastPeriodCost: usageData.lastPeriodCost,
lastPeriodCopilotCost: totalLastPeriodCopilotCost,
daysRemaining,
copilotCost: totalCopilotCost,
},
}
} catch (error) {
logger.error('Failed to get simplified billing summary', { userId, organizationId, error })
return getDefaultBillingSummary(organizationId ? 'organization' : 'individual')
}
}
/**
* Get default billing summary for error cases
*/
function getDefaultBillingSummary(type: 'individual' | 'organization') {
const freeTierLimit = getFreeTierLimit()
return {
type,
plan: 'free',
currentUsage: 0,
usageLimit: freeTierLimit,
percentUsed: 0,
isWarning: false,
isExceeded: false,
daysRemaining: 0,
creditBalance: 0,
billingInterval: 'month' as const,
// Subscription details
isPaid: false,
isPro: false,
isTeam: false,
isEnterprise: false,
isOrgScoped: false,
organizationId: null,
status: null,
seats: null,
metadata: null,
stripeSubscriptionId: null,
periodEnd: null,
// Usage details
usage: {
current: 0,
limit: freeTierLimit,
percentUsed: 0,
isWarning: false,
isExceeded: false,
billingPeriodStart: null,
billingPeriodEnd: null,
lastPeriodCost: 0,
lastPeriodCopilotCost: 0,
daysRemaining: 0,
copilotCost: 0,
},
}
}