chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
import { db } from '@sim/db'
import { member, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
const logger = createLogger('BillingAccess')
export interface EffectiveBillingStatus {
billingBlocked: boolean
billingBlockedReason: 'payment_failed' | 'dispute' | null
blockedByOrgOwner: boolean
}
/**
* Gets the effective billing blocked status for a user.
* If the user belongs to an organization, also checks whether the org owner is blocked.
*/
export async function getEffectiveBillingStatus(userId: string): Promise<EffectiveBillingStatus> {
const userStatsRows = await db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
const userBlocked = userStatsRows.length > 0 ? !!userStatsRows[0].blocked : false
const userBlockedReason = userStatsRows.length > 0 ? userStatsRows[0].blockedReason : null
if (userBlocked) {
return {
billingBlocked: true,
billingBlockedReason: userBlockedReason,
blockedByOrgOwner: false,
}
}
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
const ownerResults = await Promise.all(
memberships.map((m) =>
db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
.limit(1)
)
)
const otherOwnerIds = ownerResults
.filter((owners) => owners.length > 0 && owners[0].userId !== userId)
.map((owners) => owners[0].userId)
if (otherOwnerIds.length > 0) {
const ownerStatsResults = await Promise.all(
otherOwnerIds.map((ownerId) =>
db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, ownerId))
.limit(1)
)
)
for (const stats of ownerStatsResults) {
if (stats.length > 0 && stats[0].blocked) {
return {
billingBlocked: true,
billingBlockedReason: stats[0].blockedReason,
blockedByOrgOwner: true,
}
}
}
}
return {
billingBlocked: false,
billingBlockedReason: null,
blockedByOrgOwner: false,
}
}
export async function isOrganizationBillingBlocked(organizationId: string): Promise<boolean> {
const [owner] = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
.limit(1)
if (!owner) {
logger.error(
'Organization has no owner when checking billing-blocked state — data integrity issue',
{ organizationId }
)
return false
}
const [ownerStats] = await db
.select({ blocked: userStats.billingBlocked })
.from(userStats)
.where(eq(userStats.userId, owner.userId))
.limit(1)
return !!ownerStats?.blocked
}
@@ -0,0 +1,108 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetHighestPrioritySubscription, mockGetWorkspaceBilledAccountUserId, billingState } =
vi.hoisted(() => ({
mockGetHighestPrioritySubscription: vi.fn(),
mockGetWorkspaceBilledAccountUserId: vi.fn(),
billingState: { isBillingEnabled: true, isFreeApiDeploymentGateEnabled: true },
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isBillingEnabled() {
return billingState.isBillingEnabled
},
get isFreeApiDeploymentGateEnabled() {
return billingState.isFreeApiDeploymentGateEnabled
},
}))
vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
}))
vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
}))
import {
isApiExecutionEntitled,
isWorkspaceApiExecutionEntitled,
} from '@/lib/billing/core/api-access'
describe('isApiExecutionEntitled', () => {
beforeEach(() => {
vi.clearAllMocks()
billingState.isBillingEnabled = true
billingState.isFreeApiDeploymentGateEnabled = true
})
it('is false for a free plan', async () => {
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'free' })
expect(await isApiExecutionEntitled('user-1')).toBe(false)
})
it('is false when there is no subscription', async () => {
mockGetHighestPrioritySubscription.mockResolvedValue(null)
expect(await isApiExecutionEntitled('user-1')).toBe(false)
})
it.each(['pro', 'pro_6000', 'team', 'team_25000', 'enterprise'])(
'is true for paid plan %s',
async (plan) => {
mockGetHighestPrioritySubscription.mockResolvedValue({ plan })
expect(await isApiExecutionEntitled('user-1')).toBe(true)
}
)
it('is true on self-hosted regardless of plan, without a subscription lookup', async () => {
billingState.isBillingEnabled = false
expect(await isApiExecutionEntitled('user-1')).toBe(true)
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})
it('is true (gate off) when the feature flag is disabled, even with billing on', async () => {
billingState.isFreeApiDeploymentGateEnabled = false
expect(await isApiExecutionEntitled('user-1')).toBe(true)
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})
it('is true when userId is missing', async () => {
expect(await isApiExecutionEntitled(undefined)).toBe(true)
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})
})
describe('isWorkspaceApiExecutionEntitled', () => {
beforeEach(() => {
vi.clearAllMocks()
billingState.isBillingEnabled = true
billingState.isFreeApiDeploymentGateEnabled = true
})
it('is false when the workspace billed account is free', async () => {
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('owner-1')
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'free' })
expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(false)
})
it('is true when the workspace billed account is paid', async () => {
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('owner-1')
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'team_6000' })
expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true)
})
it('skips the billed-account lookup on self-hosted', async () => {
billingState.isBillingEnabled = false
expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true)
expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
})
it('skips the lookup (gate off) when the feature flag is disabled', async () => {
billingState.isFreeApiDeploymentGateEnabled = false
expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true)
expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
})
})
+45
View File
@@ -0,0 +1,45 @@
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isPaid } from '@/lib/billing/plan-helpers'
import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
/** The programmatic-execution paywall is active only when billing is enforced AND the gate flag is on. */
function isApiExecutionGateActive(): boolean {
return isBillingEnabled && isFreeApiDeploymentGateEnabled
}
/**
* Message for the 402 returned when a free-plan account attempts programmatic
* workflow execution (API key, public API, or MCP server).
*/
export const API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE =
'Programmatic workflow execution requires a paid plan. Upgrade to Pro or higher to use the API.'
/**
* Whether `userId` may run workflows programmatically. Always allowed when
* billing enforcement is off (self-hosted / `BILLING_ENABLED` unset) and when no
* user is resolved; otherwise requires a paid plan.
*
* `getHighestPrioritySubscription` rolls up organization memberships, so a free
* individual belonging to a paid org/workspace is entitled.
*/
export async function isApiExecutionEntitled(userId: string | undefined): Promise<boolean> {
if (!isApiExecutionGateActive() || !userId) return true
const subscription = await getHighestPrioritySubscription(userId)
return isPaid(subscription?.plan)
}
/**
* Workspace-scoped variant of {@link isApiExecutionEntitled} that gates on the
* workspace's billed account. Short-circuits when billing is off before any DB
* lookup, so the billed-account query only runs when billing is enforced.
*/
export async function isWorkspaceApiExecutionEntitled(
workspaceId: string | undefined
): Promise<boolean> {
if (!isApiExecutionGateActive() || !workspaceId) return true
const billedUserId = await getWorkspaceBilledAccountUserId(workspaceId)
return isApiExecutionEntitled(billedUserId ?? undefined)
}
@@ -0,0 +1,9 @@
const OPEN_BILLING_PERIOD_START = new Date(0)
const OPEN_BILLING_PERIOD_END = new Date(Date.UTC(9999, 11, 31))
export function defaultBillingPeriod(): { start: Date; end: Date } {
return {
start: OPEN_BILLING_PERIOD_START,
end: OPEN_BILLING_PERIOD_END,
}
}
+735
View File
@@ -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,
},
}
}
@@ -0,0 +1,169 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
billingFlag,
mockClaim,
mockSelectRows,
dbUpdateSpy,
sendEmailSpy,
getEmailPreferencesMock,
renderMock,
subjectMock,
isOrgAdminRoleMock,
} = vi.hoisted(() => ({
billingFlag: { enabled: true },
mockClaim: vi.fn<[], unknown[]>(() => [{ id: 'u1' }]),
mockSelectRows: vi.fn<[], unknown[]>(() => []),
dbUpdateSpy: vi.fn(),
sendEmailSpy: vi.fn(() => Promise.resolve({ success: true })),
getEmailPreferencesMock: vi.fn(() => Promise.resolve(null as unknown)),
renderMock: vi.fn(() => Promise.resolve('<html></html>')),
subjectMock: vi.fn(() => 'Subject'),
isOrgAdminRoleMock: vi.fn(() => true),
}))
vi.mock('@sim/db', () => {
const updateBuilder: Record<string, unknown> = {
set: () => updateBuilder,
where: () => updateBuilder,
returning: () => Promise.resolve(mockClaim()),
then: (f: (v: unknown) => unknown, r?: (e: unknown) => unknown) =>
Promise.resolve(undefined).then(f, r),
}
const selectBuilder: Record<string, unknown> = {
from: () => selectBuilder,
where: () => selectBuilder,
innerJoin: () => selectBuilder,
leftJoin: () => selectBuilder,
limit: () => Promise.resolve(mockSelectRows()),
then: (f: (v: unknown) => unknown, r?: (e: unknown) => unknown) =>
Promise.resolve(mockSelectRows()).then(f, r),
}
dbUpdateSpy.mockImplementation(() => updateBuilder)
return { db: { update: dbUpdateSpy, select: () => selectBuilder } }
})
vi.mock('@/lib/core/config/env-flags', () => ({
get isBillingEnabled() {
return billingFlag.enabled
},
}))
vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://app.sim.ai' }))
vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: sendEmailSpy }))
vi.mock('@/lib/messaging/email/unsubscribe', () => ({
getEmailPreferences: getEmailPreferencesMock,
}))
vi.mock('@/components/emails/render', () => ({
renderLimitThresholdEmail: renderMock,
getLimitEmailSubject: subjectMock,
}))
vi.mock('@sim/platform-authz/workspace', () => ({ isOrgAdminRole: isOrgAdminRoleMock }))
import { maybeSendLimitThresholdEmail } from '@/lib/billing/core/limit-notifications'
const baseUserParams = {
category: 'storage' as const,
scope: 'user' as const,
workspaceId: 'ws-1',
usageLabel: '4.5 GB',
limitLabel: '5 GB',
userId: 'u1',
userEmail: 'u1@example.com',
userName: 'Ada',
}
describe('maybeSendLimitThresholdEmail', () => {
beforeEach(() => {
vi.clearAllMocks()
billingFlag.enabled = true
mockClaim.mockReturnValue([{ id: 'u1' }])
mockSelectRows.mockReturnValue([])
getEmailPreferencesMock.mockResolvedValue(null)
})
it('sends a warning email when crossing 80% and the claim wins', async () => {
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 })
expect(sendEmailSpy).toHaveBeenCalledTimes(1)
expect(renderMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' }))
expect(subjectMock).toHaveBeenCalledWith('storage', 'warning')
})
it('sends a reached email at/over 100%', async () => {
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 5, limit: 5 })
expect(renderMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'reached' }))
expect(subjectMock).toHaveBeenCalledWith('storage', 'reached')
})
it('never sends in rearmOnly mode, even when usage is above a threshold', async () => {
await maybeSendLimitThresholdEmail({
...baseUserParams,
currentUsage: 4.5,
limit: 5,
rearmOnly: true,
})
expect(mockClaim).not.toHaveBeenCalled()
expect(sendEmailSpy).not.toHaveBeenCalled()
})
it('does not send when the atomic claim is lost (already notified)', async () => {
mockClaim.mockReturnValue([])
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 })
expect(sendEmailSpy).not.toHaveBeenCalled()
})
it('claims without re-arming on a crossing (re-arm and claim are mutually exclusive)', async () => {
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 })
expect(dbUpdateSpy).toHaveBeenCalledTimes(1)
expect(mockClaim).toHaveBeenCalledTimes(1)
expect(sendEmailSpy).toHaveBeenCalledTimes(1)
})
it('does not send in the dead band (70%80%)', async () => {
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 3.75, limit: 5 })
expect(mockClaim).not.toHaveBeenCalled()
expect(sendEmailSpy).not.toHaveBeenCalled()
})
it('re-arms below the band without claiming or sending', async () => {
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 1, limit: 5 })
expect(mockClaim).not.toHaveBeenCalled()
expect(sendEmailSpy).not.toHaveBeenCalled()
})
it('does not send OR burn the claim when the per-user toggle is off', async () => {
mockSelectRows.mockReturnValue([{ enabled: false }])
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 })
expect(sendEmailSpy).not.toHaveBeenCalled()
expect(mockClaim).not.toHaveBeenCalled()
})
it('does not send OR burn the claim when the recipient unsubscribed', async () => {
getEmailPreferencesMock.mockResolvedValue({ unsubscribeNotifications: true })
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 })
expect(sendEmailSpy).not.toHaveBeenCalled()
expect(mockClaim).not.toHaveBeenCalled()
})
it('skips entirely when billing is disabled', async () => {
billingFlag.enabled = false
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 5, limit: 5 })
expect(mockClaim).not.toHaveBeenCalled()
expect(sendEmailSpy).not.toHaveBeenCalled()
})
it('re-arms but does not send when usage is fully cleared (zero usage)', async () => {
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 0, limit: 5 })
expect(dbUpdateSpy).toHaveBeenCalledTimes(1)
expect(mockClaim).not.toHaveBeenCalled()
expect(sendEmailSpy).not.toHaveBeenCalled()
})
it('skips when the limit is non-positive', async () => {
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4, limit: 0 })
expect(dbUpdateSpy).not.toHaveBeenCalled()
expect(sendEmailSpy).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,327 @@
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 { and, eq, sql } from 'drizzle-orm'
import { getLimitEmailSubject, renderLimitThresholdEmail } from '@/components/emails/render'
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { buildUpgradeHref, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getEmailPreferences } from '@/lib/messaging/email/unsubscribe'
const logger = createLogger('LimitNotifications')
/** Limit categories that send per-category threshold emails (credits has its own path). */
export type LimitCategory = Extract<UpgradeReason, 'storage' | 'tables' | 'seats'>
const WARN_THRESHOLD = 80
const REACH_THRESHOLD = 100
/** Usage must drop below this band before the same threshold can re-notify (hysteresis). */
const REARM_BELOW = 70
/**
* Resolve the threshold a given usage percent should be notified at:
* 100 at/over the limit, 80 when approaching, 0 otherwise.
*/
function thresholdFor(percent: number): 0 | 80 | 100 {
if (percent >= REACH_THRESHOLD) return REACH_THRESHOLD
if (percent >= WARN_THRESHOLD) return WARN_THRESHOLD
return 0
}
/**
* Atomically claim a threshold for a category: advance the stored value to
* `threshold` only if it is currently lower, returning whether THIS call won the
* advance. A single conditional UPDATE is race-free — concurrent crossings can't
* both claim, so the email is sent exactly once per crossing.
*/
async function claimThreshold(
scope: 'user' | 'organization',
id: string,
category: LimitCategory,
threshold: number
): Promise<boolean> {
const setExpr = sql`jsonb_set(coalesce(${scope === 'user' ? userStats.limitNotifications : organization.limitNotifications}, '{}'::jsonb), ARRAY[${category}], to_jsonb(${threshold}::int))`
const onlyIfLower =
scope === 'user'
? sql`coalesce((${userStats.limitNotifications} ->> ${category})::int, 0) < ${threshold}`
: sql`coalesce((${organization.limitNotifications} ->> ${category})::int, 0) < ${threshold}`
const claimed =
scope === 'user'
? await db
.update(userStats)
.set({ limitNotifications: setExpr })
.where(and(eq(userStats.userId, id), onlyIfLower))
.returning({ id: userStats.userId })
: await db
.update(organization)
.set({ limitNotifications: setExpr })
.where(and(eq(organization.id, id), onlyIfLower))
.returning({ id: organization.id })
return claimed.length > 0
}
/** Re-arm a category (reset its stored threshold to 0) once usage falls back into the low band. */
async function rearmThreshold(
scope: 'user' | 'organization',
id: string,
category: LimitCategory
): Promise<void> {
const setExpr = sql`jsonb_set(coalesce(${scope === 'user' ? userStats.limitNotifications : organization.limitNotifications}, '{}'::jsonb), ARRAY[${category}], to_jsonb(0::int))`
const onlyIfArmed =
scope === 'user'
? sql`coalesce((${userStats.limitNotifications} ->> ${category})::int, 0) > 0`
: sql`coalesce((${organization.limitNotifications} ->> ${category})::int, 0) > 0`
if (scope === 'user') {
await db
.update(userStats)
.set({ limitNotifications: setExpr })
.where(and(eq(userStats.userId, id), onlyIfArmed))
} else {
await db
.update(organization)
.set({ limitNotifications: setExpr })
.where(and(eq(organization.id, id), onlyIfArmed))
}
}
interface LimitEmailRecipient {
email: string
name?: string
}
/** Whether a recipient has unsubscribed from all or from notification emails. */
async function isUnsubscribed(email: string): Promise<boolean> {
const prefs = await getEmailPreferences(email)
return Boolean(prefs?.unsubscribeAll || prefs?.unsubscribeNotifications)
}
/**
* Resolve the recipients that should receive a limit email, with all opt-outs
* already applied (the per-user notifications toggle and unsubscribe prefs).
* Returning an empty list means "nobody to notify" — the caller then skips the
* claim so the dedup state isn't burned without an email going out.
*/
async function resolveRecipients(
scope: 'user' | 'organization',
params: { userId?: string; userEmail?: string; userName?: string; organizationId?: string }
): Promise<LimitEmailRecipient[]> {
if (scope === 'user') {
if (!params.userId || !params.userEmail) return []
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 []
if (await isUnsubscribed(params.userEmail)) return []
return [{ email: params.userEmail, name: params.userName }]
}
if (!params.organizationId) return []
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))
const recipients: LimitEmailRecipient[] = []
for (const a of admins) {
if (!isOrgAdminRole(a.role)) continue
if (a.enabled === false) continue
if (!a.email) continue
if (await isUnsubscribed(a.email)) continue
recipients.push({ email: a.email, name: a.name || undefined })
}
return recipients
}
/**
* Send a usage-limit threshold email (80% warning / 100% reached) for a
* non-credit category, edge-triggered on the mutation that changed usage.
*
* Flow: bail when billing is off or the limit is non-positive; re-arm the
* persisted threshold when current usage is back in the low band; then (for
* increases) resolve eligible recipients and atomically claim the threshold
* before sending. Re-arm and claim are mutually exclusive per call — re-arm only
* fires when `desired === 0` — so the dedup stays a single atomic
* {@link claimThreshold} with no re-arm/claim interleaving race. Recipients are
* resolved with opt-outs applied BEFORE the claim, so an opted-out recipient
* never burns the threshold (which would suppress a later email once
* notifications are re-enabled). Per-recipient send failures are isolated.
*
* The highest threshold already emailed is persisted per category on
* `user_stats` / `organization`; it re-arms once usage drops below
* {@link REARM_BELOW}. Best-effort — callers fire-and-forget; failures never
* block the mutation. Mirrors the credits path in `maybeSendUsageThresholdEmail`:
* respects the per-user notifications toggle and unsubscribe preferences, and
* emails org admins for organization-scoped limits.
*/
export async function maybeSendLimitThresholdEmail(params: {
category: LimitCategory
scope: 'user' | 'organization'
workspaceId: string
currentUsage: number
limit: number
/** Pre-formatted current usage for the email body, e.g. "4.2 GB", "9 seats". */
usageLabel: string
/** Pre-formatted limit for the email body, e.g. "5 GB", "10 seats". */
limitLabel: string
/**
* When true, only the re-arm is evaluated and no email is ever sent. Used by
* usage-decrease paths (e.g. a storage shrink) where usage can still be above
* a threshold but the change is a drop, not a fresh crossing.
*/
rearmOnly?: boolean
userId?: string
userEmail?: string
userName?: string
organizationId?: string
}): Promise<void> {
try {
if (!isBillingEnabled) return
if (params.limit <= 0) return
const { category, scope } = params
const percent = Math.max(0, (params.currentUsage / params.limit) * 100)
const desired = thresholdFor(percent)
const stateId = scope === 'user' ? params.userId : params.organizationId
if (!stateId) return
if (percent < REARM_BELOW) {
await rearmThreshold(scope, stateId, category)
}
if (params.rearmOnly || desired === 0) return
const recipients = await resolveRecipients(scope, params)
if (recipients.length === 0) return
if (!(await claimThreshold(scope, stateId, category, desired))) return
const kind = desired === REACH_THRESHOLD ? 'reached' : 'warning'
const percentUsed = Math.min(100, Math.round(percent))
const upgradeLink = `${getBaseUrl()}${buildUpgradeHref(params.workspaceId, category)}`
let sent = 0
for (const r of recipients) {
try {
const html = await renderLimitThresholdEmail({
kind,
reason: category,
userName: r.name,
usageLabel: params.usageLabel,
limitLabel: params.limitLabel,
percentUsed,
upgradeLink,
})
await sendEmail({
to: r.email,
subject: getLimitEmailSubject(category, kind),
html,
emailType: 'notifications',
})
sent++
} catch (sendError) {
logger.error('Failed to send limit email', {
category,
email: r.email,
error: sendError,
})
}
}
if (sent > 0) {
logger.info('Sent usage-limit threshold email', { category, scope, kind, percentUsed, sent })
}
} catch (error) {
logger.error('Failed to send usage-limit threshold email', {
category: params.category,
scope: params.scope,
error,
})
}
}
/**
* Resolve billing scope for a billed account user, then dispatch the limit
* threshold email via {@link maybeSendLimitThresholdEmail}.
*
* The single entry point for per-category usage-limit emails: callers supply the
* billed user, the usage numbers, and pre-formatted labels, and this resolves
* personal vs. pooled (org) scope and the recipient. Best-effort — never throws.
*
* @param params.billedUserId - User whose subscription determines billing scope
* (the uploader for storage; the workspace's billed account for tables).
* @param params.subscription - Pre-resolved subscription (may be `null`) to skip
* the `getHighestPrioritySubscription` lookup on hot paths; omit to fetch here.
*/
export async function maybeNotifyLimit(params: {
category: LimitCategory
billedUserId: string
workspaceId: string
currentUsage: number
limit: number
usageLabel: string
limitLabel: string
/** Re-arm only, never send — for usage-decrease callers. See {@link maybeSendLimitThresholdEmail}. */
rearmOnly?: boolean
subscription?: HighestPrioritySubscription | null
}): Promise<void> {
try {
const sub =
params.subscription === undefined
? await getHighestPrioritySubscription(params.billedUserId)
: params.subscription
const isOrg = Boolean(sub && isOrgScopedSubscription(sub, params.billedUserId))
const percent = params.limit > 0 ? (params.currentUsage / params.limit) * 100 : 0
let userEmail: string | undefined
let userName: string | undefined
if (!isOrg && !params.rearmOnly && percent >= WARN_THRESHOLD) {
const [row] = await db
.select({ email: user.email, name: user.name })
.from(user)
.where(eq(user.id, params.billedUserId))
.limit(1)
userEmail = row?.email
userName = row?.name || undefined
}
await maybeSendLimitThresholdEmail({
category: params.category,
scope: isOrg ? 'organization' : 'user',
workspaceId: params.workspaceId,
currentUsage: params.currentUsage,
limit: params.limit,
usageLabel: params.usageLabel,
limitLabel: params.limitLabel,
rearmOnly: params.rearmOnly,
userId: params.billedUserId,
userEmail,
userName,
organizationId: isOrg ? sub?.referenceId : undefined,
})
} catch (error) {
logger.error('Failed to resolve scope for usage-limit notification', {
category: params.category,
billedUserId: params.billedUserId,
error,
})
}
}
+443
View File
@@ -0,0 +1,443 @@
import { db } from '@sim/db'
import { invitation, member, organization, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, gt, ne } from 'drizzle-orm'
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
import {
getBillingPeriodUsageCost,
getBillingPeriodUsageCostByUser,
} from '@/lib/billing/core/usage-log'
import {
computeDailyRefreshConsumed,
getOrgMemberRefreshBounds,
} from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers'
import {
getEffectiveSeats,
getFreeTierLimit,
hasUsableSubscriptionStatus,
} from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import type { DbClient } from '@/lib/db/types'
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('OrganizationBilling')
function roundCurrency(value: number): number {
return Math.round(value * 100) / 100
}
interface OrganizationUsageData {
organizationId: string
organizationName: string
subscriptionPlan: string
subscriptionStatus: string
totalSeats: number
usedSeats: number
seatsCount: number
totalCurrentUsage: number
totalUsageLimit: number
minimumBillingAmount: number
averageUsagePerMember: number
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
members: MemberUsageData[]
}
interface MemberUsageData {
userId: string
userName: string
userEmail: string
currentUsage: number
usageLimit: number
percentUsed: number
isOverLimit: boolean
role: string
joinedAt: Date
}
/**
* Per-member usage_log cost for an org's current billing period, keyed by userId.
* `currentPeriodCost` is only a baseline (no longer incremented on the hot path),
* so callers add this ledger component to it for each member's real current-period
* usage. Pass `period` to reuse an already-fetched subscription window; omit it to
* look up the org's subscription here. Returns an empty map when there's no period.
*/
export async function getOrgMemberLedgerByUser(
organizationId: string,
period?: { start: Date; end: Date } | null,
executor: DbClient = db
): Promise<Map<string, number>> {
let billingPeriod = period ?? null
if (period === undefined) {
const subscription = await getOrganizationSubscription(organizationId, { executor })
billingPeriod =
subscription?.periodStart && subscription?.periodEnd
? { start: subscription.periodStart, end: subscription.periodEnd }
: null
}
if (!billingPeriod) return new Map<string, number>()
return getBillingPeriodUsageCostByUser(
{ type: 'organization', id: organizationId },
billingPeriod,
undefined,
executor
)
}
/**
* Get comprehensive organization billing and usage data
*/
export async function getOrganizationBillingData(
organizationId: string,
executor: DbClient = db
): Promise<OrganizationUsageData | null> {
try {
// Get organization info
const orgRecord = await executor
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (orgRecord.length === 0) {
logger.warn('Organization not found', { organizationId })
return null
}
const organizationData = orgRecord[0]
// Get organization subscription directly (referenceId = organizationId)
const subscription = await getOrganizationSubscription(organizationId, { executor })
if (!subscription) {
logger.warn('No subscription found for organization', { organizationId })
return null
}
// Get all organization members with their usage data
const membersWithUsage = await executor
.select({
userId: member.userId,
userName: user.name,
userEmail: user.email,
role: member.role,
joinedAt: member.createdAt,
// User stats fields
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
// Per-member current-period usage = userStats baseline + attributed usage_log
// rows. currentPeriodCost is no longer incremented on the hot path, so the
// baseline alone under-reports; add each member's ledger sum for the period.
const billingPeriod =
subscription.periodStart && subscription.periodEnd
? { start: subscription.periodStart, end: subscription.periodEnd }
: null
const usageByUser = await getOrgMemberLedgerByUser(organizationId, billingPeriod, executor)
// Process member data
const members: MemberUsageData[] = membersWithUsage.map((memberRecord) => {
const currentUsage =
Number(memberRecord.currentPeriodCost || 0) + (usageByUser.get(memberRecord.userId) ?? 0)
const usageLimit = Number(memberRecord.currentUsageLimit || getFreeTierLimit())
const percentUsed = usageLimit > 0 ? (currentUsage / usageLimit) * 100 : 0
return {
userId: memberRecord.userId,
userName: memberRecord.userName,
userEmail: memberRecord.userEmail,
currentUsage,
usageLimit,
percentUsed: Math.round(percentUsed * 100) / 100,
isOverLimit: currentUsage > usageLimit,
role: memberRecord.role,
joinedAt: memberRecord.joinedAt,
}
})
// Authoritative org total = member baselines + the org's full usage_log for
// the period (also captures rows from members no longer present). Computed
// from raw baselines, NOT members[].currentUsage — the latter already folds
// in per-member usage_log for display, so summing it AND adding the org
// ledger would double-count.
let totalCurrentUsage = membersWithUsage.reduce(
(sum, m) => sum + Number(m.currentPeriodCost || 0),
0
)
if (billingPeriod) {
totalCurrentUsage += await getBillingPeriodUsageCost(
{ type: 'organization', id: subscription.referenceId },
billingPeriod,
undefined,
executor
)
}
if (isPaid(subscription.plan) && subscription.periodStart) {
const planDollars = getPlanTierDollars(subscription.plan)
if (planDollars > 0) {
const memberIds = members.map((m) => m.userId)
const userBounds = await getOrgMemberRefreshBounds(
subscription.referenceId,
subscription.periodStart,
executor
)
const refreshConsumed = await computeDailyRefreshConsumed(
{
userIds: 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: subscription.referenceId },
},
executor
)
totalCurrentUsage = Math.max(0, totalCurrentUsage - refreshConsumed)
}
}
const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan)
// Stripe subscription quantity; `||` not `??` because 0 seats is
// never valid for a paid sub — fall through to 1.
const licensedSeats = subscription.seats || 1
// UI seat count — metadata.seats on enterprise (column is always 1).
const effectiveSeats = getEffectiveSeats(subscription)
let minimumBillingAmount: number
let totalUsageLimit: number
if (isEnterprise(subscription.plan)) {
const configuredLimit = toNumber(toDecimal(organizationData.orgUsageLimit))
minimumBillingAmount = configuredLimit
totalUsageLimit = configuredLimit
} else {
minimumBillingAmount = licensedSeats * pricePerSeat
const configuredLimit = organizationData.orgUsageLimit
? toNumber(toDecimal(organizationData.orgUsageLimit))
: null
totalUsageLimit =
configuredLimit !== null
? Math.max(configuredLimit, minimumBillingAmount)
: minimumBillingAmount
}
const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0
const [pendingInvitationCount] = await executor
.select({ count: count() })
.from(invitation)
.where(
and(
eq(invitation.organizationId, organizationId),
eq(invitation.status, 'pending'),
ne(invitation.membershipIntent, 'external'),
gt(invitation.expiresAt, new Date())
)
)
const usedSeats = members.length + (pendingInvitationCount?.count ?? 0)
const billingPeriodStart = subscription.periodStart || null
const billingPeriodEnd = subscription.periodEnd || null
return {
organizationId,
organizationName: organizationData.name || '',
subscriptionPlan: subscription.plan,
subscriptionStatus: subscription.status || 'inactive',
totalSeats: effectiveSeats,
usedSeats,
seatsCount: licensedSeats,
totalCurrentUsage: roundCurrency(totalCurrentUsage),
totalUsageLimit: roundCurrency(totalUsageLimit),
minimumBillingAmount: roundCurrency(minimumBillingAmount),
averageUsagePerMember: roundCurrency(averageUsagePerMember),
billingPeriodStart,
billingPeriodEnd,
members: members.sort((a, b) => b.currentUsage - a.currentUsage), // Sort by usage desc
}
} catch (error) {
logger.error('Failed to get organization billing data', { organizationId, error })
throw error
}
}
/**
* Update organization usage limit (cap)
*/
export async function updateOrganizationUsageLimit(
organizationId: string,
newLimit: number
): Promise<{ success: boolean; error?: string }> {
try {
// Validate the organization exists
const orgRecord = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (orgRecord.length === 0) {
return { success: false, error: 'Organization not found' }
}
// Get subscription to validate minimum
const subscription = await getOrganizationSubscription(organizationId)
if (!subscription) {
return { success: false, error: 'No active subscription found' }
}
if (
!hasUsableSubscriptionStatus(subscription.status) ||
(await isOrganizationBillingBlocked(organizationId))
) {
return { success: false, error: 'An active subscription is required to edit usage limits' }
}
if (isEnterprise(subscription.plan)) {
return {
success: false,
error: 'Enterprise plans have fixed usage limits that cannot be changed',
}
}
if (!isPaid(subscription.plan)) {
return {
success: false,
error: 'Organization is not on a paid plan',
}
}
const { basePrice } = getPlanPricing(subscription.plan)
const seatCount = subscription.seats || 1
const minimumLimit = seatCount * basePrice
if (newLimit < minimumLimit) {
return {
success: false,
error: `Usage limit cannot be less than minimum billing amount of $${roundCurrency(minimumLimit).toFixed(2)}`,
}
}
await db
.update(organization)
.set({
orgUsageLimit: roundCurrency(newLimit).toFixed(2),
updatedAt: new Date(),
})
.where(eq(organization.id, organizationId))
logger.info('Organization usage limit updated', {
organizationId,
newLimit,
minimumLimit,
})
return { success: true }
} catch (error) {
logger.error('Failed to update organization usage limit', {
organizationId,
newLimit,
error,
})
return {
success: false,
error: 'Failed to update usage limit',
}
}
}
/**
* Get organization billing summary for admin dashboard
*/
async function getOrganizationBillingSummary(organizationId: string) {
try {
const billingData = await getOrganizationBillingData(organizationId)
if (!billingData) {
return null
}
// Calculate additional metrics
const membersOverLimit = billingData.members.filter((m) => m.isOverLimit).length
const membersNearLimit = billingData.members.filter(
(m) => !m.isOverLimit && m.percentUsed >= 80
).length
const topUsers = billingData.members.slice(0, 5).map((m) => ({
name: m.userName,
usage: m.currentUsage,
limit: m.usageLimit,
percentUsed: m.percentUsed,
}))
return {
organization: {
id: billingData.organizationId,
name: billingData.organizationName,
plan: billingData.subscriptionPlan,
status: billingData.subscriptionStatus,
},
usage: {
total: billingData.totalCurrentUsage,
limit: billingData.totalUsageLimit,
average: billingData.averageUsagePerMember,
percentUsed:
billingData.totalUsageLimit > 0
? (billingData.totalCurrentUsage / billingData.totalUsageLimit) * 100
: 0,
},
seats: {
total: billingData.totalSeats,
used: billingData.usedSeats,
available: billingData.totalSeats - billingData.usedSeats,
},
alerts: {
membersOverLimit,
membersNearLimit,
},
billingPeriod: {
start: billingData.billingPeriodStart,
end: billingData.billingPeriodEnd,
},
topUsers,
}
} catch (error) {
logger.error('Failed to get organization billing summary', { organizationId, error })
throw error
}
}
/**
* Error-tolerant wrapper around {@link isOrganizationAdminOrOwner} for billing
* gates: on a DB error it logs and returns false instead of throwing, so a
* transient failure denies access rather than surfacing a 500 mid-checkout.
* Prefer the canonical {@link isOrganizationAdminOrOwner} when a thrown error
* should propagate.
*
* @param userId - The ID of the user to check
* @param organizationId - The ID of the organization
* @returns Promise<boolean> - True if the user is an owner or admin of the organization
*/
export async function isOrganizationOwnerOrAdmin(
userId: string,
organizationId: string
): Promise<boolean> {
try {
return await isOrganizationAdminOrOwner(userId, organizationId)
} catch (error) {
logger.error('Error checking organization ownership/admin status:', error)
return false
}
}
+193
View File
@@ -0,0 +1,193 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Drizzle mock for `getHighestPrioritySubscription`. It issues up to four
* queries keyed by table:
* - `subscription` for the user's personal subs (parallelized with members)
* - `member` for the user's org memberships (parallelized with subs)
* - `organization` for the org-existence follow-up
* - `subscription` again for the org-scoped subs follow-up
*
* The mock routes results by the table object passed to `.from()`, serving the
* (twice-read) `subscription` table from a FIFO queue (first read = personal,
* second = org). It records which tables were queried so we can assert the
* parallelized pair both run and that follow-ups are skipped when appropriate.
*
* Table sentinels and shared mock state live inside `vi.hoisted` so the
* `vi.mock` factories (hoisted to the top of the file) can reference them.
*/
const { SUBSCRIPTION_TABLE, MEMBER_TABLE, ORGANIZATION_TABLE, resultsByTable, fromCalls, select } =
vi.hoisted(() => {
const SUBSCRIPTION_TABLE = { __table: 'subscription' }
const MEMBER_TABLE = { __table: 'member' }
const ORGANIZATION_TABLE = { __table: 'organization' }
const resultsByTable: Record<string, unknown[][]> = {
subscription: [],
member: [],
organization: [],
}
const fromCalls: string[] = []
const select = vi.fn(() => ({
from: (table: { __table: string }) => {
fromCalls.push(table.__table)
const where = () => {
const queue = resultsByTable[table.__table]
const next = queue.length > 0 ? queue.shift() : []
return Promise.resolve(next ?? [])
}
return { where }
},
}))
return {
SUBSCRIPTION_TABLE,
MEMBER_TABLE,
ORGANIZATION_TABLE,
resultsByTable,
fromCalls,
select,
}
})
vi.mock('@sim/db', () => ({
db: { select },
}))
vi.mock('@sim/db/schema', () => ({
subscription: SUBSCRIPTION_TABLE,
member: MEMBER_TABLE,
organization: ORGANIZATION_TABLE,
}))
/**
* Realistic plan-check predicates so `pickHighestPrioritySubscription` exercises
* the real Enterprise > Team > Pro priority ordering over the rows we feed it.
*/
vi.mock('@/lib/billing/subscriptions/utils', () => ({
ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'],
checkEnterprisePlan: (s: any) =>
s?.plan === 'enterprise' && ['active', 'past_due'].includes(s?.status),
checkTeamPlan: (s: any) => s?.plan === 'team' && ['active', 'past_due'].includes(s?.status),
checkProPlan: (s: any) => s?.plan === 'pro' && ['active', 'past_due'].includes(s?.status),
}))
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
interface SubRow {
id: string
referenceId: string
plan: string
status: string
}
function personalPro(userId: string): SubRow {
return { id: 'sub-personal-pro', referenceId: userId, plan: 'pro', status: 'active' }
}
function orgEnterprise(orgId: string): SubRow {
return { id: 'sub-org-enterprise', referenceId: orgId, plan: 'enterprise', status: 'active' }
}
function queue(table: 'subscription' | 'member' | 'organization', rows: unknown[]) {
resultsByTable[table].push(rows)
}
describe('getHighestPrioritySubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
resultsByTable.subscription = []
resultsByTable.member = []
resultsByTable.organization = []
fromCalls.length = 0
})
it('picks the org Enterprise sub over a personal Pro sub (priority order)', async () => {
queue('subscription', [personalPro('user-1')]) // personalSubs query
queue('member', [{ organizationId: 'org-1' }]) // memberships query
queue('organization', [{ id: 'org-1' }]) // org-existence query
queue('subscription', [orgEnterprise('org-1')]) // org-subscriptions query
const result = await getHighestPrioritySubscription('user-1')
expect(result).not.toBeNull()
expect(result?.id).toBe('sub-org-enterprise')
expect(result?.plan).toBe('enterprise')
})
it('selection is deterministic regardless of which parallelized query resolves first', async () => {
queue('subscription', [personalPro('user-1')])
queue('member', [{ organizationId: 'org-1' }])
queue('organization', [{ id: 'org-1' }])
queue('subscription', [orgEnterprise('org-1')])
const result = await getHighestPrioritySubscription('user-1')
expect(result?.id).toBe('sub-org-enterprise')
})
it('issues BOTH the personal-subscriptions and memberships queries (parallelized pair)', async () => {
queue('subscription', [personalPro('user-1')])
queue('member', [{ organizationId: 'org-1' }])
queue('organization', [{ id: 'org-1' }])
queue('subscription', [orgEnterprise('org-1')])
await getHighestPrioritySubscription('user-1')
expect(fromCalls).toContain('subscription')
expect(fromCalls).toContain('member')
// First two queries are exactly the parallelized pair (in either order).
expect(fromCalls.slice(0, 2).sort()).toEqual(['member', 'subscription'])
})
it('returns the personal sub and skips org follow-ups when there are no memberships', async () => {
queue('subscription', [personalPro('user-1')])
queue('member', [])
const result = await getHighestPrioritySubscription('user-1')
expect(result?.id).toBe('sub-personal-pro')
expect(result?.plan).toBe('pro')
// org-existence + org-subscription follow-ups are NOT issued.
expect(fromCalls).not.toContain('organization')
expect(fromCalls.filter((t) => t === 'subscription')).toHaveLength(1)
})
it('returns null when neither personal nor org subscriptions exist', async () => {
queue('subscription', [])
queue('member', [])
const result = await getHighestPrioritySubscription('user-1')
expect(result).toBeNull()
})
it('excludes orphaned org memberships whose organization row no longer exists', async () => {
queue('subscription', [])
queue('member', [{ organizationId: 'ghost-org' }]) // membership points at a deleted org
queue('organization', [])
const result = await getHighestPrioritySubscription('user-1')
// Org subs are never fetched (no valid org ids) -> falls back to null.
expect(result).toBeNull()
expect(fromCalls).toContain('organization')
// Only the initial personal-subs read on `subscription`; org-subs query skipped.
expect(fromCalls.filter((t) => t === 'subscription')).toHaveLength(1)
})
it('falls back to the personal sub when the only org is orphaned', async () => {
queue('subscription', [personalPro('user-1')])
queue('member', [{ organizationId: 'ghost-org' }])
queue('organization', [])
const result = await getHighestPrioritySubscription('user-1')
expect(result?.id).toBe('sub-personal-pro')
expect(fromCalls.filter((t) => t === 'subscription')).toHaveLength(1)
})
})
+139
View File
@@ -0,0 +1,139 @@
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import {
checkEnterprisePlan,
checkProPlan,
checkTeamPlan,
ENTITLED_SUBSCRIPTION_STATUSES,
} from '@/lib/billing/subscriptions/utils'
import type { DbClient } from '@/lib/db/types'
const logger = createLogger('PlanLookup')
export type HighestPrioritySubscription = Awaited<ReturnType<typeof getHighestPrioritySubscription>>
interface GetHighestPrioritySubscriptionOptions {
onError?: 'return-null' | 'throw'
/** Read-routing client (primary or replica); defaults to the primary. */
executor?: DbClient
}
function pickHighestPrioritySubscription<TSubscription>(
subscriptions: TSubscription[],
predicates: Array<(subscription: TSubscription) => boolean>
): TSubscription | null {
for (const predicate of predicates) {
const match = subscriptions.find(predicate)
if (match) return match
}
return null
}
export async function getHighestPriorityPersonalSubscription(
userId: string,
options: GetHighestPrioritySubscriptionOptions = {}
) {
const { onError = 'return-null', executor = db } = options
try {
const personalSubs = await executor
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, userId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
return pickHighestPrioritySubscription(personalSubs, [
checkEnterprisePlan,
checkTeamPlan,
checkProPlan,
])
} catch (error) {
logger.error('Error getting highest priority personal subscription', { error, userId })
if (onError === 'throw') {
throw error
}
return null
}
}
/**
* Get the highest priority paid subscription for a user.
*
* Selection order:
* 1. Plan tier: Enterprise > Team > Pro > Free
* 2. Within the same tier, **org-scoped subs beat personally-scoped subs**.
*
* The tie-break matters because a user can legitimately hold both scopes
* at once — e.g. they accepted an org invite while their own personal Pro
* is still in its `cancelAtPeriodEnd` grace window. In that case the org
* is already paying for their usage, so pooled resources should win over
* the runoff personal sub; otherwise usage, credits, and rate limits would
* leak onto the user's row until the next billing cycle.
*/
export async function getHighestPrioritySubscription(
userId: string,
options: GetHighestPrioritySubscriptionOptions = {}
) {
const { onError = 'return-null', executor = db } = options
try {
const [personalSubs, memberships] = await Promise.all([
executor
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, userId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
),
executor
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId)),
])
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
let orgSubs: typeof personalSubs = []
if (orgIds.length > 0) {
// Verify orgs exist to filter out orphaned subscriptions
const existingOrgs = await executor
.select({ id: organization.id })
.from(organization)
.where(inArray(organization.id, orgIds))
const validOrgIds = existingOrgs.map((o) => o.id)
if (validOrgIds.length > 0) {
orgSubs = await executor
.select()
.from(subscription)
.where(
and(
inArray(subscription.referenceId, validOrgIds),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
}
}
if (personalSubs.length === 0 && orgSubs.length === 0) return null
return pickHighestPrioritySubscription(
[...orgSubs, ...personalSubs],
[checkEnterprisePlan, checkTeamPlan, checkProPlan]
)
} catch (error) {
logger.error('Error getting highest priority subscription', { error, userId })
if (onError === 'throw') {
throw error
}
return null
}
}
@@ -0,0 +1,98 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, urlsMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/billing/core/access', () => ({
getEffectiveBillingStatus: vi.fn(),
isOrganizationBillingBlocked: vi.fn(),
}))
vi.mock('@/lib/billing/core/plan', () => ({
getHighestPrioritySubscription: vi.fn(),
}))
vi.mock('@/lib/billing/plan-helpers', () => ({
getPlanTierCredits: vi.fn(),
isPro: vi.fn(),
isTeam: vi.fn(),
}))
vi.mock('@/lib/billing/subscriptions/utils', () => ({
checkEnterprisePlan: vi.fn(),
checkProPlan: vi.fn(),
checkTeamPlan: vi.fn(),
ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'trialing'],
hasUsableSubscriptionAccess: vi.fn(),
USABLE_SUBSCRIPTION_STATUSES: ['active', 'trialing'],
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isAccessControlEnabled: false,
isBillingEnabled: true,
isHosted: true,
isInboxEnabled: false,
isSsoEnabled: false,
}))
vi.mock('@/lib/core/utils/urls', () => urlsMock)
import {
getOrganizationIdForSubscriptionReference,
hasPaidSubscription,
} from '@/lib/billing/core/subscription'
describe('hasPaidSubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns true when an entitled subscription exists', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'sub-1' }])
await expect(hasPaidSubscription('org-1')).resolves.toBe(true)
})
it('returns false when no entitled subscription exists', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])
await expect(hasPaidSubscription('org-1')).resolves.toBe(false)
})
it('fails closed by default when the lookup errors', async () => {
dbChainMockFns.limit.mockRejectedValueOnce(new Error('db unavailable'))
await expect(hasPaidSubscription('org-1')).resolves.toBe(true)
})
it('throws when requested so callers can retry instead of skipping cleanup', async () => {
dbChainMockFns.limit.mockRejectedValueOnce(new Error('db unavailable'))
await expect(hasPaidSubscription('org-1', { onError: 'throw' })).rejects.toThrow(
'db unavailable'
)
})
})
describe('getOrganizationIdForSubscriptionReference', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns an organization id directly when the reference already points to one', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }])
await expect(getOrganizationIdForSubscriptionReference('org-1')).resolves.toBe('org-1')
})
it('falls back to the admin-owned organization when the reference is still user-scoped', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ organizationId: 'org-1', role: 'owner' }])
await expect(getOrganizationIdForSubscriptionReference('user-1')).resolves.toBe('org-1')
})
})
+576
View File
@@ -0,0 +1,576 @@
import { db } from '@sim/db'
import { member, organization, subscription, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import {
getHighestPriorityPersonalSubscription,
getHighestPrioritySubscription,
} from '@/lib/billing/core/plan'
import {
getPlanTierCredits,
isEnterprise as isPlanEnterprise,
isPro as isPlanPro,
isTeam as isPlanTeam,
} from '@/lib/billing/plan-helpers'
import {
checkEnterprisePlan,
checkProPlan,
checkTeamPlan,
ENTITLED_SUBSCRIPTION_STATUSES,
hasUsableSubscriptionAccess,
USABLE_SUBSCRIPTION_STATUSES,
} from '@/lib/billing/subscriptions/utils'
import {
isAccessControlEnabled,
isBillingEnabled,
isHosted,
isInboxEnabled,
isSsoEnabled,
} from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('SubscriptionCore')
export { getHighestPriorityPersonalSubscription, getHighestPrioritySubscription }
export interface SubscriptionMetadata {
billingInterval?: 'month' | 'year'
[key: string]: unknown
}
export interface HasPaidSubscriptionOptions {
onError?: 'assume-active' | 'throw'
}
/**
* Extract the billing interval from subscription metadata, defaulting to 'month'.
*/
export function getBillingInterval(
metadata: SubscriptionMetadata | null | undefined
): 'month' | 'year' {
return metadata?.billingInterval === 'year' ? 'year' : 'month'
}
/**
* Resolves a subscription's effective billing interval. Prefers the Stripe-synced
* `billingInterval` column — the only source populated on enterprise/manual
* subscriptions, which skip the checkout flow that writes the metadata value — and
* falls back to `metadata.billingInterval` (the column is often null on
* checkout-created subs), defaulting to monthly. Where both are set they agree.
*/
export function resolveBillingInterval(
sub: { billingInterval?: string | null; metadata?: unknown } | null | undefined
): 'month' | 'year' {
const column = sub?.billingInterval
if (column === 'year' || column === 'month') return column
return getBillingInterval((sub?.metadata ?? null) as SubscriptionMetadata | null)
}
/**
* Merge a `billingInterval` value into a subscription's metadata JSON column.
*/
export async function writeBillingInterval(
subscriptionId: string,
interval: 'month' | 'year'
): Promise<void> {
const patch = JSON.stringify({ billingInterval: interval })
await db
.update(subscription)
.set({
metadata: sql`(COALESCE(metadata::jsonb, '{}'::jsonb) || ${patch}::jsonb)::json`,
})
.where(eq(subscription.id, subscriptionId))
}
/**
* Sync the subscription's `plan` column to match Stripe. Closes a gap
* where plan changes (Pro → Team upgrades, tier swaps) updated price,
* seats, and referenceId at Stripe but left the DB plan stale. Returns
* `true` if a write was issued, `false` if no change was needed.
*/
export async function syncSubscriptionPlan(
subscriptionId: string,
currentPlan: string | null,
planFromStripe: string | null
): Promise<boolean> {
if (!planFromStripe) return false
if (currentPlan === planFromStripe) return false
await db
.update(subscription)
.set({ plan: planFromStripe })
.where(eq(subscription.id, subscriptionId))
logger.info('Synced subscription plan name from Stripe', {
subscriptionId,
previousPlan: currentPlan,
newPlan: planFromStripe,
})
return true
}
/**
* Get the organization's subscription row when its status is one of
* `USABLE_SUBSCRIPTION_STATUSES` (product access — stricter than
* `ENTITLED_SUBSCRIPTION_STATUSES` which also includes `past_due`).
* Use this for feature-gating ("can this org use the product right
* now"). Use `getOrganizationSubscription` (from `core/billing.ts`)
* when you need the billing-side entitlement row that includes
* past-due subscriptions. Returns `null` when there is no usable sub.
*/
export async function getOrganizationSubscriptionUsable(organizationId: string) {
try {
const [orgSub] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
return orgSub ?? null
} catch (error) {
logger.error('Error getting usable organization subscription', { error, organizationId })
return null
}
}
/**
* Check if a referenceId (user ID or org ID) has a paid subscription row.
* Used for duplicate subscription prevention and transfer safety.
*
* Fails closed by default: returns true on error to prevent duplicate creation.
*/
export async function hasPaidSubscription(
referenceId: string,
options: HasPaidSubscriptionOptions = {}
): Promise<boolean> {
const { onError = 'assume-active' } = options
try {
const [activeSub] = await db
.select({ id: subscription.id })
.from(subscription)
.where(
and(
eq(subscription.referenceId, referenceId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
return !!activeSub
} catch (error) {
logger.error('Error checking active subscription', { error, referenceId })
if (onError === 'throw') {
throw error
}
return true
}
}
export async function getOrganizationIdForSubscriptionReference(
referenceId: string
): Promise<string | null> {
const [referencedOrganization] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, referenceId))
.limit(1)
if (referencedOrganization) {
return referencedOrganization.id
}
const [memberRecord] = await db
.select({
organizationId: member.organizationId,
role: member.role,
})
.from(member)
.where(eq(member.userId, referenceId))
.limit(1)
if (memberRecord && isOrgAdminRole(memberRecord.role)) {
return memberRecord.organizationId
}
return null
}
/**
* Check if user is on Pro plan (direct or via organization)
*/
export async function isProPlan(userId: string): Promise<boolean> {
try {
if (!isBillingEnabled) {
return true
}
const subscription = await getHighestPrioritySubscription(userId)
const isPro =
subscription &&
(checkProPlan(subscription) ||
checkTeamPlan(subscription) ||
checkEnterprisePlan(subscription))
if (isPro) {
logger.info('User has pro-level plan', { userId, plan: subscription.plan })
}
return !!isPro
} catch (error) {
logger.error('Error checking pro plan status', { error, userId })
return false
}
}
/**
* Check if user is on Team plan (direct or via organization)
*/
export async function isTeamPlan(userId: string): Promise<boolean> {
try {
if (!isBillingEnabled) {
return true
}
const subscription = await getHighestPrioritySubscription(userId)
const isTeam =
subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription))
if (isTeam) {
logger.info('User has team-level plan', { userId, plan: subscription.plan })
}
return !!isTeam
} catch (error) {
logger.error('Error checking team plan status', { error, userId })
return false
}
}
/**
* Check if user is on Enterprise plan (direct or via organization)
*/
export async function isEnterprisePlan(userId: string): Promise<boolean> {
try {
if (!isBillingEnabled) {
return true
}
const subscription = await getHighestPrioritySubscription(userId)
const isEnterprise = subscription && checkEnterprisePlan(subscription)
if (isEnterprise) {
logger.info('User has enterprise plan', { userId, plan: subscription.plan })
}
return !!isEnterprise
} catch (error) {
logger.error('Error checking enterprise plan status', { error, userId })
return false
}
}
/**
* Check if user is an admin or owner of an enterprise organization
* Returns true if:
* - User is a member of an enterprise organization AND
* - User's role in that organization is 'owner' or 'admin'
*
* In non-production environments, returns true for convenience.
*/
export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boolean> {
try {
if (!isBillingEnabled) {
return true
}
const [memberRecord] = await db
.select({
organizationId: member.organizationId,
role: member.role,
})
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (!memberRecord) {
return false
}
if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
return false
}
const billingStatus = await getEffectiveBillingStatus(userId)
if (billingStatus.billingBlocked) {
return false
}
const orgSub = await getOrganizationSubscriptionUsable(memberRecord.organizationId)
const isEnterprise = orgSub && checkEnterprisePlan(orgSub)
if (isEnterprise) {
logger.info('User is enterprise org admin/owner', {
userId,
organizationId: memberRecord.organizationId,
role: memberRecord.role,
})
}
return !!isEnterprise
} catch (error) {
logger.error('Error checking enterprise org admin/owner status', { error, userId })
return false
}
}
/**
* Check if an organization has an enterprise plan
* Used for Access Control (Permission Groups) feature gating
*/
export async function isOrganizationOnEnterprisePlan(organizationId: string): Promise<boolean> {
try {
if (!isBillingEnabled) {
return true
}
if (isAccessControlEnabled && !isHosted) {
return true
}
if (await isOrganizationBillingBlocked(organizationId)) {
return false
}
const orgSub = await getOrganizationSubscriptionUsable(organizationId)
return !!orgSub && checkEnterprisePlan(orgSub)
} catch (error) {
logger.error('Error checking organization enterprise plan status', { error, organizationId })
return false
}
}
/**
* Check if user has access to SSO feature
* Returns true if:
* - SSO_ENABLED env var is set (self-hosted override), OR
* - User is admin/owner of an enterprise organization
*
* In non-production environments, returns true for convenience.
*/
export async function hasSSOAccess(userId: string): Promise<boolean> {
try {
if (isSsoEnabled && !isHosted) {
return true
}
return isEnterpriseOrgAdminOrOwner(userId)
} catch (error) {
logger.error('Error checking SSO access', { error, userId })
return false
}
}
/**
* Check whether a workspace is entitled to the Access Control (Permission Groups)
* feature. Entitlement follows the workspace's `billedAccountUserId`:
* - self-hosted override honored via ACCESS_CONTROL_ENABLED, OR
* - billing disabled, OR
* - the workspace belongs to an enterprise-plan organization (org-mode), OR
* - the billed user has an individual enterprise subscription (personal workspace).
*/
export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise<boolean> {
try {
if (!isBillingEnabled) return true
if (isAccessControlEnabled && !isHosted) return true
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
if (!ws) return false
if (ws.organizationId) {
return isOrganizationOnEnterprisePlan(ws.organizationId)
}
const billedSub = await getHighestPrioritySubscription(ws.billedAccountUserId)
return !!billedSub && checkEnterprisePlan(billedSub)
} catch (error) {
logger.error('Error checking workspace enterprise plan status', { error, workspaceId })
return false
}
}
const MAX_PLAN_CREDITS = 25000
/**
* Whether a plan tier entitles the inbox (Sim Mailer) feature: a Max tier
* (credits >= 25000, covering `pro_25000` and `team_25000`) or any enterprise
* plan. Subscription status (usable vs entitled) is gated by callers before this
* runs — the predicate is tier-only.
*/
function isInboxEntitledPlan(plan: string): boolean {
return getPlanTierCredits(plan) >= MAX_PLAN_CREDITS || isPlanEnterprise(plan)
}
/**
* Check whether a workspace is entitled to the inbox (Sim Mailer) feature.
* Entitlement follows the workspace's billing entity — not the acting user — so
* any workspace admin (including an external member) can manage the inbox when
* the workspace's organization, or its billed account for personal workspaces,
* is on a Max or enterprise plan.
*
* Returns true if:
* - INBOX_ENABLED env var is set (self-hosted override), OR
* - billing is disabled, OR
* - the workspace belongs to an organization on a Max/enterprise plan (org-mode), OR
* - the billed user has an individual Max/enterprise subscription (personal workspace).
*/
export async function hasWorkspaceInboxAccess(workspaceId: string): Promise<boolean> {
try {
if (isInboxEnabled) return true
if (!isBillingEnabled) return true
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
if (!ws) return false
if (ws.organizationId) {
if (await isOrganizationBillingBlocked(ws.organizationId)) return false
const orgSub = await getOrganizationSubscriptionUsable(ws.organizationId)
return !!orgSub && isInboxEntitledPlan(orgSub.plan)
}
const [billedSub, billingStatus] = await Promise.all([
getHighestPrioritySubscription(ws.billedAccountUserId),
getEffectiveBillingStatus(ws.billedAccountUserId),
])
if (!billedSub) return false
if (!hasUsableSubscriptionAccess(billedSub.status, billingStatus.billingBlocked)) return false
return isInboxEntitledPlan(billedSub.plan)
} catch (error) {
logger.error('Error checking workspace inbox access', { error, workspaceId })
return false
}
}
/**
* Whether a workspace should RETAIN its provisioned inbox (Sim Mailer)
* infrastructure. Unlike {@link hasWorkspaceInboxAccess}, which gates active use
* on a *usable* (active) subscription, this uses the broader *entitled* status
* set (active OR `past_due`) so a transient payment failure never triggers the
* destructive teardown of a paying customer's inbox.
*
* Reconciliation should delete AgentMail resources only when this returns
* `false` — i.e. the plan is genuinely terminal (canceled, downgraded off
* Max/Enterprise, or gone). Fails open (returns `true`) on any error or
* ambiguity: never tear down on uncertainty.
*/
export async function hasWorkspaceInboxGraceAccess(workspaceId: string): Promise<boolean> {
try {
if (isInboxEnabled) return true
if (!isBillingEnabled) return true
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
if (!ws) return true
if (ws.organizationId) {
const { getOrganizationSubscription } = await import('@/lib/billing/core/billing')
const orgSub = await getOrganizationSubscription(ws.organizationId)
return !!orgSub && isInboxEntitledPlan(orgSub.plan)
}
const billedSub = await getHighestPrioritySubscription(ws.billedAccountUserId)
return !!billedSub && isInboxEntitledPlan(billedSub.plan)
} catch (error) {
logger.error('Error checking workspace inbox grace access', { error, workspaceId })
return true
}
}
/**
* Check if user has access to live sync (every 5 minutes) for KB connectors
* Returns true if:
* - Self-hosted deployment, OR
* - User has a Max plan (credits >= 25000) or enterprise plan
*/
export async function hasLiveSyncAccess(userId: string): Promise<boolean> {
try {
if (!isHosted) {
return true
}
const [sub, billingStatus] = await Promise.all([
getHighestPrioritySubscription(userId),
getEffectiveBillingStatus(userId),
])
if (!sub) return false
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) return false
return getPlanTierCredits(sub.plan) >= 25000 || checkEnterprisePlan(sub)
} catch (error) {
logger.error('Error checking live sync access', { error, userId })
return false
}
}
/**
* Send welcome email for Pro and Team plan subscriptions
*/
export async function sendPlanWelcomeEmail(subscription: any): Promise<void> {
try {
const subPlan = subscription.plan
if (isPlanPro(subPlan) || isPlanTeam(subPlan)) {
const userId = subscription.referenceId
const users = await db
.select({ email: user.email, name: user.name })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (users.length > 0 && users[0].email) {
const { getEmailSubject, renderPlanWelcomeEmail } = await import('@/components/emails')
const { sendEmail } = await import('@/lib/messaging/email/mailer')
const baseUrl = getBaseUrl()
const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers')
const html = await renderPlanWelcomeEmail({
planName: getDisplayPlanName(subPlan),
userName: users[0].name || undefined,
loginLink: `${baseUrl}/login`,
})
const displayName = getDisplayPlanName(subPlan)
await sendEmail({
to: users[0].email,
subject: `Your ${displayName} plan is now active on ${(await import('@/ee/whitelabeling')).getBrandConfig().name}`,
html,
emailType: 'updates',
})
logger.info('Plan welcome email sent successfully', {
userId,
email: users[0].email,
plan: subPlan,
})
}
}
} catch (error) {
logger.error('Failed to send plan welcome email', {
error,
subscriptionId: subscription.id,
plan: subscription.plan,
})
throw error
}
}
+307
View File
@@ -0,0 +1,307 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetHighestPrioritySubscription,
mockInsert,
mockIsOrgScopedSubscription,
mockOnConflictDoNothing,
mockReturning,
mockValues,
mockTransaction,
mockUpdate,
} = vi.hoisted(() => ({
mockGetHighestPrioritySubscription: vi.fn(),
mockInsert: vi.fn(),
mockIsOrgScopedSubscription: vi.fn(),
mockOnConflictDoNothing: vi.fn(),
mockReturning: vi.fn(),
mockValues: vi.fn(),
mockTransaction: vi.fn(),
mockUpdate: vi.fn(),
}))
vi.mock('@sim/db', () => {
const instance = { insert: mockInsert, transaction: mockTransaction }
return { db: instance, dbReplica: instance }
})
vi.mock('@sim/db/schema', () => ({
usageLog: {
billingEntityId: 'usageLog.billingEntityId',
billingEntityType: 'usageLog.billingEntityType',
billingPeriodEnd: 'usageLog.billingPeriodEnd',
billingPeriodStart: 'usageLog.billingPeriodStart',
category: 'usageLog.category',
cost: 'usageLog.cost',
createdAt: 'usageLog.createdAt',
description: 'usageLog.description',
eventKey: 'usageLog.eventKey',
executionId: 'usageLog.executionId',
id: 'usageLog.id',
metadata: 'usageLog.metadata',
source: 'usageLog.source',
userId: 'usageLog.userId',
workflowId: 'usageLog.workflowId',
workspaceId: 'usageLog.workspaceId',
},
}))
vi.mock('@/lib/billing/core/plan', () => ({
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
}))
vi.mock('@/lib/billing/subscriptions/utils', () => ({
isOrgScopedSubscription: mockIsOrgScopedSubscription,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isBillingEnabled: true,
}))
import {
CUMULATIVE_COST_EPSILON,
recordCumulativeUsage,
recordUsage,
resolveCumulativeTopUp,
} from '@/lib/billing/core/usage-log'
describe('recordUsage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockReturning.mockResolvedValue([{ cost: '0.10' }, { cost: '0.20' }])
mockOnConflictDoNothing.mockReturnValue({ returning: mockReturning })
mockValues.mockReturnValue({
onConflictDoNothing: mockOnConflictDoNothing,
})
mockInsert.mockReturnValue({ values: mockValues })
mockGetHighestPrioritySubscription.mockResolvedValue({
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
periodStart: new Date('2026-05-01T00:00:00.000Z'),
referenceId: 'org-1',
})
mockIsOrgScopedSubscription.mockReturnValue(true)
})
it('commits canonical usage rows with deterministic event keys and billing scope', async () => {
await recordUsage({
userId: 'user-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
entries: [
{ category: 'fixed', source: 'workflow', description: 'execution_fee', cost: 0.1 },
{
category: 'model',
source: 'workflow',
description: 'gpt-4',
cost: 0.2,
metadata: { inputTokens: 10, outputTokens: 20 },
},
],
})
const values = mockValues.mock.calls[0][0]
expect(values).toHaveLength(2)
expect(values[0]).toMatchObject({
billingEntityId: 'org-1',
billingEntityType: 'organization',
billingPeriodEnd: new Date('2026-06-01T00:00:00.000Z'),
billingPeriodStart: new Date('2026-05-01T00:00:00.000Z'),
})
expect(values[0].eventKey).toMatch(/^[a-f0-9]{64}$/)
expect(values[1].eventKey).toMatch(/^[a-f0-9]{64}$/)
expect(values[0].eventKey).not.toBe(values[1].eventKey)
expect(mockOnConflictDoNothing).toHaveBeenCalledWith(
expect.objectContaining({ target: 'usageLog.eventKey' })
)
})
it('uses pre-resolved billing context without loading subscriptions', async () => {
await recordUsage({
userId: 'user-1',
billingEntity: { type: 'user', id: 'user-1' },
billingPeriod: {
start: new Date('2026-05-01T00:00:00.000Z'),
end: new Date('2026-06-01T00:00:00.000Z'),
},
entries: [{ category: 'fixed', source: 'workflow', description: 'execution_fee', cost: 0.1 }],
})
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
expect(mockValues.mock.calls[0][0][0]).toMatchObject({
billingEntityId: 'user-1',
billingEntityType: 'user',
})
})
})
describe('resolveCumulativeTopUp', () => {
it('bills the full amount on the first flush (nothing recorded yet)', () => {
expect(resolveCumulativeTopUp(0, 0.3474447)).toEqual({
shouldBill: true,
delta: 0.3474447,
newTotal: 0.3474447,
})
})
it('bills only the delta when the cumulative grows (recovered request)', () => {
const result = resolveCumulativeTopUp(0.3474447, 0.4662453)
expect(result.shouldBill).toBe(true)
expect(result.newTotal).toBe(0.4662453)
expect(result.delta).toBeCloseTo(0.1188006, 9)
})
it('is a no-op when the cumulative is unchanged (abort-race duplicate)', () => {
expect(resolveCumulativeTopUp(0.4662453, 0.4662453)).toEqual({
shouldBill: false,
delta: 0,
newTotal: 0.4662453,
})
})
it('is a no-op when an out-of-order flush carries a lower cumulative', () => {
expect(resolveCumulativeTopUp(0.4662453, 0.3)).toMatchObject({ shouldBill: false, delta: 0 })
})
it('ignores sub-epsilon increases from decimal round-trips', () => {
expect(
resolveCumulativeTopUp(0.4662453, 0.4662453 + CUMULATIVE_COST_EPSILON / 2)
).toMatchObject({ shouldBill: false })
})
})
describe('recordCumulativeUsage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockReturning.mockResolvedValue([{ cost: '0.3474447' }])
mockOnConflictDoNothing.mockReturnValue({ returning: mockReturning })
mockValues.mockReturnValue({ onConflictDoNothing: mockOnConflictDoNothing })
mockInsert.mockReturnValue({ values: mockValues })
mockGetHighestPrioritySubscription.mockResolvedValue({
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
periodStart: new Date('2026-05-01T00:00:00.000Z'),
referenceId: 'org-1',
})
mockIsOrgScopedSubscription.mockReturnValue(true)
})
const setupTx = (existingRow: { id: string; cost: string } | null) => {
const limit = vi.fn().mockResolvedValue(existingRow ? [existingRow] : [])
const where = vi.fn().mockReturnValue({ limit })
const from = vi.fn().mockReturnValue({ where })
const select = vi.fn().mockReturnValue({ from })
const updateWhere = vi.fn().mockResolvedValue(undefined)
const updateSet = vi.fn().mockReturnValue({ where: updateWhere })
mockUpdate.mockReturnValue({ set: updateSet })
const tx = {
execute: vi.fn().mockResolvedValue(undefined),
select,
update: mockUpdate,
insert: mockInsert, // recordUsage(tx) reuses the shared insert chain
}
mockTransaction.mockImplementation(async (fn: (t: typeof tx) => unknown) => fn(tx))
return { tx, select, updateSet }
}
/** True when any tx.execute call ran a sql`` template containing the substring. */
const executedSqlContaining = (tx: { execute: ReturnType<typeof vi.fn> }, substring: string) =>
tx.execute.mock.calls.some(([arg]) => {
const strings = (arg as { strings?: readonly string[] } | null)?.strings
return Array.isArray(strings) && strings.some((s) => s.includes(substring))
})
it('inserts the full cumulative on the first flush', async () => {
setupTx(null)
const result = await recordCumulativeUsage({
userId: 'user-1',
workspaceId: 'ws-1',
source: 'workspace-chat',
model: 'claude-opus-4.8',
cost: 0.3474447,
eventKey: 'update-cost:msg-1-billing',
metadata: { inputTokens: 100, outputTokens: 5 },
})
expect(result).toEqual({ billed: true, delta: 0.3474447, total: 0.3474447 })
expect(mockInsert).toHaveBeenCalledTimes(1)
expect(mockUpdate).not.toHaveBeenCalled()
})
it('tops up to the higher cumulative and bills only the delta', async () => {
const { updateSet } = setupTx({ id: 'row-1', cost: '0.3474447' })
const result = await recordCumulativeUsage({
userId: 'user-1',
source: 'workspace-chat',
model: 'claude-opus-4.8',
cost: 0.4662453,
eventKey: 'update-cost:msg-1-billing',
})
expect(result.billed).toBe(true)
expect(result.total).toBe(0.4662453)
expect(result.delta).toBeCloseTo(0.1188006, 9)
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ cost: '0.4662453' }))
expect(mockInsert).not.toHaveBeenCalled()
})
it('does not bill when the cumulative is not higher than recorded', async () => {
const { updateSet } = setupTx({ id: 'row-1', cost: '0.4662453' })
const result = await recordCumulativeUsage({
userId: 'user-1',
source: 'workspace-chat',
model: 'claude-opus-4.8',
cost: 0.4662453,
eventKey: 'update-cost:msg-1-billing',
})
expect(result).toEqual({ billed: false, delta: 0, total: 0.4662453 })
expect(updateSet).not.toHaveBeenCalled()
expect(mockInsert).not.toHaveBeenCalled()
})
it('resolves the billing context before opening the locked transaction, exactly once', async () => {
setupTx(null)
await recordCumulativeUsage({
userId: 'user-1',
source: 'workspace-chat',
model: 'claude-opus-4.8',
cost: 0.3474447,
eventKey: 'update-cost:msg-1-billing',
})
// One lookup total: pre-resolved outside the tx, and the first-flush
// insert reuses it instead of re-resolving on the pool inside the tx.
expect(mockGetHighestPrioritySubscription).toHaveBeenCalledTimes(1)
expect(mockGetHighestPrioritySubscription.mock.invocationCallOrder[0]).toBeLessThan(
mockTransaction.mock.invocationCallOrder[0]
)
})
it('stamps the pre-resolved billing context onto the first-flush insert', async () => {
setupTx(null)
await recordCumulativeUsage({
userId: 'user-1',
source: 'workspace-chat',
model: 'claude-opus-4.8',
cost: 0.3474447,
eventKey: 'update-cost:msg-1-billing',
})
expect(mockValues.mock.calls[0][0][0]).toMatchObject({
billingEntityId: 'org-1',
billingEntityType: 'organization',
})
})
it('bounds the advisory-lock wait and locks on the 64-bit event-key hash', async () => {
const { tx } = setupTx({ id: 'row-1', cost: '0.3474447' })
await recordCumulativeUsage({
userId: 'user-1',
source: 'workspace-chat',
model: 'claude-opus-4.8',
cost: 0.4662453,
eventKey: 'update-cost:msg-1-billing',
})
expect(executedSqlContaining(tx, 'lock_timeout')).toBe(true)
expect(executedSqlContaining(tx, 'pg_advisory_xact_lock')).toBe(true)
expect(executedSqlContaining(tx, 'hashtextextended')).toBe(true)
})
})
+755
View File
@@ -0,0 +1,755 @@
import { createHash } from 'node:crypto'
import { db, dbReplica } from '@sim/db'
import { usageLog, workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, desc, eq, gte, inArray, lt, lte, or, sql } from 'drizzle-orm'
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { apportionCredits } from '@/lib/billing/credits/conversion'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import type { DbClient, DbOrTx } from '@/lib/db/types'
const logger = createLogger('UsageLog')
/**
* Usage log category types
*/
export type UsageLogCategory = 'model' | 'fixed' | 'tool'
/**
* Usage log source types
*/
export type UsageLogSource =
| 'workflow'
| 'wand'
| 'copilot'
| 'workspace-chat'
| 'mcp_copilot'
| 'mothership_block'
| 'knowledge-base'
| 'voice-input'
| 'enrichment'
/**
* usage_log sources that make up the "copilot" cost breakdown shown in billing
* summaries: the copilot agent, mothership/workspace chat, MCP copilot, and
* mothership blocks. Mirrors the source set billed via /api/billing/update-cost.
*/
export const COPILOT_USAGE_SOURCES: UsageLogSource[] = [
'copilot',
'workspace-chat',
'mcp_copilot',
'mothership_block',
]
/**
* Metadata for 'model' category charges
*/
export interface ModelUsageMetadata {
inputTokens: number
outputTokens: number
toolCost?: number
}
/**
* Union type for all usage log metadata types
*/
export type UsageLogMetadata = ModelUsageMetadata | Record<string, unknown> | null
export type BillingEntityType = 'user' | 'organization'
export interface BillingEntity {
type: BillingEntityType
id: string
}
/**
* A single usage entry to be recorded in the usage_log table.
*/
interface UsageEntry {
category: UsageLogCategory
source: UsageLogSource
description: string
cost: number
eventKey?: string
sourceReference?: string
metadata?: UsageLogMetadata
}
interface RecordUsageBaseParams {
/** The user being charged */
userId: string
/** One or more usage_log entries to record. Total cost is derived from these. */
entries: UsageEntry[]
/** Workspace context */
workspaceId?: string
/** Workflow context */
workflowId?: string
/** Execution context */
executionId?: string
}
/**
* Parameters for the central recordUsage function.
* This is the single entry point for all billing mutations.
*
* Callers that pass `tx` (e.g. the per-execution advisory-lock reconciliation
* in the workflow completion path) must pre-resolve the billing context before
* opening the transaction: resolving it inside would run the subscription
* lookups on the global pool while the tx already holds a pooled connection,
* starving the pool under load (see recordCumulativeUsage for the history).
*/
export type RecordUsageParams = RecordUsageBaseParams &
(
| {
/** Transaction the ledger INSERT participates in. */
tx: DbOrTx
/** Billing entity scope, resolved before the transaction opened. */
billingEntity: BillingEntity
/** Billing period bounds, resolved before the transaction opened. */
billingPeriod: { start: Date; end: Date }
}
| {
tx?: undefined
/** Billing entity scope, resolved by caller when already known. */
billingEntity?: BillingEntity
/** Billing period bounds, resolved by caller when already known. */
billingPeriod?: { start: Date; end: Date }
}
)
export function stableEventKey(parts: Record<string, unknown>): string {
const payload = Object.keys(parts)
.sort()
.map((key) => `${key}:${String(parts[key] ?? '')}`)
.join('|')
return createHash('sha256').update(payload).digest('hex')
}
type ResolvedSubscription = Awaited<ReturnType<typeof getHighestPrioritySubscription>>
export interface BillingContext {
billingEntity: BillingEntity
billingPeriod: { start: Date; end: Date }
}
/**
* Derive the billing entity + period from an ALREADY-resolved subscription.
* Callers that already hold the subscription (e.g. the workflow completion path,
* which fetches it for usage-threshold emails) can derive the context once and
* pass it into recordUsage so resolveBillingContext skips a redundant lookup.
* This is the single source of the entity/period derivation — keep it the only
* place that maps a subscription to a billing context.
*/
export function deriveBillingContext(
userId: string,
subscription: ResolvedSubscription
): BillingContext {
const billingEntity: BillingEntity =
subscription && isOrgScopedSubscription(subscription, userId)
? { type: 'organization', id: subscription.referenceId }
: { type: 'user', id: userId }
const billingPeriod =
subscription?.periodStart && subscription.periodEnd
? { start: subscription.periodStart, end: subscription.periodEnd }
: defaultBillingPeriod()
return { billingEntity, billingPeriod }
}
async function resolveBillingContext(
userId: string,
billingEntity?: BillingEntity,
billingPeriod?: { start: Date; end: Date }
): Promise<BillingContext> {
if (billingEntity && billingPeriod) {
return { billingEntity, billingPeriod }
}
const subscription = await getHighestPrioritySubscription(userId)
const derived = deriveBillingContext(userId, subscription)
return {
billingEntity: billingEntity ?? derived.billingEntity,
billingPeriod: billingPeriod ?? derived.billingPeriod,
}
}
/**
* Returns post-cutover usage for an attributed billing entity/period.
* Legacy pre-cutover usage remains in userStats as a baseline until reset.
*/
export async function getBillingPeriodUsageCost(
billingEntity: BillingEntity,
billingPeriod: { start: Date; end: Date },
source?: UsageLogSource | UsageLogSource[],
executor: DbClient = db
): Promise<number> {
const conditions = [
eq(usageLog.billingEntityType, billingEntity.type),
eq(usageLog.billingEntityId, billingEntity.id),
eq(usageLog.billingPeriodStart, billingPeriod.start),
eq(usageLog.billingPeriodEnd, billingPeriod.end),
]
if (source) {
conditions.push(
Array.isArray(source) ? inArray(usageLog.source, source) : eq(usageLog.source, source)
)
}
const [row] = await executor
.select({
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
})
.from(usageLog)
.where(and(...conditions))
return Number.parseFloat(row?.cost ?? '0')
}
export async function getBillingPeriodUsageCostByUser(
billingEntity: BillingEntity,
billingPeriod: { start: Date; end: Date },
source?: UsageLogSource | UsageLogSource[],
executor: DbClient = db
): Promise<Map<string, number>> {
const conditions = [
eq(usageLog.billingEntityType, billingEntity.type),
eq(usageLog.billingEntityId, billingEntity.id),
eq(usageLog.billingPeriodStart, billingPeriod.start),
eq(usageLog.billingPeriodEnd, billingPeriod.end),
]
if (source) {
conditions.push(
Array.isArray(source) ? inArray(usageLog.source, source) : eq(usageLog.source, source)
)
}
const rows = await executor
.select({
userId: usageLog.userId,
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
})
.from(usageLog)
.where(and(...conditions))
.groupBy(usageLog.userId)
return new Map(rows.map((row) => [row.userId, Number.parseFloat(row.cost ?? '0')]))
}
/**
* A single user's usage_log cost inside an organization's own workspaces within
* a wall-clock window (by `created_at`).
*
* Unlike {@link getBillingPeriodUsageCostByUser} (which filters by the attributed
* billing entity and the stored billing-period columns), this joins `workspace`
* on `organization_id` and filters by `user_id` + a `created_at` range. That
* captures the member's consumption inside org-owned workspaces regardless of
* which billing entity the row was attributed to — required for per-member
* org-workspace usage, including external members whose runs bill to their own
* personal entity and mothership/copilot cost attributed to the using user.
* Scoped to one user so it uses the `(user_id, created_at)` index rather than
* scanning the whole org's period on the execution hot path.
*/
export async function getOrgWorkspaceUsageCostForUser(
organizationId: string,
userId: string,
window: { start: Date; end: Date }
): Promise<number> {
const [row] = await db
.select({ cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)` })
.from(usageLog)
.innerJoin(workspace, eq(workspace.id, usageLog.workspaceId))
.where(
and(
eq(usageLog.userId, userId),
eq(workspace.organizationId, organizationId),
gte(usageLog.createdAt, window.start),
lt(usageLog.createdAt, window.end)
)
)
return Number.parseFloat(row?.cost ?? '0')
}
/**
* Records usage as append-only billing events.
*
* This intentionally avoids per-event userStats updates: userStats is retained
* as the pre-cutover period baseline and for low-frequency billing trackers,
* but usage writes no longer contend on the user_stats row.
*/
export async function recordUsage(params: RecordUsageParams): Promise<void> {
// The usage ledger is written regardless of BILLING_ENABLED so it is the
// single, universal source of truth for cost (including self-hosted, where
// it powers the logs-page cost display). Billing *enforcement* (Stripe /
// overage) is gated separately by callers, not here.
const {
userId,
entries,
workspaceId,
workflowId,
executionId,
billingEntity,
billingPeriod,
tx,
} = params
const validEntries = entries.filter((e) => e.cost > 0)
if (validEntries.length === 0) {
return
}
const context = await resolveBillingContext(userId, billingEntity, billingPeriod)
const insertedRows = await (tx ?? db)
.insert(usageLog)
.values(
validEntries.map((entry, index) => {
const sourceReference =
entry.sourceReference ??
[executionId, workflowId, workspaceId, entry.source, entry.description, index]
.filter((part) => part !== undefined && part !== null && part !== '')
.join(':')
const eventKey =
entry.eventKey ??
stableEventKey({
userId,
source: entry.source,
category: entry.category,
description: entry.description,
sourceReference,
executionId,
workflowId,
workspaceId,
index,
})
return {
id: generateId(),
userId,
category: entry.category,
source: entry.source,
description: entry.description,
metadata: entry.metadata ?? null,
cost: entry.cost.toString(),
eventKey,
billingEntityType: context.billingEntity.type,
billingEntityId: context.billingEntity.id,
billingPeriodStart: context.billingPeriod.start,
billingPeriodEnd: context.billingPeriod.end,
workspaceId: workspaceId ?? null,
workflowId: workflowId ?? null,
executionId: executionId ?? null,
}
})
)
.onConflictDoNothing({
target: usageLog.eventKey,
where: sql`${usageLog.eventKey} IS NOT NULL`,
})
.returning({ cost: usageLog.cost })
const insertedCost = insertedRows.reduce((sum, row) => sum + Number.parseFloat(row.cost), 0)
if (insertedRows.length < validEntries.length) {
logger.debug('Skipped duplicate usage events', {
userId,
attemptedEntries: validEntries.length,
insertedEntries: insertedRows.length,
})
}
logger.debug('Recorded usage', {
userId,
totalCost: insertedCost,
entryCount: validEntries.length,
sources: [...new Set(validEntries.map((e) => e.source))],
})
}
/**
* Floating-point tolerance for cumulative cost comparison. Costs are dollars;
* a sub-microcent difference is treated as "no change" so a DB round-trip
* (decimal string -> float) can't manufacture a spurious top-up.
*/
export const CUMULATIVE_COST_EPSILON = 1e-9
/**
* Decide whether an incoming CUMULATIVE cost for a request should bill, given
* what has already been recorded for it.
*
* Billing is a monotonic top-up: only a strictly-higher cumulative bills, and
* it bills just the delta above what's recorded; a same-or-lower cumulative is
* a no-op. This is the core invariant that makes repeated flushes of a single
* request converge to the true total exactly once — a partial mid-loop flush
* (e.g. after a provider error), the recovered terminal flush, and abort-race
* duplicates all reconcile to the maximum cumulative with no under- or
* over-billing, independent of arrival order.
*/
export function resolveCumulativeTopUp(
recordedCost: number,
incomingCost: number
): { shouldBill: boolean; delta: number; newTotal: number } {
if (incomingCost <= recordedCost + CUMULATIVE_COST_EPSILON) {
return { shouldBill: false, delta: 0, newTotal: recordedCost }
}
return { shouldBill: true, delta: incomingCost - recordedCost, newTotal: incomingCost }
}
export interface RecordCumulativeUsageParams {
userId: string
workspaceId?: string
source: UsageLogSource
/** Model name, stored as the row description. */
model: string
/** The request's CUMULATIVE cost so far (not a per-leg delta). */
cost: number
/** Stable per-request key; the single ledger row is keyed on this. */
eventKey: string
metadata?: UsageLogMetadata
}
export interface RecordCumulativeUsageResult {
/** True when a new (delta) charge was recorded for this flush. */
billed: boolean
/** Amount newly charged by this flush (0 on a duplicate/lower flush). */
delta: number
/** The request's recorded cumulative cost after this flush. */
total: number
}
/**
* Bounds the wait for the per-event-key advisory lock (and any row/index lock
* waits inside the critical section). The Go mothership gives each UpdateCost
* POST a 5s deadline, retries 3x with backoff, then dead-letters the charge
* keyed on the same idempotency key — so a stuck lock holder must surface as
* a fast, retryable failure (SQLSTATE 55P03) within that budget rather than
* an unbounded wait that pins pooled connections.
*/
const CUMULATIVE_FLUSH_LOCK_TIMEOUT_MS = 3_000
/**
* Record a request's CUMULATIVE cost idempotently with monotonic top-up.
*
* Keeps exactly ONE usage_log row per `eventKey` holding the MAX cumulative
* cost ever submitted for the request, billing only the incremental delta on
* each flush. A per-key transactional advisory lock serializes concurrent
* flushes so the read-then-write — including the first insert — is race-free
* (no two flushes can both believe they are first and clobber each other).
* The billing context is resolved BEFORE the transaction and the lock wait is
* bounded by `lock_timeout`, keeping the critical section to one SELECT plus
* one INSERT/UPDATE on a single pooled connection.
*
* Because every leg flushes its cumulative and this converges to the max,
* there is no under-billing if the request recovers after a partial flush, no
* over-billing from duplicate/abort-race flushes, and no lost billing if the
* process dies between legs — each leg's cost is durably recorded as it lands.
*/
export async function recordCumulativeUsage(
params: RecordCumulativeUsageParams
): Promise<RecordCumulativeUsageResult> {
const { userId, workspaceId, source, model, cost, eventKey, metadata } = params
// Resolved before the locked transaction on purpose: resolving inside it
// ran the subscription lookups on the global pool while this tx already
// held a pooled connection plus the advisory lock, so under load N
// first-flush transactions each pinned a connection while waiting for one
// more — starving the pool and queueing every same-key flush (and the Go
// side's retries) behind the stall.
const billingContext = await resolveBillingContext(userId)
return db.transaction(async (tx) => {
// Serialize all flushes for this request (lock auto-releases at tx end),
// with a bounded wait so a pathological holder fails this flush fast and
// lets the caller retry instead of hanging the connection.
await tx.execute(
sql`select set_config('lock_timeout', ${`${CUMULATIVE_FLUSH_LOCK_TIMEOUT_MS}ms`}, true)`
)
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${eventKey}, 0))`)
const [existing] = await tx
.select({ id: usageLog.id, cost: usageLog.cost })
.from(usageLog)
.where(eq(usageLog.eventKey, eventKey))
.limit(1)
const recorded = existing ? Number.parseFloat(existing.cost) : 0
const { shouldBill, delta, newTotal } = resolveCumulativeTopUp(recorded, cost)
if (!shouldBill) {
return { billed: false, delta: 0, total: recorded }
}
if (existing) {
// Top up the single row to the new (higher) cumulative; the
// period total is SUM(usage_log.cost), so this lifts it by the delta.
await tx
.update(usageLog)
.set({ cost: newTotal.toString(), metadata: metadata ?? null })
.where(eq(usageLog.id, existing.id))
} else {
// First flush for this request: insert the canonical row with the
// pre-resolved billing context. Runs in the same tx + advisory lock.
await recordUsage({
userId,
workspaceId,
tx,
billingEntity: billingContext.billingEntity,
billingPeriod: billingContext.billingPeriod,
entries: [
{
category: 'model',
source,
description: model,
cost: newTotal,
eventKey,
sourceReference: eventKey,
...(metadata ? { metadata } : {}),
},
],
})
}
return { billed: true, delta, total: newTotal }
})
}
interface UsageLogFilter {
source?: UsageLogSource
workspaceId?: string
startDate?: Date
endDate?: Date
}
function buildUsageLogConditions(userId: string, filter: UsageLogFilter) {
const conditions = [eq(usageLog.userId, userId)]
if (filter.source) conditions.push(eq(usageLog.source, filter.source))
if (filter.workspaceId) conditions.push(eq(usageLog.workspaceId, filter.workspaceId))
if (filter.startDate) conditions.push(gte(usageLog.createdAt, filter.startDate))
if (filter.endDate) conditions.push(lte(usageLog.createdAt, filter.endDate))
return conditions
}
/**
* Apportions credits across every log matching the filter (not just one
* page), so a row's `creditCost` is identical everywhere it's shown — the
* paginated list and the CSV export both call this rather than each
* apportioning their own subset, which would let the same row disagree
* between the two (or between pages of the same list) since apportionment
* depends on the complete set's total.
*/
export async function getUsageCreditsByLogId(
userId: string,
filter: UsageLogFilter
): Promise<Record<string, number>> {
const rows = await dbReplica
.select({ id: usageLog.id, cost: usageLog.cost })
.from(usageLog)
.where(and(...buildUsageLogConditions(userId, filter)))
.orderBy(desc(usageLog.createdAt), desc(usageLog.id))
return apportionCredits(
rows.map((row) => ({ key: row.id, dollars: Number.parseFloat(row.cost) }))
)
}
/**
* Options for querying usage logs
*/
export interface GetUsageLogsOptions {
/** Filter by source */
source?: UsageLogSource
/** Filter by workspace */
workspaceId?: string
/** Start date (inclusive) */
startDate?: Date
/** End date (inclusive) */
endDate?: Date
/** Maximum number of results */
limit?: number
/** Cursor for pagination (log ID) */
cursor?: string
/**
* The cursor row's `createdAt`, when the caller already has it (e.g. a
* multi-page export loop holding the previous page's rows in memory).
* Skips the row lookup that would otherwise resolve it from `cursor`.
*/
cursorCreatedAt?: Date
/**
* Whether to compute the full-filter `summary` aggregate (default `true`).
* A cursor-paginated caller collecting every page (e.g. a CSV export) only
* needs `logs` from each page and would otherwise pay for the same
* cursor-independent `SUM`/`GROUP BY` scan once per page for a result it
* never reads — set `false` to skip it.
*/
includeSummary?: boolean
}
/**
* Usage log entry returned from queries
*/
interface UsageLogEntry {
id: string
createdAt: string
category: UsageLogCategory
source: UsageLogSource
description: string
metadata?: UsageLogMetadata
cost: number
workspaceId?: string
workflowId?: string
/** Name of the referenced workflow, when `workflowId` resolves to one. */
workflowName?: string
executionId?: string
}
/**
* Result from getUserUsageLogs
*/
export interface UsageLogsResult {
logs: UsageLogEntry[]
/** `{ totalCost: 0, bySource: {} }` when `includeSummary` is `false`. */
summary: {
totalCost: number
bySource: Record<string, number>
}
pagination: {
nextCursor?: string
hasMore: boolean
}
}
/**
* Get usage logs for a user with optional filtering and pagination
*/
export async function getUserUsageLogs(
userId: string,
options: GetUsageLogsOptions = {}
): Promise<UsageLogsResult> {
const {
source,
workspaceId,
startDate,
endDate,
limit = 50,
cursor,
cursorCreatedAt,
includeSummary = true,
} = options
try {
const conditions = buildUsageLogConditions(userId, { source, workspaceId, startDate, endDate })
if (cursor) {
let resolvedCursorCreatedAt = cursorCreatedAt
if (!resolvedCursorCreatedAt) {
// Cursor resolution stays on the primary: the page itself reads a
// load-balanced replica, and a laggier sibling replica missing the
// cursor row would silently restart pagination from page 1.
const cursorLog = await db
.select({ createdAt: usageLog.createdAt })
.from(usageLog)
.where(eq(usageLog.id, cursor))
.limit(1)
resolvedCursorCreatedAt = cursorLog[0]?.createdAt
}
if (resolvedCursorCreatedAt) {
const cursorCondition = or(
lt(usageLog.createdAt, resolvedCursorCreatedAt),
and(eq(usageLog.createdAt, resolvedCursorCreatedAt), lt(usageLog.id, cursor))
)
if (cursorCondition) conditions.push(cursorCondition)
}
}
const logs = await dbReplica
.select({
id: usageLog.id,
createdAt: usageLog.createdAt,
category: usageLog.category,
source: usageLog.source,
description: usageLog.description,
metadata: usageLog.metadata,
cost: usageLog.cost,
workspaceId: usageLog.workspaceId,
workflowId: usageLog.workflowId,
workflowName: workflow.name,
executionId: usageLog.executionId,
})
.from(usageLog)
.leftJoin(workflow, eq(usageLog.workflowId, workflow.id))
.where(and(...conditions))
.orderBy(desc(usageLog.createdAt), desc(usageLog.id))
.limit(limit + 1)
const hasMore = logs.length > limit
const resultLogs = hasMore ? logs.slice(0, limit) : logs
const transformedLogs: UsageLogEntry[] = resultLogs.map((log) => ({
id: log.id,
createdAt: log.createdAt.toISOString(),
category: log.category as UsageLogCategory,
source: log.source as UsageLogSource,
description: log.description,
...(log.metadata ? { metadata: log.metadata as UsageLogMetadata } : {}),
cost: Number.parseFloat(log.cost),
...(log.workspaceId ? { workspaceId: log.workspaceId } : {}),
...(log.workflowId ? { workflowId: log.workflowId } : {}),
...(log.workflowName ? { workflowName: log.workflowName } : {}),
...(log.executionId ? { executionId: log.executionId } : {}),
}))
const bySource: Record<string, number> = {}
let totalCost = 0
if (includeSummary) {
const summaryConditions = buildUsageLogConditions(userId, {
source,
workspaceId,
startDate,
endDate,
})
const summaryResult = await dbReplica
.select({
source: usageLog.source,
totalCost: sql<string>`SUM(${usageLog.cost})`,
})
.from(usageLog)
.where(and(...summaryConditions))
.groupBy(usageLog.source)
for (const row of summaryResult) {
const sourceCost = Number.parseFloat(row.totalCost || '0')
bySource[row.source] = sourceCost
totalCost += sourceCost
}
}
return {
logs: transformedLogs,
summary: {
totalCost,
bySource,
},
pagination: {
nextCursor:
hasMore && resultLogs.length > 0 ? resultLogs[resultLogs.length - 1].id : undefined,
hasMore,
},
}
} catch (error) {
logger.error('Failed to get usage logs', {
error: toError(error).message,
userId,
options,
})
throw error
}
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Tests for getUserUsageLimit.
*
* Org-scoped members carry a null `currentUsageLimit` by design, so a user
* whose subscription stops being org-scoped without a resync is left null.
* The limit read must self-heal that state to the plan default instead of
* failing closed and blocking every execution.
*
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
const {
mockGetFreeTierLimit,
mockGetPerUserMinimumLimit,
mockHasPaidSubscriptionStatus,
mockIsOrgScopedSubscription,
} = vi.hoisted(() => ({
mockGetFreeTierLimit: vi.fn(),
mockGetPerUserMinimumLimit: vi.fn(),
mockHasPaidSubscriptionStatus: vi.fn(),
mockIsOrgScopedSubscription: vi.fn(),
}))
vi.mock('@/lib/billing/subscriptions/utils', () => ({
canEditUsageLimit: vi.fn(),
getFreeTierLimit: mockGetFreeTierLimit,
getPerUserMinimumLimit: mockGetPerUserMinimumLimit,
getPlanPricing: vi.fn(() => ({ basePrice: 20 })),
hasPaidSubscriptionStatus: mockHasPaidSubscriptionStatus,
hasUsableSubscriptionAccess: vi.fn(),
isOrgScopedSubscription: mockIsOrgScopedSubscription,
}))
vi.mock('@/lib/billing/core/plan', () => ({
getHighestPrioritySubscription: vi.fn(),
}))
vi.mock('@/lib/billing/core/access', () => ({
getEffectiveBillingStatus: vi.fn(),
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
getBillingPeriodUsageCost: vi.fn(),
}))
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
computeDailyRefreshConsumed: vi.fn(),
getOrgMemberRefreshBounds: vi.fn(),
}))
vi.mock('@/components/emails', () => ({
getEmailSubject: vi.fn(),
renderCreditsExhaustedEmail: vi.fn(),
renderFreeTierUpgradeEmail: vi.fn(),
renderUsageThresholdEmail: vi.fn(),
}))
vi.mock('@/lib/messaging/email/mailer', () => ({
sendEmail: vi.fn(),
}))
vi.mock('@/lib/messaging/email/unsubscribe', () => ({
getEmailPreferences: vi.fn(),
}))
import { getUserUsageLimit } from '@/lib/billing/core/usage'
const PRO_SUBSCRIPTION = {
id: 'sub-1',
plan: 'pro',
status: 'active',
referenceId: 'user-1',
seats: 1,
periodStart: null,
periodEnd: null,
} as never
describe('getUserUsageLimit', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsOrgScopedSubscription.mockReturnValue(false)
})
it('returns the stored limit when set', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '25' }])
const limit = await getUserUsageLimit('user-1', null)
expect(limit).toBe(25)
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})
it('throws when no userStats row exists', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])
await expect(getUserUsageLimit('user-1', null)).rejects.toThrow('No user stats record found')
})
it('heals a null limit to the free-tier default for free users', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null }])
dbChainMockFns.returning.mockResolvedValueOnce([{ currentUsageLimit: '10' }])
mockGetFreeTierLimit.mockReturnValue(10)
const limit = await getUserUsageLimit('user-1', null)
expect(limit).toBe(10)
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.set).toHaveBeenCalledWith({
currentUsageLimit: '10',
usageLimitUpdatedAt: expect.any(Date),
})
const [condition] = dbChainMockFns.where.mock.calls.at(-1) ?? []
expect(condition).toMatchObject({
type: 'and',
conditions: [{ type: 'eq', right: 'user-1' }, { type: 'isNull' }],
})
})
it('heals a null limit to the plan minimum for paid personal subscriptions', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null }])
dbChainMockFns.returning.mockResolvedValueOnce([{ currentUsageLimit: '40' }])
mockHasPaidSubscriptionStatus.mockReturnValue(true)
mockGetPerUserMinimumLimit.mockReturnValue(40)
const limit = await getUserUsageLimit('user-1', PRO_SUBSCRIPTION)
expect(limit).toBe(40)
expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(PRO_SUBSCRIPTION)
expect(dbChainMockFns.set).toHaveBeenCalledWith({
currentUsageLimit: '40',
usageLimitUpdatedAt: expect.any(Date),
})
})
it('returns a concurrently written limit when the guarded heal matches no rows', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null }])
dbChainMockFns.returning.mockResolvedValueOnce([])
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '30' }])
mockGetFreeTierLimit.mockReturnValue(10)
const limit = await getUserUsageLimit('user-1', null)
expect(limit).toBe(30)
})
it('still returns the fallback when the heal write fails', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null }])
dbChainMockFns.returning.mockRejectedValueOnce(new Error('connection lost'))
mockGetFreeTierLimit.mockReturnValue(10)
await expect(getUserUsageLimit('user-1', null)).resolves.toBe(10)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetWorkspaceBilledAccountUserId, mockGetHighestPrioritySubscription } = vi.hoisted(
() => ({
mockGetWorkspaceBilledAccountUserId: vi.fn(),
mockGetHighestPrioritySubscription: vi.fn(),
})
)
vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
}))
vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
}))
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
describe('getWorkspaceOwnerSubscriptionAccess', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('owner-1')
})
it('reports paid + org-scoped for an org team plan billed to the owner', async () => {
mockGetHighestPrioritySubscription.mockResolvedValue({
plan: 'team_25000',
status: 'active',
referenceId: 'org-1',
})
const access = await getWorkspaceOwnerSubscriptionAccess('ws-1')
expect(access).toMatchObject({
plan: 'team_25000',
isPaid: true,
isTeam: true,
isPro: false,
isEnterprise: false,
isOrgScoped: true,
organizationId: 'org-1',
})
})
it('reports free when the billed account has no subscription', async () => {
mockGetHighestPrioritySubscription.mockResolvedValue(null)
const access = await getWorkspaceOwnerSubscriptionAccess('ws-1')
expect(access).toMatchObject({ plan: 'free', isPaid: false, isOrgScoped: false })
})
it('reports free when the workspace has no billed account', async () => {
mockGetWorkspaceBilledAccountUserId.mockResolvedValue(null)
const access = await getWorkspaceOwnerSubscriptionAccess('ws-1')
expect(access.isPaid).toBe(false)
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,56 @@
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
import {
hasPaidSubscriptionStatus,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
/**
* The subscription access fields of a workspace's billed account, as a workspace-
* scoped counterpart to the viewer's `/api/billing` data. Feed this to the
* client `getSubscriptionAccessState` to derive `hasUsablePaidAccess` etc. for
* the WORKSPACE (its owner's rolled-up plan), instead of the signed-in viewer's
* individual plan — so a free member of a paid workspace isn't gated.
*
* Carries no usage/credit/Stripe data: safe to expose to any workspace member.
*/
export interface WorkspaceOwnerSubscriptionAccess {
plan: string
status: string | null
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
isOrgScoped: boolean
organizationId: string | null
}
/**
* Resolves the workspace's billed account and returns its subscription access
* fields (rolled up over org memberships). Mirrors the flag derivation in
* `getSimplifiedBillingSummary` so the result matches the viewer `/api/billing`
* shape for the owner.
*/
export async function getWorkspaceOwnerSubscriptionAccess(
workspaceId: string
): Promise<WorkspaceOwnerSubscriptionAccess> {
const billedUserId = await getWorkspaceBilledAccountUserId(workspaceId)
const subscription = billedUserId ? await getHighestPrioritySubscription(billedUserId) : null
const plan = subscription?.plan ?? 'free'
const hasPaidEntitlement = hasPaidSubscriptionStatus(subscription?.status)
const orgScoped =
subscription && billedUserId ? isOrgScopedSubscription(subscription, billedUserId) : false
return {
plan,
status: subscription?.status ?? null,
isPaid: hasPaidEntitlement && isPaid(plan),
isPro: hasPaidEntitlement && isPro(plan),
isTeam: hasPaidEntitlement && isTeam(plan),
isEnterprise: hasPaidEntitlement && isEnterprise(plan),
isOrgScoped: orgScoped,
organizationId: orgScoped && subscription ? subscription.referenceId : null,
}
}