chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user