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

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
+38
View File
@@ -0,0 +1,38 @@
import { createLogger } from '@sim/logger'
import { hasPaidSubscription } from '@/lib/billing'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
const logger = createLogger('BillingAuthorization')
/**
* Check if a user is authorized to manage billing for a given reference ID.
* Reference ID can be either a user ID (personal subscription) or an
* organization ID (org-scoped subscription — team, enterprise, or a
* `pro_*` plan transferred to an org).
*
* This function also performs duplicate subscription validation for
* organizations:
* - Rejects if an organization already has an active subscription (prevents
* duplicates).
* - Personal subscriptions skip this check to allow upgrades.
*/
export async function authorizeSubscriptionReference(
userId: string,
referenceId: string,
action?: string
): Promise<boolean> {
if (!isOrgScopedSubscription({ referenceId }, userId)) {
return true
}
if (action === 'upgrade-subscription' && (await hasPaidSubscription(referenceId))) {
logger.warn('Blocking checkout - active subscription already exists for organization', {
userId,
referenceId,
})
return false
}
return isOrganizationOwnerOrAdmin(userId, referenceId)
}
@@ -0,0 +1,129 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockFlags, mockDbLimit, mockGetOrgMemberUsageLimit, mockGetOrgMemberWorkspaceUsage } =
vi.hoisted(() => ({
mockFlags: { isHosted: true, isBillingEnabled: true },
mockDbLimit: vi.fn(),
mockGetOrgMemberUsageLimit: vi.fn(),
mockGetOrgMemberWorkspaceUsage: vi.fn(),
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isHosted() {
return mockFlags.isHosted
},
get isBillingEnabled() {
return mockFlags.isBillingEnabled
},
}))
vi.mock('@sim/db', () => ({
db: {
select: () => ({
from: () => ({
where: () => ({
limit: mockDbLimit,
}),
}),
}),
},
}))
vi.mock('@/lib/billing/organizations/member-limits', () => ({
getOrgMemberUsageLimit: mockGetOrgMemberUsageLimit,
getOrgMemberWorkspaceUsage: mockGetOrgMemberWorkspaceUsage,
}))
// core/usage pulls in the email-rendering chain at import; stub the two symbols
// usage-monitor imports from it so the module loads in a node test env.
vi.mock('@/lib/billing/core/usage', () => ({
getPooledOrgCurrentPeriodCost: vi.fn(),
getUserUsageLimit: vi.fn(),
}))
import { checkOrgMemberUsageLimit } from '@/lib/billing/calculations/usage-monitor'
describe('checkOrgMemberUsageLimit', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFlags.isHosted = true
mockFlags.isBillingEnabled = true
mockDbLimit.mockResolvedValue([{ organizationId: 'org-1' }])
mockGetOrgMemberUsageLimit.mockResolvedValue(2)
mockGetOrgMemberWorkspaceUsage.mockResolvedValue(1)
})
it('no-ops when not hosted', async () => {
mockFlags.isHosted = false
const result = await checkOrgMemberUsageLimit('user-1', 'ws-1')
expect(result.isExceeded).toBe(false)
expect(mockDbLimit).not.toHaveBeenCalled()
expect(mockGetOrgMemberUsageLimit).not.toHaveBeenCalled()
})
it('no-ops when billing is disabled', async () => {
mockFlags.isBillingEnabled = false
const result = await checkOrgMemberUsageLimit('user-1', 'ws-1')
expect(result.isExceeded).toBe(false)
})
it('no-ops when workspaceId is empty', async () => {
const result = await checkOrgMemberUsageLimit('user-1', '')
expect(result.isExceeded).toBe(false)
expect(mockDbLimit).not.toHaveBeenCalled()
})
it('no-ops when the workspace is not org-owned', async () => {
mockDbLimit.mockResolvedValue([{ organizationId: null }])
const result = await checkOrgMemberUsageLimit('user-1', 'ws-1')
expect(result.isExceeded).toBe(false)
expect(mockGetOrgMemberUsageLimit).not.toHaveBeenCalled()
})
it('no-ops when the member has no cap set', async () => {
mockGetOrgMemberUsageLimit.mockResolvedValue(null)
const result = await checkOrgMemberUsageLimit('user-1', 'ws-1')
expect(result.isExceeded).toBe(false)
expect(mockGetOrgMemberWorkspaceUsage).not.toHaveBeenCalled()
})
it('does not block when usage is below the cap', async () => {
mockGetOrgMemberWorkspaceUsage.mockResolvedValue(1)
mockGetOrgMemberUsageLimit.mockResolvedValue(2)
const result = await checkOrgMemberUsageLimit('user-1', 'ws-1')
expect(result.isExceeded).toBe(false)
expect(result.currentUsage).toBe(1)
expect(result.limit).toBe(2)
expect(result.message).toBeUndefined()
})
it('blocks when usage meets the cap (>=)', async () => {
mockGetOrgMemberWorkspaceUsage.mockResolvedValue(2)
mockGetOrgMemberUsageLimit.mockResolvedValue(2)
const result = await checkOrgMemberUsageLimit('user-1', 'ws-1')
expect(result.isExceeded).toBe(true)
expect(result.message).toBeTruthy()
})
it('blocks all usage when the cap is 0', async () => {
mockGetOrgMemberUsageLimit.mockResolvedValue(0)
mockGetOrgMemberWorkspaceUsage.mockResolvedValue(0)
const result = await checkOrgMemberUsageLimit('user-1', 'ws-1')
expect(result.isExceeded).toBe(true)
})
it('fails open when an unexpected error occurs', async () => {
mockDbLimit.mockRejectedValue(new Error('db down'))
const result = await checkOrgMemberUsageLimit('user-1', 'ws-1')
expect(result.isExceeded).toBe(false)
})
it('fails open when org-workspace usage cannot be computed (unexpected error)', async () => {
mockGetOrgMemberWorkspaceUsage.mockRejectedValue(new Error('db unavailable'))
const result = await checkOrgMemberUsageLimit('user-1', 'ws-1')
expect(result.isExceeded).toBe(false)
})
})
@@ -0,0 +1,526 @@
import { db } from '@sim/db'
import { member, userStats, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq } from 'drizzle-orm'
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import {
getHighestPrioritySubscription,
type HighestPrioritySubscription,
} from '@/lib/billing/core/plan'
import { getPooledOrgCurrentPeriodCost, getUserUsageLimit } from '@/lib/billing/core/usage'
import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import {
computeDailyRefreshConsumed,
getOrgMemberRefreshBounds,
} from '@/lib/billing/credits/daily-refresh'
import {
getOrgMemberUsageLimit,
getOrgMemberWorkspaceUsage,
} from '@/lib/billing/organizations/member-limits'
import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
const logger = createLogger('UsageMonitor')
const WARNING_THRESHOLD = 80
interface UsageData {
percentUsed: number
isWarning: boolean
isExceeded: boolean
currentUsage: number
limit: number
/**
* Whether the returned values are this user's individual slice or the
* organization's pooled total/cap. When an org pool is the blocker,
* the pooled values are surfaced here so error messages reflect it.
*/
scope: 'user' | 'organization'
/** Present only when `scope === 'organization'`. */
organizationId: string | null
}
async function computePooledOrgUsage(
organizationId: string,
sub: {
plan: string | null
seats: number | null
periodStart: Date | null
periodEnd: Date | null
}
): Promise<number> {
const { memberIds, currentPeriodCost } = await getPooledOrgCurrentPeriodCost(organizationId)
if (memberIds.length === 0) return 0
const billingPeriod =
sub.periodStart && sub.periodEnd
? { start: sub.periodStart, end: sub.periodEnd }
: defaultBillingPeriod()
const ledgerUsage = await getBillingPeriodUsageCost(
{ type: 'organization', id: organizationId },
billingPeriod
)
return applyOrgRefresh(organizationId, sub, currentPeriodCost + ledgerUsage, memberIds)
}
/**
* Checks a user's cost usage against their subscription plan limit
* and returns usage information including whether they're approaching the limit
*/
export async function checkUsageStatus(
userId: string,
preloadedSubscription?: HighestPrioritySubscription
): Promise<UsageData> {
try {
if (!isBillingEnabled) {
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
const currentUsage =
statsRecords.length > 0 ? toNumber(toDecimal(statsRecords[0].currentPeriodCost)) : 0
return {
percentUsed: Math.min((currentUsage / 1000) * 100, 100),
isWarning: false,
isExceeded: false,
currentUsage,
limit: 1000,
scope: 'user',
organizationId: null,
}
}
const sub =
preloadedSubscription !== undefined
? preloadedSubscription
: await getHighestPrioritySubscription(userId)
const limit = await getUserUsageLimit(userId, sub)
logger.info('Using stored usage limit', { userId, limit })
const subIsOrgScoped = isOrgScopedSubscription(sub, userId)
const scope: 'user' | 'organization' = subIsOrgScoped ? 'organization' : 'user'
const organizationId: string | null = subIsOrgScoped && sub ? sub.referenceId : null
if (subIsOrgScoped && sub) {
const currentUsage = await computePooledOrgUsage(sub.referenceId, sub)
return buildUsageData({ currentUsage, limit, scope, organizationId })
}
const statsRecords = await db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (statsRecords.length === 0) {
logger.info('No usage stats found for user', { userId, limit })
return {
percentUsed: 0,
isWarning: false,
isExceeded: false,
currentUsage: 0,
limit,
scope: 'user',
organizationId: null,
}
}
const billingPeriod =
sub?.periodStart && sub.periodEnd
? { start: sub.periodStart, end: sub.periodEnd }
: defaultBillingPeriod()
const ledgerUsage = await getBillingPeriodUsageCost({ type: 'user', id: userId }, billingPeriod)
let currentUsage = toNumber(toDecimal(statsRecords[0].currentPeriodCost)) + ledgerUsage
if (sub && isPaid(sub.plan) && sub.periodStart) {
const planDollars = getPlanTierDollars(sub.plan)
if (planDollars > 0) {
const refresh = await computeDailyRefreshConsumed({
userIds: [userId],
periodStart: sub.periodStart,
periodEnd: sub.periodEnd ?? null,
planDollars,
billingEntity: { type: 'user', id: userId },
})
currentUsage = Math.max(0, currentUsage - refresh)
}
}
return buildUsageData({ currentUsage, limit, scope, organizationId })
} catch (error) {
logger.error('Error checking usage status', {
error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
userId,
})
logger.error('Cannot determine usage status - blocking execution', {
userId,
error: toError(error).message,
})
return {
percentUsed: 100,
isWarning: false,
isExceeded: true,
currentUsage: 0,
limit: 0,
scope: 'user',
organizationId: null,
}
}
}
async function applyOrgRefresh(
organizationId: string,
sub: {
plan: string | null
seats: number | null
periodStart: Date | null
periodEnd: Date | null
},
currentUsage: number,
preloadedMemberIds?: string[]
): Promise<number> {
if (!isPaid(sub.plan) || !sub.periodStart) {
return currentUsage
}
const memberIds =
preloadedMemberIds ?? (await getPooledOrgCurrentPeriodCost(organizationId)).memberIds
if (memberIds.length === 0) return currentUsage
const planDollars = getPlanTierDollars(sub.plan)
if (planDollars <= 0) return currentUsage
const userBounds = await getOrgMemberRefreshBounds(organizationId, sub.periodStart)
const refresh = await computeDailyRefreshConsumed({
userIds: memberIds,
periodStart: sub.periodStart,
periodEnd: sub.periodEnd ?? null,
planDollars,
seats: sub.seats || 1,
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
billingEntity: { type: 'organization', id: organizationId },
})
return Math.max(0, currentUsage - refresh)
}
function buildUsageData(params: {
currentUsage: number
limit: number
scope: 'user' | 'organization'
organizationId: string | null
}): UsageData {
const { currentUsage, limit, scope, organizationId } = params
const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 100
const isExceeded = currentUsage >= limit
const isWarning = !isExceeded && percentUsed >= WARNING_THRESHOLD
logger.info('Final usage statistics', {
currentUsage,
limit,
percentUsed,
isWarning,
isExceeded,
scope,
organizationId,
})
return {
percentUsed,
isWarning,
isExceeded,
currentUsage,
limit,
scope,
organizationId,
}
}
/**
* Displays a notification to the user when they're approaching their usage limit
* Can be called on app startup or before executing actions that might incur costs
*/
async function checkAndNotifyUsage(userId: string): Promise<void> {
try {
if (!isBillingEnabled) {
return
}
const usageData = await checkUsageStatus(userId)
if (usageData.isExceeded) {
logger.warn('User has exceeded usage limits', {
userId,
usage: usageData.currentUsage,
limit: usageData.limit,
})
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('usage-exceeded', {
detail: { usageData },
})
)
}
} else if (usageData.isWarning) {
logger.info('User approaching usage limits', {
userId,
usage: usageData.currentUsage,
limit: usageData.limit,
percent: usageData.percentUsed,
})
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('usage-warning', {
detail: { usageData },
})
)
}
}
} catch (error) {
logger.error('Error in usage notification system', { error, userId })
}
}
/**
* Whether an account (or its organization owner) is billing-blocked — frozen for
* a dispute or flagged for a payment issue. Independent of usage limits, so it can
* gate paths that are otherwise exempt from metered caps (e.g. BYOK wand): a
* frozen account is locked out everywhere.
*/
export async function checkBillingBlocked(
userId: string
): Promise<{ blocked: boolean; message?: string }> {
const stats = await db
.select({ blocked: userStats.billingBlocked, blockedReason: userStats.billingBlockedReason })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (stats.length > 0 && stats[0].blocked) {
return {
blocked: true,
message:
stats[0].blockedReason === 'dispute'
? 'Account frozen. Please contact support to resolve this issue.'
: 'Billing issue detected. Please update your payment method to continue.',
}
}
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
for (const m of memberships) {
const owners = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
.limit(1)
if (owners.length > 0) {
const ownerStats = await db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, owners[0].userId))
.limit(1)
if (ownerStats.length > 0 && ownerStats[0].blocked) {
return {
blocked: true,
message:
ownerStats[0].blockedReason === 'dispute'
? 'Organization account frozen. Please contact support to resolve this issue.'
: 'Organization billing issue. Please contact your organization owner.',
}
}
}
}
return { blocked: false }
}
/**
* Server-side function to check if a user has exceeded their usage limits
* For use in API routes, webhooks, and scheduled executions
*
* @param userId The ID of the user to check
* @returns An object containing the exceeded status and usage details
*/
export async function checkServerSideUsageLimits(
userId: string,
preloadedSubscription?: HighestPrioritySubscription
): Promise<{
isExceeded: boolean
currentUsage: number
limit: number
message?: string
}> {
try {
if (!isBillingEnabled) {
return {
isExceeded: false,
currentUsage: 0,
limit: 99999,
}
}
logger.info('Server-side checking usage limits for user', { userId })
const stats = await db
.select({ current: userStats.currentPeriodCost })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
const currentUsage = stats.length > 0 ? toNumber(toDecimal(stats[0].current)) : 0
const blocked = await checkBillingBlocked(userId)
if (blocked.blocked) {
return { isExceeded: true, currentUsage, limit: 0, message: blocked.message }
}
const usageData = await checkUsageStatus(userId, preloadedSubscription)
const formattedUsage = (usageData.currentUsage ?? 0).toFixed(2)
const formattedLimit = (usageData.limit ?? 0).toFixed(2)
const exceededMessage =
usageData.scope === 'organization'
? `Organization usage limit exceeded: $${formattedUsage} pooled of $${formattedLimit} organization limit. Ask a team admin to raise the organization usage limit to continue.`
: `Usage limit exceeded: $${formattedUsage} used of $${formattedLimit} limit. Please upgrade your plan or raise your usage limit to continue.`
return {
isExceeded: usageData.isExceeded,
currentUsage: usageData.currentUsage,
limit: usageData.limit,
message: usageData.isExceeded ? exceededMessage : undefined,
}
} catch (error) {
logger.error('Error in server-side usage limit check', {
error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
userId,
})
logger.error('Cannot determine usage limits - blocking execution', {
userId,
error: toError(error).message,
})
return {
isExceeded: true,
currentUsage: 0,
limit: 0,
message:
error instanceof Error && error.message.includes('No user stats record found')
? 'User account not properly initialized. Please contact support.'
: 'Unable to determine usage limits. Execution blocked for security. Please contact support.',
}
}
}
/**
* Per-member usage cap for the organization that owns the given workspace.
*
* Hosted-only and independent of the pooled org limit
* ({@link checkServerSideUsageLimits}). No-ops (isExceeded:false) when the
* feature is off (`!isHosted` / `!isBillingEnabled`), the workspace is not
* org-owned, or the actor has no per-member cap. Otherwise compares the actor's
* current-period usage inside the org's workspaces against their cap
* (`usage >= limit` blocks).
*
* Fails open on unexpected error: this is a secondary, additive gate, so a
* transient fault must not block execution that the primary pooled/personal
* check already allowed.
*/
export async function checkOrgMemberUsageLimit(
userId: string,
workspaceId: string
): Promise<{
isExceeded: boolean
currentUsage: number
limit: number | null
message?: string
}> {
try {
if (!isHosted || !isBillingEnabled || !workspaceId) {
return { isExceeded: false, currentUsage: 0, limit: null }
}
const [workspaceRow] = await db
.select({ organizationId: workspace.organizationId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
const organizationId = workspaceRow?.organizationId
if (!organizationId) {
return { isExceeded: false, currentUsage: 0, limit: null }
}
// Resolve the cap first and short-circuit when unset (the common case); only
// then is computing usage worthwhile. Kept sequential, not raced, to avoid a
// usage query on every uncapped member's execution.
const limit = await getOrgMemberUsageLimit(organizationId, userId)
if (limit === null) {
return { isExceeded: false, currentUsage: 0, limit: null }
}
const usage = await getOrgMemberWorkspaceUsage(organizationId, userId)
const isExceeded = usage >= limit
return {
isExceeded,
currentUsage: usage,
limit,
message: isExceeded
? `Member credit limit exceeded: ${dollarsToCredits(usage).toLocaleString()} of ${dollarsToCredits(limit).toLocaleString()} credits used for this organization's workspaces. Ask an organization admin to raise your credit limit to continue.`
: undefined,
}
} catch (error) {
logger.error('Error checking per-member org usage limit', {
error: toError(error).message,
userId,
workspaceId,
})
return { isExceeded: false, currentUsage: 0, limit: null }
}
}
/**
* Unified usage gate for an actor: the pooled/personal cap first
* ({@link checkServerSideUsageLimits}), then the per-member org-workspace cap
* ({@link checkOrgMemberUsageLimit}) when a workspace is in scope. Returns the
* first exceeded result so every billable surface (workflow exec, copilot,
* voice, wand, enrichment, KB indexing) can gate on a single
* `{ isExceeded, message }`. `scope` distinguishes a pooled cap from a per-member
* cap so clients can hide an "Upgrade" affordance a capped member can't act on (a
* per-member cap is only raisable by an org admin).
*/
export async function checkActorUsageLimits(
userId: string,
workspaceId?: string | null
): Promise<{ isExceeded: boolean; message?: string; scope?: 'pooled' | 'member' }> {
const pooled = await checkServerSideUsageLimits(userId)
if (pooled.isExceeded) {
return { isExceeded: true, message: pooled.message, scope: 'pooled' }
}
if (workspaceId) {
const member = await checkOrgMemberUsageLimit(userId, workspaceId)
if (member.isExceeded) {
return { isExceeded: true, message: member.message, scope: 'member' }
}
}
return { isExceeded: false }
}
@@ -0,0 +1,152 @@
/**
* @vitest-environment node
*/
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockFlags } = vi.hoisted(() => ({
mockFlags: { isBillingEnabled: true },
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isBillingEnabled() {
return mockFlags.isBillingEnabled
},
isHosted: true,
}))
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
import {
releaseExecutionSlot,
reserveExecutionSlot,
resolveBillingEntityKey,
} from '@/lib/billing/calculations/usage-reservation'
const evalMock = vi.fn()
const getdelMock = vi.fn()
const zremMock = vi.fn()
const fakeRedis = { eval: evalMock, getdel: getdelMock, zrem: zremMock }
const baseParams = {
userId: 'user-1',
executionId: 'exec-1',
subscription: { plan: 'free' as const, referenceId: 'user-1' },
currentUsage: 0,
limit: 5,
}
describe('usage-reservation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFlags.isBillingEnabled = true
redisConfigMockFns.mockGetRedisClient.mockReturnValue(fakeRedis)
})
describe('resolveBillingEntityKey', () => {
it('keys personal subscriptions by user', () => {
expect(resolveBillingEntityKey('user-1', { referenceId: 'user-1' })).toBe('user:user-1')
})
it('keys org-scoped subscriptions by organization', () => {
expect(resolveBillingEntityKey('user-1', { referenceId: 'org-9' })).toBe('org:org-9')
})
})
describe('reserveExecutionSlot', () => {
it('admits when the reservation script returns 1', async () => {
evalMock.mockResolvedValueOnce(1)
const result = await reserveExecutionSlot(baseParams)
expect(result.reserved).toBe(true)
expect(evalMock).toHaveBeenCalledTimes(1)
})
it('rejects when the reservation script returns 0 (slots full)', async () => {
evalMock.mockResolvedValueOnce(0)
const result = await reserveExecutionSlot(baseParams)
expect(result.reserved).toBe(false)
})
it('passes the free-tier concurrency cap and headroom slots to the script', async () => {
evalMock.mockResolvedValueOnce(1)
await reserveExecutionSlot(baseParams)
const args = evalMock.mock.calls[0]
// eval(script, numKeys, inflightKey, pointerKey, now, expiry, maxConc, headroomSlots, member, entityKey, pttl)
expect(args[2]).toBe('usage:inflight:user:user-1')
expect(args[3]).toBe('usage:reservation:exec-1')
expect(args[6]).toBe('15')
expect(args[7]).toBe('1000')
expect(args[8]).toBe('exec-1')
expect(args[9]).toBe('user:user-1')
})
it('reserves against the org entity for org-scoped subscriptions', async () => {
evalMock.mockResolvedValueOnce(1)
await reserveExecutionSlot({
...baseParams,
subscription: { plan: 'team', referenceId: 'org-9' },
})
const args = evalMock.mock.calls[0]
expect(args[2]).toBe('usage:inflight:org:org-9')
expect(args[6]).toBe('150')
})
it('clamps negative headroom to zero slots', async () => {
evalMock.mockResolvedValueOnce(0)
await reserveExecutionSlot({ ...baseParams, currentUsage: 10, limit: 5 })
expect(evalMock.mock.calls[0][7]).toBe('0')
})
it('fails open (admits) when billing enforcement is disabled', async () => {
mockFlags.isBillingEnabled = false
const result = await reserveExecutionSlot(baseParams)
expect(result.reserved).toBe(true)
expect(evalMock).not.toHaveBeenCalled()
})
it('fails open (admits) when Redis is unavailable', async () => {
redisConfigMockFns.mockGetRedisClient.mockReturnValue(null)
const result = await reserveExecutionSlot(baseParams)
expect(result.reserved).toBe(true)
expect(evalMock).not.toHaveBeenCalled()
})
it('fails open (admits) when the reservation script throws', async () => {
evalMock.mockRejectedValueOnce(new Error('connection lost'))
const result = await reserveExecutionSlot(baseParams)
expect(result.reserved).toBe(true)
})
})
describe('releaseExecutionSlot', () => {
it('reads the pointer then removes the slot from that entity set', async () => {
getdelMock.mockResolvedValueOnce('org:org-9')
await releaseExecutionSlot('exec-1')
expect(getdelMock).toHaveBeenCalledWith('usage:reservation:exec-1')
expect(zremMock).toHaveBeenCalledWith('usage:inflight:org:org-9', 'exec-1')
})
it('does not touch the in-flight set when the pointer is already gone', async () => {
getdelMock.mockResolvedValueOnce(null)
await releaseExecutionSlot('exec-1')
expect(zremMock).not.toHaveBeenCalled()
})
it('uses only single-key commands (cluster-safe; no key built inside Lua)', async () => {
getdelMock.mockResolvedValueOnce('user:user-1')
await releaseExecutionSlot('exec-1')
expect(evalMock).not.toHaveBeenCalled()
})
it('is a no-op when billing enforcement is disabled', async () => {
mockFlags.isBillingEnabled = false
await releaseExecutionSlot('exec-1')
expect(getdelMock).not.toHaveBeenCalled()
})
it('swallows release errors', async () => {
getdelMock.mockRejectedValueOnce(new Error('boom'))
await expect(releaseExecutionSlot('exec-1')).resolves.toBeUndefined()
})
})
})
@@ -0,0 +1,207 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { getRedisClient } from '@/lib/core/config/redis'
import { getExecutionReservationTtlMs } from '@/lib/core/execution-limits'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
const logger = createLogger('UsageReservation')
/**
* Maximum number of simultaneously in-flight (admitted but not-yet-costed)
* executions a single billing entity may hold at once.
*
* The usage-cap admission gate reads already-recorded cost, but cost is only
* written when an execution finishes. Without a reservation, N parallel
* executions all read the same pre-burst usage, all pass the cap, and all run —
* collectively spending far past the cap before any cost lands in the ledger
* (free-tier abuse / hard-cap defeat). Bounding the number of in-flight
* executions per billing entity bounds the worst-case overshoot to roughly this
* many executions' worth of spend.
*/
const MAX_CONCURRENT_EXECUTIONS: Record<SubscriptionPlan, number> = {
free: 15,
pro: 75,
team: 150,
enterprise: 300,
}
/**
* Per-slot reserved cost estimate (dollars). The guaranteed-minimum charge
* every execution incurs, used to taper admission as recorded usage approaches
* the cap: an entity may hold at most `floor(headroom / estimate)` concurrent
* slots, keeping `recordedUsage + reservedSlots * estimate <= limit`. A lone
* execution is never blocked on headroom alone — the recorded-usage gate
* (`isExceeded`) governs the single-execution case, so the only residual
* overshoot is the one already inherent to admission (cost is unknown until the
* execution finishes).
*/
const SLOT_COST_ESTIMATE = BASE_EXECUTION_CHARGE
const INFLIGHT_KEY_PREFIX = 'usage:inflight:'
const POINTER_KEY_PREFIX = 'usage:reservation:'
/**
* Atomically admit an execution only when both the per-entity concurrency cap
* and the remaining usage headroom permit it, then record the in-flight slot.
*
* Prune expired members (crash safety) -> `count = ZCARD` -> reject when
* `count >= min(maxConcurrency, max(1, headroomSlots))` -> otherwise `ZADD` the
* slot, refresh the set TTL, and write the per-execution pointer for release.
* The `max(1, ...)` floor guarantees a lone execution is never blocked on
* headroom alone; concurrency above the first slot still tapers with headroom.
*/
const RESERVE_SCRIPT = `
local now = tonumber(ARGV[1])
local expiryScore = tonumber(ARGV[2])
local maxConcurrency = tonumber(ARGV[3])
local headroomSlots = tonumber(ARGV[4])
local pttl = tonumber(ARGV[7])
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
local count = redis.call('ZCARD', KEYS[1])
if headroomSlots < 1 then headroomSlots = 1 end
local allowed = maxConcurrency
if headroomSlots < allowed then allowed = headroomSlots end
if count >= allowed then
return 0
end
redis.call('ZADD', KEYS[1], expiryScore, ARGV[5])
redis.call('PEXPIRE', KEYS[1], pttl)
redis.call('SET', KEYS[2], ARGV[6], 'PX', pttl)
return 1
`
/**
* Stable per-entity reservation key. Org-scoped subscriptions reserve against
* the organization's pooled cap; everyone else against their personal cap —
* mirroring the entity the usage limit itself is enforced on.
*/
export function resolveBillingEntityKey(
userId: string,
subscription: { referenceId?: string | null } | null | undefined
): string {
if (isOrgScopedSubscription(subscription, userId) && subscription?.referenceId) {
return `org:${subscription.referenceId}`
}
return `user:${userId}`
}
function getMaxConcurrentExecutions(plan: string | null | undefined): number {
return MAX_CONCURRENT_EXECUTIONS[getPlanTypeForLimits(plan) as SubscriptionPlan]
}
export interface ReserveExecutionSlotParams {
userId: string
executionId: string
subscription: { plan?: string | null; referenceId?: string | null } | null | undefined
/** Recorded usage for the billing entity at admission time (dollars). */
currentUsage: number
/** The entity's usage cap (dollars). */
limit: number
}
export interface ReserveExecutionSlotResult {
reserved: boolean
}
/**
* Atomic admission reservation that closes the usage-cap check-then-use race.
*
* No-ops (admits) when billing enforcement is off or Redis is unavailable —
* the caller's recorded-usage check still runs in those cases, and failing open
* here matches the rate limiter rather than turning a Redis blip into a full
* execution outage.
*/
export async function reserveExecutionSlot(
params: ReserveExecutionSlotParams
): Promise<ReserveExecutionSlotResult> {
if (!isBillingEnabled) {
return { reserved: true }
}
const redis = getRedisClient()
if (!redis) {
return { reserved: true }
}
const { userId, executionId, subscription, currentUsage, limit } = params
const entityKey = resolveBillingEntityKey(userId, subscription)
const maxConcurrency = getMaxConcurrentExecutions(subscription?.plan)
const headroom = Math.max(0, limit - currentUsage)
const headroomSlots = Math.floor(headroom / SLOT_COST_ESTIMATE)
const ttlMs = getExecutionReservationTtlMs()
const now = Date.now()
const expiryScore = now + ttlMs
try {
const result = await redis.eval(
RESERVE_SCRIPT,
2,
`${INFLIGHT_KEY_PREFIX}${entityKey}`,
`${POINTER_KEY_PREFIX}${executionId}`,
now.toString(),
expiryScore.toString(),
maxConcurrency.toString(),
headroomSlots.toString(),
executionId,
entityKey,
ttlMs.toString()
)
const reserved = result === 1
if (!reserved) {
logger.warn('Execution admission throttled — concurrency/usage reservation full', {
entityKey,
executionId,
maxConcurrency,
headroomSlots,
})
}
return { reserved }
} catch (error) {
logger.error('Usage reservation error — failing open (admitting execution)', {
error: toError(error).message,
entityKey,
executionId,
})
return { reserved: true }
}
}
/**
* Release the in-flight reservation held for an execution. Best-effort and
* idempotent — safe to call for executions that never reserved (Redis down,
* billing disabled) or are released more than once. Must NOT be called for a
* paused execution that may still resume.
*
* Uses discrete single-key commands rather than a Lua script that rebuilds the
* in-flight key from the pointer value: the entity that owns the slot is only
* known after reading the pointer, and constructing a key inside Lua bypasses
* the `KEYS` declaration that Redis Cluster relies on for slot routing.
*/
export async function releaseExecutionSlot(executionId: string): Promise<void> {
if (!isBillingEnabled) {
return
}
const redis = getRedisClient()
if (!redis) {
return
}
try {
const pointerKey = `${POINTER_KEY_PREFIX}${executionId}`
const entityKey = await redis.getdel(pointerKey)
if (entityKey) {
await redis.zrem(`${INFLIGHT_KEY_PREFIX}${entityKey}`, executionId)
}
} catch (error) {
logger.warn('Failed to release usage reservation', {
error: toError(error).message,
executionId,
})
}
}
+380
View File
@@ -0,0 +1,380 @@
import { db } from '@sim/db'
import type { DataRetentionSettings, WorkspaceMode } from '@sim/db/schema'
import { organization, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { tasks } from '@trigger.dev/sdk'
import { and, asc, eq, gt, isNull } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/subscription'
import { getPlanType, type PlanCategory } from '@/lib/billing/plan-helpers'
import { type RetentionHoursKey, resolveEffectiveRetentionHours } from '@/lib/billing/retention'
import { chunkArray } from '@/lib/cleanup/batch-delete'
import { getJobQueue } from '@/lib/core/async-jobs'
import { shouldExecuteInline } from '@/lib/core/async-jobs/config'
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import type { EnqueueOptions } from '@/lib/core/async-jobs/types'
import { isTriggerAvailable } from '@/lib/knowledge/documents/service'
import { isOrganizationWorkspace, WORKSPACE_MODE } from '@/lib/workspaces/policy'
const logger = createLogger('RetentionDispatcher')
/** Trigger.dev's documented cap on items per `batchTrigger` call (SDK 4.3.1+). */
const BATCH_TRIGGER_CHUNK_SIZE = 1000
const WORKSPACE_SCOPE_PAGE_SIZE = 500
/** Bounds per-run memory + DB connections regardless of plan size. */
const WORKSPACES_PER_CLEANUP_CHUNK = 500
export type CleanupJobType = 'cleanup-logs' | 'cleanup-soft-deletes' | 'cleanup-tasks'
export type NonEnterprisePlan = Exclude<PlanCategory, 'enterprise'>
const NON_ENTERPRISE_PLANS = ['free', 'pro', 'team'] as const satisfies readonly NonEnterprisePlan[]
export interface CleanupJobPayload {
plan: PlanCategory
workspaceIds: string[]
retentionHours: number
label: string
/** Set on exactly one chunk per dispatch so plan-wide housekeeping runs once. */
runGlobalHousekeeping?: boolean
}
interface CleanupJobConfig {
key: RetentionHoursKey
defaults: Record<PlanCategory, number | null>
}
interface WorkspaceCleanupScopeRow {
id: string
billedAccountUserId: string
organizationId: string | null
workspaceMode: WorkspaceMode
organizationSettings: DataRetentionSettings | null
}
const DAY = 24
type PlanResolutionEntry = readonly [string, PlanCategory]
function getCleanupConcurrencyKey(jobType: CleanupJobType): string {
return `cleanup:${jobType}`
}
/**
* Single source of truth for cleanup retention: which key each job type reads
* from `organization.dataRetentionSettings`, and the default retention (in
* hours) per plan. Enterprise is always `null` here — enterprise orgs must
* set their own value.
*/
export const CLEANUP_CONFIG = {
'cleanup-logs': {
key: 'logRetentionHours',
defaults: { free: 30 * DAY, pro: null, team: null, enterprise: null },
},
'cleanup-soft-deletes': {
key: 'softDeleteRetentionHours',
defaults: { free: 30 * DAY, pro: 90 * DAY, team: 90 * DAY, enterprise: null },
},
'cleanup-tasks': {
key: 'taskCleanupHours',
defaults: { free: null, pro: null, team: null, enterprise: null },
},
} as const satisfies Record<CleanupJobType, CleanupJobConfig>
async function listActiveWorkspaceCleanupScopeRowsPage(
afterId: string | null
): Promise<WorkspaceCleanupScopeRow[]> {
const rows = await db
.select({
id: workspace.id,
billedAccountUserId: workspace.billedAccountUserId,
organizationId: workspace.organizationId,
workspaceMode: workspace.workspaceMode,
organizationSettings: organization.dataRetentionSettings,
})
.from(workspace)
.leftJoin(organization, eq(organization.id, workspace.organizationId))
.where(
afterId
? and(isNull(workspace.archivedAt), gt(workspace.id, afterId))
: isNull(workspace.archivedAt)
)
.orderBy(asc(workspace.id))
.limit(WORKSPACE_SCOPE_PAGE_SIZE)
return rows.map((row) => ({
...row,
organizationSettings: (row.organizationSettings as DataRetentionSettings | null) ?? null,
}))
}
async function resolvePersonalPlanTypesByBilledUserId(
rows: WorkspaceCleanupScopeRow[]
): Promise<Map<string, PlanCategory>> {
const billedUserIds = Array.from(new Set(rows.map((row) => row.billedAccountUserId)))
const entries = await Promise.all(
billedUserIds.map(async (userId) => {
try {
const subscription = await getHighestPriorityPersonalSubscription(userId, {
onError: 'throw',
})
return [userId, getPlanType(subscription?.plan)] as const
} catch (error) {
logger.error('Skipping cleanup for billed user after plan lookup failed', {
userId,
error,
})
return null
}
})
)
return new Map(entries.filter((entry): entry is PlanResolutionEntry => entry !== null))
}
async function resolvePlanTypesByWorkspaceId(
rows: WorkspaceCleanupScopeRow[]
): Promise<Map<string, PlanCategory>> {
const userScopedRows = rows.filter((row) => row.workspaceMode !== WORKSPACE_MODE.ORGANIZATION)
const userPlanByBilledUserId = await resolvePersonalPlanTypesByBilledUserId(userScopedRows)
const entries = await Promise.all(
rows.map(async (row) => {
if (row.workspaceMode === WORKSPACE_MODE.ORGANIZATION) {
const organizationId = isOrganizationWorkspace(row) ? row.organizationId : null
if (!organizationId) {
logger.error('Skipping cleanup for malformed organization workspace', {
workspaceId: row.id,
organizationId: row.organizationId,
})
return null
}
try {
const subscription = await getOrganizationSubscription(organizationId, {
onError: 'throw',
})
if (!subscription) {
logger.warn('Skipping cleanup for organization workspace without an org subscription', {
workspaceId: row.id,
organizationId,
})
return null
}
return [row.id, getPlanType(subscription?.plan)] as const
} catch (error) {
logger.error('Skipping cleanup for organization workspace after plan lookup failed', {
workspaceId: row.id,
organizationId,
error,
})
return null
}
}
const plan = userPlanByBilledUserId.get(row.billedAccountUserId)
if (plan === undefined) {
return null
}
return [row.id, plan] as const
})
)
return new Map(entries.filter((entry): entry is PlanResolutionEntry => entry !== null))
}
async function buildCleanupRunner(jobType: CleanupJobType): Promise<EnqueueOptions['runner']> {
const cleanupRunner = await (async () => {
switch (jobType) {
case 'cleanup-logs':
return (await import('@/background/cleanup-logs')).runCleanupLogs
case 'cleanup-soft-deletes':
return (await import('@/background/cleanup-soft-deletes')).runCleanupSoftDeletes
case 'cleanup-tasks':
return (await import('@/background/cleanup-tasks')).runCleanupTasks
}
})()
return ((payload) => cleanupRunner(payload as CleanupJobPayload)) as EnqueueOptions['runner']
}
/** Job type → plan whose housekeeping is global, not per-workspace. */
const GLOBAL_HOUSEKEEPING_PLAN: Partial<Record<CleanupJobType, PlanCategory>> = {
'cleanup-logs': 'free',
}
async function forEachCleanupChunk(
jobType: CleanupJobType,
onChunk: (payload: CleanupJobPayload) => Promise<void>
): Promise<{ chunkCount: number; workspaceCount: number }> {
const config = CLEANUP_CONFIG[jobType]
const chunkCountByPlan: Partial<Record<NonEnterprisePlan, number>> = {}
const housekeepingPlan = GLOBAL_HOUSEKEEPING_PLAN[jobType]
let housekeepingAssigned = false
let workspaceCount = 0
let chunkCount = 0
let afterId: string | null = null
const emitChunk = async (payload: CleanupJobPayload) => {
if (payload.plan === housekeepingPlan && !housekeepingAssigned) {
payload.runGlobalHousekeeping = true
housekeepingAssigned = true
}
chunkCount++
await onChunk(payload)
}
while (true) {
const rows = await listActiveWorkspaceCleanupScopeRowsPage(afterId)
if (rows.length === 0) break
afterId = rows[rows.length - 1].id
const planByWorkspaceId = await resolvePlanTypesByWorkspaceId(rows)
for (const plan of NON_ENTERPRISE_PLANS) {
const retentionHours = config.defaults[plan]
if (retentionHours === null) continue
const workspaceIds = rows
.filter((row) => planByWorkspaceId.get(row.id) === plan)
.map((row) => row.id)
if (workspaceIds.length === 0) continue
workspaceCount += workspaceIds.length
const planChunks = chunkArray(workspaceIds, WORKSPACES_PER_CLEANUP_CHUNK)
for (const ws of planChunks) {
const chunkNumber = (chunkCountByPlan[plan] ?? 0) + 1
chunkCountByPlan[plan] = chunkNumber
await emitChunk({
plan,
workspaceIds: ws,
retentionHours,
label: `${plan}/${chunkNumber}`,
})
}
}
for (const row of rows) {
if (planByWorkspaceId.get(row.id) !== 'enterprise') continue
const hours = resolveEffectiveRetentionHours({
orgSettings: row.organizationSettings,
workspaceId: row.id,
key: config.key,
})
if (hours == null) continue
workspaceCount++
await emitChunk({
plan: 'enterprise',
workspaceIds: [row.id],
retentionHours: hours,
label: `enterprise/${row.id}`,
})
}
}
if (housekeepingPlan && housekeepingPlan !== 'enterprise' && !housekeepingAssigned) {
const retentionHours = config.defaults[housekeepingPlan]
if (retentionHours != null) {
await emitChunk({
plan: housekeepingPlan,
workspaceIds: [],
retentionHours,
label: `${housekeepingPlan}/housekeeping`,
runGlobalHousekeeping: true,
})
}
}
return { chunkCount, workspaceCount }
}
/**
* Resolve the workspace set + retention cutoff once, then fan out one task
* run per `WORKSPACES_PER_CLEANUP_CHUNK` workspaces via `tasks.batchTrigger`.
* Falls back to `JobQueueBackend` enqueue when Trigger.dev isn't available.
*/
export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{
jobIds: string[]
jobCount: number
chunkCount: number
workspaceCount: number
}> {
const jobIds: string[] = []
let succeeded = 0
let failed = 0
if (isTriggerAvailable()) {
let batch: CleanupJobPayload[] = []
const flushBatch = async () => {
if (batch.length === 0) return
const currentBatch = batch
batch = []
const region = await resolveTriggerRegion()
const batchResult = await tasks.batchTrigger(
jobType,
currentBatch.map((payload) => ({
payload,
options: {
tags: [`plan:${payload.plan}`, `jobType:${jobType}`],
concurrencyKey: getCleanupConcurrencyKey(jobType),
region,
},
}))
)
jobIds.push(batchResult.batchId)
succeeded += currentBatch.length
}
const { chunkCount, workspaceCount } = await forEachCleanupChunk(jobType, async (payload) => {
batch.push(payload)
if (batch.length >= BATCH_TRIGGER_CHUNK_SIZE) {
await flushBatch()
}
})
await flushBatch()
logger.info(
`[${jobType}] Trigger cleanup chunks: ${succeeded} dispatched in ${jobIds.length} batch(es)`
)
return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount }
}
const inlineRunner = shouldExecuteInline() ? await buildCleanupRunner(jobType) : undefined
if (inlineRunner) {
const { chunkCount, workspaceCount } = await forEachCleanupChunk(jobType, async (payload) => {
try {
await inlineRunner(payload, new AbortController().signal)
jobIds.push(`inline:${jobType}:${payload.label}`)
succeeded++
} catch (error) {
failed++
logger.error(`[${jobType}] Inline cleanup chunk failed:`, {
plan: payload.plan,
label: payload.label,
error,
})
}
})
logger.info(`[${jobType}] Inline cleanup chunks: ${succeeded} succeeded, ${failed} failed`)
return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount }
}
const jobQueue = await getJobQueue()
const { chunkCount, workspaceCount } = await forEachCleanupChunk(jobType, async (payload) => {
try {
const jobId = await jobQueue.enqueue(jobType, payload, {
concurrencyKey: getCleanupConcurrencyKey(jobType),
})
jobIds.push(jobId)
succeeded++
} catch (reason) {
failed++
logger.error(`[${jobType}] Failed to enqueue chunk:`, { reason })
}
})
logger.info(`[${jobType}] Chunk enqueue: ${succeeded} succeeded, ${failed} failed`)
return { jobIds, jobCount: jobIds.length, chunkCount, workspaceCount }
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Number of pills to display in usage indicators.
*/
export const USAGE_PILL_COUNT = 8
/**
* Usage percentage thresholds for visual states.
*/
export const USAGE_THRESHOLDS = {
/** Warning threshold (yellow/orange state) */
WARNING: 75,
/** Critical threshold (red state) */
CRITICAL: 90,
} as const
/**
* Color values for usage pill states using CSS variables
*/
export const USAGE_PILL_COLORS = {
/** Unfilled pill color (gray) */
UNFILLED: 'var(--surface-7)',
/** Normal filled pill color (blue) */
FILLED: 'var(--brand-secondary)',
/** Warning state pill color (yellow/orange) */
WARNING: 'var(--warning)',
/** Critical/limit reached pill color (red) */
AT_LIMIT: 'var(--text-error)',
} as const
+14
View File
@@ -0,0 +1,14 @@
export { USAGE_PILL_COLORS, USAGE_THRESHOLDS } from './consts'
export {
type CtaIntent,
type CtaVariant,
derivePlanView,
getUpgradeCardCta,
type PlanCardCta,
type PlanTier,
type PlanView,
resolvePlanTier,
type UpgradeCardId,
} from './plan-view'
export { useLimitUpgradeToast } from './use-limit-upgrade-toast'
export { getFilledPillColor, getSubscriptionAccessState } from './utils'
+118
View File
@@ -0,0 +1,118 @@
import { CREDIT_TIERS } from '@/lib/billing/constants'
import { getPlanTierCredits, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers'
/**
* Canonical client-side plan abstraction.
*
* Single source of truth for the plan-derived UI decisions shared across the
* upgrade page, the home credits chip, the sidebar usage indicator, and the
* settings billing page: which tier the user is on, the per-card CTA on the
* upgrade page, whether the upgrade page is accessible, and whether credit
* balances should be shown.
*
* Pure and framework-free — see {@link usePlanView} for the React Query wrapper.
*/
/** Credit-tier-resolved plan identity used to drive upgrade-page UI. */
export type PlanTier = 'free' | 'pro' | 'max' | 'enterprise'
/** The three plan cards rendered on the upgrade page. */
export type UpgradeCardId = 'pro' | 'max' | 'enterprise'
/** Chip variant a card's CTA renders with. */
export type CtaVariant = 'primary' | 'border-shadow'
/** What activating a card's CTA does, mapped to a handler by the consumer. */
export type CtaIntent = 'upgrade' | 'downgrade' | 'manage' | 'sales'
/** Plan-derived CTA descriptor for a single upgrade card. */
export interface PlanCardCta {
label: string
variant: CtaVariant
intent: CtaIntent
/** True when this card is the highlighted next step up from the current plan. */
highlighted: boolean
}
/** Plan-derived view consumed by every billing surface. */
export interface PlanView {
tier: PlanTier
isFree: boolean
isPaid: boolean
isEnterprise: boolean
/** Enterprise manages billing out-of-band — it cannot use the self-serve upgrade page. */
canAccessUpgrade: boolean
/** Credit balances are meaningless for enterprise (custom limits) and are hidden. */
showCredits: boolean
}
const MAX_TIER_CREDITS = CREDIT_TIERS[1].credits
/** Tier ordering used to derive upgrade/downgrade/highlight relationships. */
const PLAN_RANK: Record<PlanTier, number> = { free: 0, pro: 1, max: 2, enterprise: 3 }
/**
* Resolve a plan name to its credit-tier identity. Paid pro/team plans split
* into `pro` / `max` by their credit allocation (>= 25k credits => Max).
*/
export function resolvePlanTier(plan: string | null | undefined): PlanTier {
if (isEnterprise(plan)) return 'enterprise'
if (isFree(plan)) return 'free'
return getPlanTierCredits(plan) >= MAX_TIER_CREDITS ? 'max' : 'pro'
}
/**
* Derive the CTA for a single upgrade card given the current plan tier.
*
* The highlighted (primary) card is always the immediate next step up from the
* current tier. The same-tier card reads "Current Plan" (a non-actionable
* marker — plan management lives on the Billing settings page) and lower-tier
* cards offer "Downgrade plan" (a secondary `border-shadow` chip). A higher card
* reads "Get started" only while the user has no paid plan yet; once they are on
* a paid tier it becomes an explicit "Upgrade plan".
*/
export function getUpgradeCardCta(current: PlanTier, card: UpgradeCardId): PlanCardCta {
const isNextStepUp = PLAN_RANK[card] === PLAN_RANK[current] + 1
if (card === 'enterprise') {
return {
label: 'Talk to sales',
variant: isNextStepUp ? 'primary' : 'border-shadow',
intent: 'sales',
highlighted: isNextStepUp,
}
}
if (PLAN_RANK[current] === PLAN_RANK[card]) {
return { label: 'Current Plan', variant: 'border-shadow', intent: 'manage', highlighted: false }
}
if (PLAN_RANK[current] > PLAN_RANK[card]) {
return {
label: 'Downgrade plan',
variant: 'border-shadow',
intent: 'downgrade',
highlighted: false,
}
}
return {
label: current === 'free' ? 'Get started' : 'Upgrade plan',
variant: isNextStepUp ? 'primary' : 'border-shadow',
intent: 'upgrade',
highlighted: isNextStepUp,
}
}
/** Derive the shared plan view from a plan name. */
export function derivePlanView(plan: string | null | undefined): PlanView {
const enterprise = isEnterprise(plan)
return {
tier: resolvePlanTier(plan),
isFree: isFree(plan),
isPaid: isPaid(plan),
isEnterprise: enterprise,
canAccessUpgrade: !enterprise,
showCredits: !enterprise,
}
}
+80
View File
@@ -0,0 +1,80 @@
export interface UsageData {
current: number
limit: number
percentUsed: number
isWarning: boolean
isExceeded: boolean
billingPeriodStart: Date | string | null
billingPeriodEnd: Date | string | null
lastPeriodCost: number
lastPeriodCopilotCost?: number
copilotCost?: number
}
interface UsageLimitData {
currentLimit: number
canEdit: boolean
minimumLimit: number
plan: string
setBy?: string
updatedAt?: Date
}
export interface SubscriptionData {
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
/** True when the subscription's `referenceId` is an organization. */
isOrgScoped: boolean
organizationId: string | null
plan: string
status: string | null
seats: number | null
metadata: any | null
stripeSubscriptionId: string | null
periodEnd: Date | string | null
cancelAtPeriodEnd?: boolean
usage: UsageData
billingBlocked?: boolean
}
export type BillingStatus = 'unknown' | 'ok' | 'warning' | 'exceeded' | 'blocked'
interface SubscriptionStore {
subscriptionData: SubscriptionData | null
usageLimitData: UsageLimitData | null
isLoading: boolean
error: string | null
lastFetched: number | null
loadSubscriptionData: () => Promise<SubscriptionData | null>
loadUsageLimitData: () => Promise<UsageLimitData | null>
loadData: () => Promise<{
subscriptionData: SubscriptionData | null
usageLimitData: UsageLimitData | null
}>
updateUsageLimit: (newLimit: number) => Promise<{ success: boolean; error?: string }>
refresh: () => Promise<void>
clearError: () => void
reset: () => void
getSubscriptionStatus: () => {
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
isOrgScoped: boolean
organizationId: string | null
isFree: boolean
plan: string
status: string | null
seats: number | null
metadata: any | null
}
getUsage: () => UsageData
getBillingStatus: () => BillingStatus
getRemainingBudget: () => number
getDaysRemainingInPeriod: () => number | null
isAtLeastPro: () => boolean
isAtLeastTeam: () => boolean
canUpgrade: () => boolean
}
+235
View File
@@ -0,0 +1,235 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { getErrorMessage } from '@sim/utils/errors'
import { useQueryClient } from '@tanstack/react-query'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { listCreatorOrganizationsContract } from '@/lib/api/contracts/organizations'
import { subscriptionTransferContract } from '@/lib/api/contracts/user'
import { client, useSession, useSubscription } from '@/lib/auth/auth-client'
import { buildPlanName, getDisplayPlanName, isPaid } from '@/lib/billing/plan-helpers'
import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { organizationKeys } from '@/hooks/queries/organization'
const logger = createLogger('SubscriptionUpgrade')
type TargetPlan = 'pro' | 'team'
const CONSTANTS = {
INITIAL_TEAM_SEATS: 1,
DEFAULT_CREDIT_TIER: 6000,
} as const
interface UpgradeOptions {
creditTier?: number
annual?: boolean
}
export function useSubscriptionUpgrade() {
const { data: session } = useSession()
const betterAuthSubscription = useSubscription()
const queryClient = useQueryClient()
const handleUpgrade = useCallback(
async (targetPlan: TargetPlan, options?: UpgradeOptions) => {
const creditTier = options?.creditTier ?? CONSTANTS.DEFAULT_CREDIT_TIER
const annual = options?.annual ?? false
const planName = buildPlanName(targetPlan, creditTier)
const userId = session?.user?.id
if (!userId) {
throw new Error('User not authenticated')
}
let currentSubscriptionRowId: string | undefined
let currentStripeSubscriptionId: string | undefined
let allSubscriptions: any[] = []
try {
const listResult = await client.subscription.list()
allSubscriptions = listResult.data || []
const activePersonalSub = allSubscriptions.find(
(sub: any) => hasPaidSubscriptionStatus(sub.status) && sub.referenceId === userId
)
currentSubscriptionRowId = activePersonalSub?.id
currentStripeSubscriptionId = activePersonalSub?.stripeSubscriptionId
} catch (_e) {
currentSubscriptionRowId = undefined
currentStripeSubscriptionId = undefined
}
if (currentSubscriptionRowId && !currentStripeSubscriptionId) {
logger.error('Active paid subscription is missing its Stripe subscription ID', {
userId,
subscriptionRowId: currentSubscriptionRowId,
targetPlan,
})
throw new Error(
'We could not match your current plan with our payment provider. Please contact support before upgrading so you are not charged twice.'
)
}
let referenceId = userId
if (targetPlan === 'team') {
try {
let orgsData
try {
orgsData = await requestJson(listCreatorOrganizationsContract, {})
} catch (err) {
if (err instanceof ApiClientError) {
throw new Error('Failed to check organization status')
}
throw err
}
const existingOrg = orgsData.organizations?.find((org) => isOrgAdminRole(org.role))
if (existingOrg) {
const existingOrgSub = allSubscriptions.find(
(sub: any) =>
hasPaidSubscriptionStatus(sub.status) &&
sub.referenceId === existingOrg.id &&
isPaid(sub.plan)
)
if (existingOrgSub) {
logger.warn('Organization already has an active subscription', {
userId,
organizationId: existingOrg.id,
existingSubscriptionId: existingOrgSub.id,
plan: existingOrgSub.plan,
})
const existingPlanName = getDisplayPlanName(existingOrgSub.plan)
throw new Error(
`This organization is already on the ${existingPlanName} plan. Manage it from the billing settings.`
)
}
logger.info('Using existing organization for team plan upgrade', {
userId,
organizationId: existingOrg.id,
})
referenceId = existingOrg.id
try {
await client.organization.setActive({ organizationId: referenceId })
logger.info('Set organization as active', { organizationId: referenceId })
} catch (error) {
logger.warn('Failed to set organization as active, proceeding with upgrade', {
organizationId: referenceId,
error: getErrorMessage(error, 'Unknown error'),
})
}
} else if (orgsData.isMemberOfAnyOrg) {
throw new Error(
'You are already a member of an organization. Please leave it or ask an admin to upgrade.'
)
} else {
logger.info('Will create organization after payment succeeds', { userId })
}
} catch (error) {
logger.error('Failed to prepare for team plan upgrade', error)
throw error instanceof Error
? error
: new Error('Failed to prepare team workspace. Please try again or contact support.')
}
}
const currentUrl = `${window.location.origin}${window.location.pathname}`
const successUrlObj = new URL(window.location.href)
successUrlObj.searchParams.set('upgraded', 'true')
const successUrl = successUrlObj.toString()
try {
const upgradeParams = {
plan: planName,
referenceId,
successUrl,
cancelUrl: currentUrl,
...(targetPlan === 'team' && { seats: CONSTANTS.INITIAL_TEAM_SEATS }),
...(annual && { annual: true }),
} as const
const finalParams = currentStripeSubscriptionId
? { ...upgradeParams, subscriptionId: currentStripeSubscriptionId }
: upgradeParams
logger.info(
currentStripeSubscriptionId
? 'Upgrading existing subscription'
: 'Creating new subscription',
{
targetPlan,
planName,
annual,
currentStripeSubscriptionId,
currentSubscriptionRowId,
referenceId,
}
)
await betterAuthSubscription.upgrade(finalParams)
if (targetPlan === 'team' && currentSubscriptionRowId && referenceId !== userId) {
try {
logger.info('Transferring subscription to organization after upgrade', {
subscriptionId: currentSubscriptionRowId,
organizationId: referenceId,
})
try {
await requestJson(subscriptionTransferContract, {
params: { id: currentSubscriptionRowId },
body: { organizationId: referenceId },
})
logger.info('Successfully transferred subscription to organization', {
subscriptionId: currentSubscriptionRowId,
organizationId: referenceId,
})
} catch (transferError) {
logger.error('Failed to transfer subscription to organization', {
subscriptionId: currentSubscriptionRowId,
organizationId: referenceId,
error:
transferError instanceof ApiClientError
? (transferError.rawBody ?? transferError.message)
: transferError instanceof Error
? transferError.message
: 'Unknown error',
})
}
} catch (error) {
logger.error('Error transferring subscription after upgrade', error)
}
}
if (targetPlan === 'team') {
try {
await queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
logger.info('Refreshed organization data after team upgrade')
} catch (error) {
logger.warn('Failed to refresh organization data after upgrade', error)
}
}
logger.info('Subscription upgrade completed successfully', { targetPlan, referenceId })
} catch (error) {
logger.error('Failed to initiate subscription upgrade:', error)
if (error instanceof Error) {
logger.error('Detailed error:', {
message: error.message,
stack: error.stack,
cause: error.cause,
})
}
throw new Error(
`Failed to upgrade subscription: ${getErrorMessage(error, 'Unknown error')}`
)
}
},
[session?.user?.id, betterAuthSubscription, queryClient]
)
return { handleUpgrade }
}
@@ -0,0 +1,30 @@
'use client'
import { useCallback } from 'react'
import { toast } from '@sim/emcn'
import { useParams, useRouter } from 'next/navigation'
import { buildUpgradeHref, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
/**
* Returns a callback that surfaces a usage-limit error as an actionable toast
* with an "Upgrade" button deep-linking to the reason-tagged upgrade page.
*
* The toast persists until dismissed (emcn keeps actionable toasts open), so the
* user always has the upgrade path within reach when they hit a limit.
*/
export function useLimitUpgradeToast() {
const router = useRouter()
const { workspaceId } = useParams<{ workspaceId: string }>()
return useCallback(
(reason: UpgradeReason, message: string) => {
toast.error(message, {
action: {
label: 'Upgrade',
onClick: () => router.push(buildUpgradeHref(workspaceId, reason)),
},
})
},
[router, workspaceId]
)
}
+187
View File
@@ -0,0 +1,187 @@
/**
* Helper functions for subscription-related computations
* These are pure functions that compute values from subscription data
*/
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
import { getPlanTierCredits, isEnterprise, isFree, isPro } from '@/lib/billing/plan-helpers'
import { hasUsableSubscriptionAccess } from '@/lib/billing/subscriptions/utils'
import { USAGE_PILL_COLORS } from './consts'
import type { BillingStatus, SubscriptionData, UsageData } from './types'
const defaultUsage: UsageData = {
current: 0,
limit: DEFAULT_FREE_CREDITS,
percentUsed: 0,
isWarning: false,
isExceeded: false,
billingPeriodStart: null,
billingPeriodEnd: null,
lastPeriodCost: 0,
}
/**
* Get subscription status flags from subscription data
*/
export function getSubscriptionStatus(
subscriptionData: Partial<SubscriptionData> | null | undefined
) {
return {
isPaid: subscriptionData?.isPaid ?? false,
isPro: subscriptionData?.isPro ?? false,
isTeam: subscriptionData?.isTeam ?? false,
isEnterprise: subscriptionData?.isEnterprise ?? false,
isOrgScoped: subscriptionData?.isOrgScoped ?? false,
organizationId: subscriptionData?.organizationId ?? null,
isFree: !(subscriptionData?.isPaid ?? false),
plan: subscriptionData?.plan ?? 'free',
status: subscriptionData?.status ?? null,
seats: subscriptionData?.seats ?? null,
metadata: subscriptionData?.metadata ?? null,
}
}
export function getSubscriptionAccessState(
subscriptionData: Partial<SubscriptionData> | null | undefined
) {
const status = getSubscriptionStatus(subscriptionData)
const billingBlocked = Boolean(subscriptionData?.billingBlocked)
const hasUsablePaidAccess = hasUsableSubscriptionAccess(status.status, billingBlocked)
// Team-management features (invitations, seats, roles) are available on
// any paid subscription attached to an organization — including `pro_*`
// plans that have been transferred to an org. Plan-name gating would
// miss those.
const hasUsableTeamAccess =
hasUsablePaidAccess && (status.isOrgScoped || status.isTeam || status.isEnterprise)
const hasUsableEnterpriseAccess = hasUsablePaidAccess && status.isEnterprise
const hasUsableMaxAccess =
hasUsablePaidAccess && (getPlanTierCredits(status.plan) >= 25000 || isEnterprise(status.plan))
return {
...status,
billingBlocked,
hasUsablePaidAccess,
hasUsableTeamAccess,
hasUsableEnterpriseAccess,
hasUsableMaxAccess,
}
}
/**
* Get usage data from subscription data
* Validates and sanitizes all numeric values to prevent crashes from malformed data
*/
export function getUsage(
subscriptionData: Partial<SubscriptionData> | null | undefined
): UsageData {
const usage = subscriptionData?.usage
if (!usage) {
return defaultUsage
}
return {
current:
typeof usage.current === 'number' && Number.isFinite(usage.current) ? usage.current : 0,
limit:
typeof usage.limit === 'number' && Number.isFinite(usage.limit)
? usage.limit
: DEFAULT_FREE_CREDITS,
percentUsed:
typeof usage.percentUsed === 'number' && Number.isFinite(usage.percentUsed)
? usage.percentUsed
: 0,
isWarning: Boolean(usage.isWarning),
isExceeded: Boolean(usage.isExceeded),
billingPeriodStart: usage.billingPeriodStart ?? null,
billingPeriodEnd: usage.billingPeriodEnd ?? null,
lastPeriodCost:
typeof usage.lastPeriodCost === 'number' && Number.isFinite(usage.lastPeriodCost)
? usage.lastPeriodCost
: 0,
}
}
/**
* Get billing status based on usage and blocked state
*/
export function getBillingStatus(
subscriptionData: Partial<SubscriptionData> | null | undefined
): BillingStatus {
const usage = getUsage(subscriptionData)
const blocked = subscriptionData?.billingBlocked
if (blocked) return 'blocked'
if (usage.isExceeded) return 'exceeded'
if (usage.isWarning) return 'warning'
return 'ok'
}
/**
* Get remaining budget
*/
export function getRemainingBudget(
subscriptionData: Partial<SubscriptionData> | null | undefined
): number {
const usage = getUsage(subscriptionData)
return Math.max(0, usage.limit - usage.current)
}
/**
* Get days remaining in billing period
*/
export function getDaysRemainingInPeriod(
subscriptionData: Partial<SubscriptionData> | null | undefined
): number | null {
const usage = getUsage(subscriptionData)
if (!usage.billingPeriodEnd) return null
const now = new Date()
const endDate =
typeof usage.billingPeriodEnd === 'string'
? new Date(usage.billingPeriodEnd)
: usage.billingPeriodEnd
const diffTime = endDate.getTime() - now.getTime()
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return Math.max(0, diffDays)
}
/**
* Check if subscription is at least Pro tier
*/
export function isAtLeastPro(
subscriptionData: Partial<SubscriptionData> | null | undefined
): boolean {
const status = getSubscriptionStatus(subscriptionData)
return status.isPro || status.isTeam || status.isEnterprise
}
/**
* Check if subscription is at least Team tier
*/
export function isAtLeastTeam(
subscriptionData: Partial<SubscriptionData> | null | undefined
): boolean {
const status = getSubscriptionStatus(subscriptionData)
return status.isTeam || status.isEnterprise
}
export function canUpgrade(
subscriptionData: Partial<SubscriptionData> | null | undefined
): boolean {
const status = getSubscriptionStatus(subscriptionData)
return isFree(status.plan) || isPro(status.plan)
}
/**
* Get the appropriate filled pill color based on usage thresholds.
*
* @param isCritical - Whether usage is at critical level (blocked or >= 90%)
* @param isWarning - Whether usage is at warning level (>= 75% but < critical)
* @returns CSS color value for filled pills
*/
export function getFilledPillColor(isCritical: boolean, isWarning: boolean): string {
if (isCritical) return USAGE_PILL_COLORS.AT_LIMIT
if (isWarning) return USAGE_PILL_COLORS.WARNING
return USAGE_PILL_COLORS.FILLED
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Billing and cost constants shared between client and server code
*/
/**
* Fallback free credits (in dollars) when env var is not set
*/
export const DEFAULT_FREE_CREDITS = 5
/**
* Default per-user minimum limits (in dollars) for paid plans when env vars are absent.
* These are intentionally kept at legacy pricing ($20 Pro, $40 Team) for backward
* compatibility with existing subscribers on the old plan names ('pro', 'team').
* New tiered plans (pro_6000, team_25000, etc.) derive their limits from CREDIT_TIERS.
*/
export const DEFAULT_PRO_TIER_COST_LIMIT = 20
export const DEFAULT_TEAM_TIER_COST_LIMIT = 40
export const DEFAULT_ENTERPRISE_TIER_COST_LIMIT = 200
/**
* Base charge applied to every workflow execution
* This charge is applied regardless of whether the workflow uses AI models
*/
export const BASE_EXECUTION_CHARGE = 0.005
/**
* Fixed cost for search tool invocation (in dollars)
*/
export const SEARCH_TOOL_COST = 0.01
/**
* Default threshold (in dollars) for incremental overage billing
* When unbilled overage reaches this amount, an invoice item is created
*/
export const DEFAULT_OVERAGE_THRESHOLD = 100
/**
* Maximum time to wait on billing coordination row locks before retrying later.
*/
export const BILLING_LOCK_TIMEOUT_MS = 5_000
/**
* Available credit tiers. Each tier maps a credit amount to the underlying dollar cost.
* 1 credit = $0.005, so credits = dollars * 200.
*/
export const CREDIT_TIERS = [
{ credits: 6000, dollars: 25, name: 'Pro' },
{ credits: 25000, dollars: 100, name: 'Max' },
] as const
export type CreditTier = (typeof CREDIT_TIERS)[number]
/**
* Credits granted per dollar of plan spend. A credit is $0.005, so a dollar
* buys 200 — the conversion behind both free-tier and daily-refresh credits.
*/
export const CREDITS_PER_DOLLAR = 200
/**
* Daily refresh rate: 1% of plan cost per day.
* E.g. $25 plan => $0.25/day => 50 credits/day included usage.
*/
export const DAILY_REFRESH_RATE = 0.01
/**
* Annual subscribers pay 15% less than the equivalent monthly plan
* but receive the same included credits. The Stripe annual price is
* `monthlyDollars * 12 * (1 - ANNUAL_DISCOUNT_RATE)`.
*/
export const ANNUAL_DISCOUNT_RATE = 0.15
/**
* Dollar value used as the usage limit when on-demand billing is enabled.
* Effectively unlimited — any limit >= this threshold is treated as uncapped.
*/
export const ON_DEMAND_UNLIMITED = 999999
+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,
}
}
+217
View File
@@ -0,0 +1,217 @@
import { db } from '@sim/db'
import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
import {
hasUsableSubscriptionAccess,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { Decimal, toDecimal, toFixedString, toNumber } from '@/lib/billing/utils/decimal'
import type { DbClient } from '@/lib/db/types'
const logger = createLogger('CreditBalance')
export interface CreditBalanceInfo {
balance: number
entityType: 'user' | 'organization'
entityId: string
}
/**
* Read credit balance directly from a known entity (user or organization).
* Use this in webhook / admin paths that already know the target entity —
* unlike `getCreditBalance(userId)` it does not route through
* `getHighestPrioritySubscription`, so callers don't need to resolve the
* org owner as a user-id proxy.
*/
export async function getCreditBalanceForEntity(
entityType: 'user' | 'organization',
entityId: string,
executor: DbClient = db
): Promise<number> {
if (entityType === 'organization') {
const rows = await executor
.select({ creditBalance: organization.creditBalance })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0
}
const rows = await executor
.select({ creditBalance: userStats.creditBalance })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0
}
export async function getCreditBalance(
userId: string,
executor: DbClient = db
): Promise<CreditBalanceInfo> {
const subscription = await getHighestPrioritySubscription(userId, { executor })
if (isOrgScopedSubscription(subscription, userId) && subscription) {
return {
balance: await getCreditBalanceForEntity('organization', subscription.referenceId, executor),
entityType: 'organization',
entityId: subscription.referenceId,
}
}
return {
balance: await getCreditBalanceForEntity('user', userId, executor),
entityType: 'user',
entityId: userId,
}
}
export async function addCredits(
entityType: 'user' | 'organization',
entityId: string,
amount: number
): Promise<void> {
if (entityType === 'organization') {
await db
.update(organization)
.set({ creditBalance: sql`${organization.creditBalance} + ${amount}` })
.where(eq(organization.id, entityId))
logger.info('Added credits to organization', { organizationId: entityId, amount })
} else {
await db
.update(userStats)
.set({ creditBalance: sql`${userStats.creditBalance} + ${amount}` })
.where(eq(userStats.userId, entityId))
logger.info('Added credits to user', { userId: entityId, amount })
}
}
async function removeCredits(
entityType: 'user' | 'organization',
entityId: string,
amount: number
): Promise<void> {
if (entityType === 'organization') {
await db
.update(organization)
.set({ creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${amount})` })
.where(eq(organization.id, entityId))
logger.info('Removed credits from organization', { organizationId: entityId, amount })
} else {
await db
.update(userStats)
.set({ creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${amount})` })
.where(eq(userStats.userId, entityId))
logger.info('Removed credits from user', { userId: entityId, amount })
}
}
interface DeductResult {
creditsUsed: number
overflow: number
}
async function atomicDeductUserCredits(userId: string, cost: number): Promise<number> {
const costDecimal = toDecimal(cost)
const costStr = toFixedString(costDecimal)
// Use raw SQL with CTE to capture old balance before update
const result = await db.execute<{ old_balance: string; new_balance: string }>(sql`
WITH old_balance AS (
SELECT credit_balance FROM user_stats WHERE user_id = ${userId}
)
UPDATE user_stats
SET credit_balance = CASE
WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal
ELSE 0
END
WHERE user_id = ${userId} AND credit_balance >= 0
RETURNING
(SELECT credit_balance FROM old_balance) as old_balance,
credit_balance as new_balance
`)
const rows = Array.from(result)
if (rows.length === 0) return 0
const oldBalance = toDecimal(rows[0].old_balance)
return toNumber(oldBalance.lessThan(costDecimal) ? oldBalance : costDecimal)
}
async function atomicDeductOrgCredits(orgId: string, cost: number): Promise<number> {
const costDecimal = toDecimal(cost)
const costStr = toFixedString(costDecimal)
// Use raw SQL with CTE to capture old balance before update
const result = await db.execute<{ old_balance: string; new_balance: string }>(sql`
WITH old_balance AS (
SELECT credit_balance FROM organization WHERE id = ${orgId}
)
UPDATE organization
SET credit_balance = CASE
WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal
ELSE 0
END
WHERE id = ${orgId} AND credit_balance >= 0
RETURNING
(SELECT credit_balance FROM old_balance) as old_balance,
credit_balance as new_balance
`)
const rows = Array.from(result)
if (rows.length === 0) return 0
const oldBalance = toDecimal(rows[0].old_balance)
return toNumber(oldBalance.lessThan(costDecimal) ? oldBalance : costDecimal)
}
async function deductFromCredits(userId: string, cost: number): Promise<DeductResult> {
if (cost <= 0) {
return { creditsUsed: 0, overflow: 0 }
}
const subscription = await getHighestPrioritySubscription(userId)
const orgScoped = isOrgScopedSubscription(subscription, userId)
let creditsUsed: number
if (orgScoped && subscription?.referenceId) {
creditsUsed = await atomicDeductOrgCredits(subscription.referenceId, cost)
} else {
creditsUsed = await atomicDeductUserCredits(userId, cost)
}
const overflow = toNumber(Decimal.max(0, toDecimal(cost).minus(creditsUsed)))
if (creditsUsed > 0) {
logger.info('Deducted credits atomically', {
userId,
creditsUsed,
overflow,
entityType: orgScoped ? 'organization' : 'user',
})
}
return { creditsUsed, overflow }
}
export async function canPurchaseCredits(userId: string): Promise<boolean> {
const subscription = await getHighestPrioritySubscription(userId)
if (!subscription) {
return false
}
const billingStatus = await getEffectiveBillingStatus(userId)
if (!hasUsableSubscriptionAccess(subscription.status, billingStatus.billingBlocked)) {
return false
}
// Enterprise users must contact support to purchase credits
return isPro(subscription.plan) || isTeam(subscription.plan)
}
@@ -0,0 +1,79 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
apportionCredits,
creditsToDollars,
dollarsToCredits,
formatCreditCost,
} from '@/lib/billing/credits/conversion'
describe('creditsToDollars', () => {
it('converts credits to dollars at 200 credits per dollar', () => {
expect(creditsToDollars(200)).toBe(1)
expect(creditsToDollars(0)).toBe(0)
expect(creditsToDollars(1)).toBe(0.005)
})
it('round-trips with dollarsToCredits for whole-credit values', () => {
expect(dollarsToCredits(creditsToDollars(2000))).toBe(2000)
expect(dollarsToCredits(creditsToDollars(1))).toBe(1)
})
})
describe('formatCreditCost', () => {
it('renders multiplier-inclusive dollars as a single-rounded credit label', () => {
expect(formatCreditCost(0.005)).toBe('1 credit')
expect(formatCreditCost(0.03141848)).toBe('6 credits')
expect(formatCreditCost(1.234)).toBe('247 credits')
})
it('distinguishes sub-credit charges from zero', () => {
expect(formatCreditCost(0.001)).toBe('<1 credit')
expect(formatCreditCost(0)).toBe('0 credits')
})
it('honors emptyForZeroOrLess for the trace view contract', () => {
expect(formatCreditCost(0, { emptyForZeroOrLess: true })).toBeUndefined()
expect(formatCreditCost(undefined, { emptyForZeroOrLess: true })).toBeUndefined()
expect(formatCreditCost(undefined)).toBe('—')
})
})
describe('apportionCredits', () => {
it('keeps line items summing exactly to the rounded total (no round-then-sum drift)', () => {
// Real execution 43ef064d: base + 2x model, multiplier already applied.
// Round-then-sum would give 1 + 4 + 2 = 7; the true total is 6.
const credits = apportionCredits([
{ key: 'base', dollars: 0.005 },
{ key: 'input', dollars: 0.018798 },
{ key: 'output', dollars: 0.00762 },
{ key: 'tool', dollars: 0 },
])
const total = dollarsToCredits(0.005 + 0.018798 + 0.00762 + 0)
expect(total).toBe(6)
expect(credits.base + credits.input + credits.output + credits.tool).toBe(total)
expect(credits.base).toBe(1)
})
it('handles all-zero components', () => {
const credits = apportionCredits([
{ key: 'base', dollars: 0 },
{ key: 'model', dollars: 0 },
])
expect(credits.base + credits.model).toBe(0)
})
it('ignores negative/non-finite components without throwing', () => {
const credits = apportionCredits([
{ key: 'base', dollars: 0.005 },
{ key: 'model', dollars: Number.NaN },
{ key: 'tool', dollars: -1 },
])
expect(credits.base).toBe(1)
expect(credits.model).toBe(0)
expect(credits.tool).toBe(0)
})
})
+136
View File
@@ -0,0 +1,136 @@
/**
* Credit conversion utilities.
* All DB values remain in dollars; these helpers convert at API/UI boundaries only.
* 1 credit = $0.005 (i.e. $1 = 200 credits)
*/
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
export const CREDIT_MULTIPLIER = 200
export function dollarsToCredits(dollars: number): number {
return Math.round(dollars * CREDIT_MULTIPLIER)
}
/**
* Convert a credit amount (the API/UI unit) into stored dollars.
* Inverse of {@link dollarsToCredits}; use at write boundaries that accept
* credit-denominated input (e.g. per-member usage limits).
*/
export function creditsToDollars(credits: number): number {
return credits / CREDIT_MULTIPLIER
}
/**
* Pluralizes an already credit-denominated integer, e.g. `1234` -> `"1,234
* credits"`, `1` -> `"1 credit"`. Low-level building block for
* {@link formatCreditCost} — also useful directly for consumers that already
* hold precise integer credits (e.g. a server-converted `creditCost` value),
* which would otherwise double-convert by round-tripping back through
* dollars.
*/
export function formatCreditsLabel(credits: number): string {
return `${credits.toLocaleString()} ${credits === 1 ? 'credit' : 'credits'}`
}
/**
* Single source of truth for rendering a dollar cost as a credit label.
*
* Both the billing cost breakdown and the trace view derive their credit
* strings from here so the two surfaces can never diverge in rounding,
* thresholds, or pluralization. The dollar amount passed in is expected to
* already carry any cost multiplier (the value is converted with one — and
* only one — round via `dollarsToCredits`, i.e. multiply-then-round; never
* round per-line then multiply).
*
* `emptyForZeroOrLess` controls the zero/empty behavior so existing call sites
* keep their contracts: the breakdown wants a concrete "0 credits"/"—" string,
* the trace view wants `undefined` so it can hide the chip entirely.
*/
export function formatCreditCost(
dollars: number | null | undefined,
opts?: { emptyForZeroOrLess?: boolean }
): string | undefined {
if (dollars === undefined || dollars === null || !Number.isFinite(dollars)) {
return opts?.emptyForZeroOrLess ? undefined : '—'
}
const credits = dollarsToCredits(dollars)
if (credits <= 0) {
if (dollars > 0) return '<1 credit'
return opts?.emptyForZeroOrLess ? undefined : '0 credits'
}
return formatCreditsLabel(credits)
}
/**
* Renders an already-apportioned integer `creditCost` (see {@link apportionCredits})
* alongside its raw `dollarCost`, so a row can legitimately apportion to 0
* credits — once a sibling absorbs the shared rounding remainder — without
* reading as a flat, misleading "0 credits" for an event that had a real,
* positive charge. Mirrors {@link formatCreditCost}'s zero/sub-credit
* wording, but never recomputes credits from `dollarCost` (that would
* double-convert a value the caller already apportioned).
*/
export function formatApportionedCreditCost(creditCost: number, dollarCost: number): string {
if (creditCost > 0) return formatCreditsLabel(creditCost)
return dollarCost > 0 ? '<1 credit' : '0 credits'
}
/**
* Splits a set of cost components into integer credits that sum *exactly* to
* the credits of their combined total.
*
* This is the fix for the "line items don't add up to the total" class of bug:
* rounding each line independently (round-then-sum) drifts from the real charge
* (e.g. 1 + 2 + 1 = 4, or 1 + 4 + 2 = 7, when the true total is 6). Instead we
* convert the *summed* dollars to credits with a single round (multiply-then-
* round) and distribute that figure across the components via the largest-
* remainder method, so every component is multiplier-applied, internally
* consistent, and the rows always reconcile with the total.
*
* Each component's `dollars` is expected to already include any cost multiplier.
*/
export function apportionCredits<K extends string>(
components: { key: K; dollars: number }[]
): Record<K, number> {
const result = {} as Record<K, number>
const sanitized = components.map((c) => ({
key: c.key,
dollars: Number.isFinite(c.dollars) && c.dollars > 0 ? c.dollars : 0,
}))
const totalDollars = sanitized.reduce((sum, c) => sum + c.dollars, 0)
const targetCredits = dollarsToCredits(totalDollars)
const exact = sanitized.map((c) => ({
key: c.key,
floor: Math.floor(c.dollars * CREDIT_MULTIPLIER),
frac: c.dollars * CREDIT_MULTIPLIER - Math.floor(c.dollars * CREDIT_MULTIPLIER),
}))
for (const c of exact) result[c.key] = c.floor
let remainder = targetCredits - exact.reduce((sum, c) => sum + c.floor, 0)
const byFraction = [...exact].sort((a, b) => b.frac - a.frac)
for (let i = 0; i < byFraction.length && remainder > 0; i++) {
result[byFraction[i].key] += 1
remainder--
}
return result
}
/**
* Format a dollar amount as a comma-separated credit string.
* Values at or above the on-demand unlimited threshold display as ∞.
* @example formatCredits(20) => "2,000"
* @example formatCredits(999999) => "∞"
*/
export function formatCredits(dollars: number): string {
if (dollars >= ON_DEMAND_UNLIMITED) return '∞'
return dollarsToCredits(dollars).toLocaleString()
}
@@ -0,0 +1,158 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, drizzleOrmMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('drizzle-orm', () => {
const sqlTag = () => {
const obj: { as: () => typeof obj } = { as: () => obj }
return obj
}
return {
...drizzleOrmMock,
sql: Object.assign(sqlTag, { raw: sqlTag }),
sum: () => ({ as: () => 'sum' }),
}
})
vi.mock('@/lib/billing/constants', () => ({
DAILY_REFRESH_RATE: 0.01,
}))
import {
computeDailyRefreshConsumed,
getDailyRefreshDollars,
} from '@/lib/billing/credits/daily-refresh'
describe('computeDailyRefreshConsumed', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 0 when planDollars is 0', async () => {
const result = await computeDailyRefreshConsumed({
userIds: ['user-1'],
periodStart: new Date('2026-03-01'),
planDollars: 0,
})
expect(result).toBe(0)
expect(dbChainMockFns.groupBy).not.toHaveBeenCalled()
})
it('returns 0 when userIds is empty', async () => {
const result = await computeDailyRefreshConsumed({
userIds: [],
periodStart: new Date('2026-03-01'),
planDollars: 25,
})
expect(result).toBe(0)
expect(dbChainMockFns.groupBy).not.toHaveBeenCalled()
})
it('returns 0 when periodEnd is before periodStart', async () => {
const result = await computeDailyRefreshConsumed({
userIds: ['user-1'],
periodStart: new Date('2026-03-10'),
periodEnd: new Date('2026-03-01'),
planDollars: 25,
})
expect(result).toBe(0)
})
it('caps each day at the daily refresh allowance', async () => {
dbChainMockFns.groupBy.mockResolvedValueOnce([
{ dayIndex: 0, dayTotal: '0.50' },
{ dayIndex: 1, dayTotal: '0.10' },
{ dayIndex: 2, dayTotal: '1.00' },
])
const result = await computeDailyRefreshConsumed({
userIds: ['user-1'],
periodStart: new Date('2026-03-01'),
periodEnd: new Date('2026-03-04'),
planDollars: 25,
})
// Daily refresh = $25 * 0.01 = $0.25/day
// Day 0: MIN(0.50, 0.25) = 0.25
// Day 1: MIN(0.10, 0.25) = 0.10
// Day 2: MIN(1.00, 0.25) = 0.25
// Total = 0.60
expect(result).toBe(0.6)
})
it('returns 0 when no usage rows exist', async () => {
dbChainMockFns.groupBy.mockResolvedValueOnce([])
const result = await computeDailyRefreshConsumed({
userIds: ['user-1'],
periodStart: new Date('2026-03-01'),
periodEnd: new Date('2026-03-04'),
planDollars: 25,
})
expect(result).toBe(0)
})
it('multiplies daily refresh by seats', async () => {
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '2.00' }])
const result = await computeDailyRefreshConsumed({
userIds: ['user-1', 'user-2', 'user-3'],
periodStart: new Date('2026-03-01'),
periodEnd: new Date('2026-03-02'),
planDollars: 100,
seats: 3,
})
// Daily refresh = $100 * 0.01 * 3 seats = $3.00/day
// Day 0: MIN(2.00, 3.00) = 2.00
expect(result).toBe(2.0)
})
it('caps at refresh even with high usage and multiple seats', async () => {
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '50.00' }])
const result = await computeDailyRefreshConsumed({
userIds: ['user-1', 'user-2'],
periodStart: new Date('2026-03-01'),
periodEnd: new Date('2026-03-02'),
planDollars: 100,
seats: 2,
})
// Daily refresh = $100 * 0.01 * 2 seats = $2.00/day
// Day 0: MIN(50.00, 2.00) = 2.00
expect(result).toBe(2.0)
})
it('handles null dayTotal gracefully', async () => {
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: null }])
const result = await computeDailyRefreshConsumed({
userIds: ['user-1'],
periodStart: new Date('2026-03-01'),
periodEnd: new Date('2026-03-02'),
planDollars: 25,
})
expect(result).toBe(0)
})
})
describe('getDailyRefreshDollars', () => {
it('computes correct daily refresh for Pro ($25)', () => {
expect(getDailyRefreshDollars(25)).toBe(0.25)
})
it('computes correct daily refresh for Max ($100)', () => {
expect(getDailyRefreshDollars(100)).toBe(1.0)
})
it('returns 0 for $0 plan', () => {
expect(getDailyRefreshDollars(0)).toBe(0)
})
})
@@ -0,0 +1,179 @@
/**
* Daily Refresh Credits
*
* Each billing period is divided into 1-day windows starting from `periodStart`.
* Users receive `planDollars * DAILY_REFRESH_RATE` in "included" usage per day.
* Usage within that allowance does not count toward the plan limit (use-it-or-lose-it).
*
* The total refresh consumed in a period is:
* SUM( MIN(day_usage, daily_refresh_amount) ) for each day
*
* This is subtracted from `currentPeriodCost` to derive "effective billable usage".
*/
import { db } from '@sim/db'
import { member, usageLog, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, gte, inArray, lt, or, sql, sum } from 'drizzle-orm'
import { DAILY_REFRESH_RATE } from '@/lib/billing/constants'
import type { DbClient } from '@/lib/db/types'
const logger = createLogger('DailyRefresh')
const MS_PER_DAY = 86_400_000
/**
* Optional per-user date window. `usageLog` rows outside
* `[userStart, userEnd)` are excluded from that user's contribution.
* Used to slice refresh around a mid-cycle org join so pre-join and
* post-join refresh are billed by the right subscription.
*/
export interface PerUserBounds {
userStart?: Date | null
userEnd?: Date | null
}
/**
* Compute the total daily refresh credits consumed in the current billing period
* using a single aggregating SQL query grouped by day offset.
*
* For each day from `periodStart`:
* consumed_today = MIN(actual_usage_today, daily_refresh_dollars)
*
* @returns Total dollars of refresh consumed across all days (to subtract from usage)
*/
export async function computeDailyRefreshConsumed(
params: {
userIds: string[]
periodStart: Date
periodEnd?: Date | null
planDollars: number
seats?: number
userBounds?: Record<string, PerUserBounds>
billingEntity?: { type: 'user' | 'organization'; id: string }
},
executor: DbClient = db
): Promise<number> {
const {
userIds,
periodStart,
periodEnd,
planDollars,
seats = 1,
userBounds,
billingEntity,
} = params
if (planDollars <= 0 || userIds.length === 0) return 0
const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats
const now = new Date()
const cap = periodEnd && periodEnd < now ? periodEnd : now
if (cap <= periodStart) return 0
const dayCount = Math.ceil((cap.getTime() - periodStart.getTime()) / MS_PER_DAY)
if (dayCount <= 0) return 0
const unboundedUsers = userBounds ? userIds.filter((id) => !(id in userBounds)) : userIds
const billingEntityFilter = billingEntity
? and(
eq(usageLog.billingEntityType, billingEntity.type),
eq(usageLog.billingEntityId, billingEntity.id),
eq(usageLog.billingPeriodStart, periodStart)
)
: undefined
const boundedClauses = userBounds
? Object.entries(userBounds).flatMap(([userId, bounds]) => {
if (!userIds.includes(userId)) return []
const effectiveStart =
bounds.userStart && bounds.userStart > periodStart ? bounds.userStart : periodStart
const effectiveEnd = bounds.userEnd && bounds.userEnd < cap ? bounds.userEnd : cap
if (effectiveEnd <= effectiveStart) return []
return [
and(
eq(usageLog.userId, userId),
billingEntityFilter,
gte(usageLog.createdAt, effectiveStart),
lt(usageLog.createdAt, effectiveEnd)
),
]
})
: []
const rowFilters =
unboundedUsers.length > 0
? [
and(
inArray(usageLog.userId, unboundedUsers),
billingEntityFilter,
gte(usageLog.createdAt, periodStart),
lt(usageLog.createdAt, cap)
),
...boundedClauses,
]
: boundedClauses
if (rowFilters.length === 0) return 0
const rows = await executor
.select({
dayIndex:
sql<number>`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(periodStart.getTime() / 1000)}) / 86400)`.as(
'day_index'
),
dayTotal: sum(usageLog.cost).as('day_total'),
})
.from(usageLog)
.where(rowFilters.length === 1 ? rowFilters[0] : or(...rowFilters))
.groupBy(sql`day_index`)
let totalConsumed = 0
for (const row of rows) {
const dayUsage = Number.parseFloat(row.dayTotal ?? '0')
totalConsumed += Math.min(dayUsage, dailyRefreshDollars)
}
logger.debug('Daily refresh computed', {
userCount: userIds.length,
periodStart: periodStart.toISOString(),
days: dayCount,
dailyRefreshDollars,
totalConsumed,
hasUserBounds: Boolean(userBounds),
})
return totalConsumed
}
/**
* Get the daily refresh allowance in dollars for a plan.
*/
export function getDailyRefreshDollars(planDollars: number): number {
return planDollars * DAILY_REFRESH_RATE
}
export async function getOrgMemberRefreshBounds(
organizationId: string,
periodStart: Date,
executor: DbClient = db
): Promise<Record<string, { userStart: Date }>> {
const rows = await executor
.select({
userId: member.userId,
snapshotAt: userStats.proPeriodCostSnapshotAt,
})
.from(member)
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
const bounds: Record<string, { userStart: Date }> = {}
for (const row of rows) {
if (row.snapshotAt && row.snapshotAt > periodStart) {
bounds[row.userId] = { userStart: row.snapshotAt }
}
}
return bounds
}
+233
View File
@@ -0,0 +1,233 @@
import { db } from '@sim/db'
import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { canPurchaseCredits } from '@/lib/billing/credits/balance'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getCustomerId, resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
const logger = createLogger('CreditPurchase')
/**
* Sets usage limit to planBase + creditBalance.
* This ensures users can use their plan's included amount plus any prepaid credits.
*/
export async function setUsageLimitForCredits(
entityType: 'user' | 'organization',
entityId: string,
plan: string,
seats: number | null,
creditBalance: number
): Promise<void> {
try {
const { basePrice } = getPlanPricing(plan)
const seatCount = seats || 1
const planBase =
entityType === 'organization' ? Number(basePrice) * seatCount : Number(basePrice)
const creditBalanceNum = Number(creditBalance)
const newLimit = planBase + creditBalanceNum
if (entityType === 'organization') {
const orgRows = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
const currentLimit = orgRows.length > 0 ? toNumber(toDecimal(orgRows[0].orgUsageLimit)) : 0
if (newLimit > currentLimit) {
await db
.update(organization)
.set({ orgUsageLimit: newLimit.toString() })
.where(eq(organization.id, entityId))
logger.info('Set org usage limit to planBase + credits', {
organizationId: entityId,
plan,
seats,
planBase,
creditBalance,
previousLimit: currentLimit,
newLimit,
})
}
} else {
const userStatsRows = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
const currentLimit =
userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].currentUsageLimit)) : 0
if (newLimit > currentLimit) {
await db
.update(userStats)
.set({ currentUsageLimit: newLimit.toString() })
.where(eq(userStats.userId, entityId))
logger.info('Set user usage limit to planBase + credits', {
userId: entityId,
plan,
planBase,
creditBalance,
previousLimit: currentLimit,
newLimit,
})
}
}
} catch (error) {
logger.error('Failed to set usage limit for credits', { entityType, entityId, error })
}
}
export interface PurchaseCreditsParams {
userId: string
amountDollars: number
requestId: string
}
export interface PurchaseResult {
success: boolean
error?: string
}
export async function purchaseCredits(params: PurchaseCreditsParams): Promise<PurchaseResult> {
const { userId, amountDollars, requestId } = params
if (amountDollars < 10 || amountDollars > 1000) {
return { success: false, error: 'Amount must be between $10 and $1000' }
}
const canPurchase = await canPurchaseCredits(userId)
if (!canPurchase) {
return { success: false, error: 'Only Pro and Team users can purchase credits' }
}
const subscription = await getHighestPrioritySubscription(userId)
if (!subscription || !subscription.stripeSubscriptionId) {
return { success: false, error: 'No active subscription found' }
}
// Enterprise users must contact support
if (isEnterprise(subscription.plan)) {
return { success: false, error: 'Enterprise users must contact support to purchase credits' }
}
let entityType: 'user' | 'organization' = 'user'
let entityId = userId
// Org-scoped subs route credit purchases to the organization and must be authorized
// by an org owner/admin. We've already rejected enterprise above.
if (isOrgScopedSubscription(subscription, userId)) {
const isAdmin = await isOrganizationOwnerOrAdmin(userId, subscription.referenceId)
if (!isAdmin) {
return { success: false, error: 'Only organization owners and admins can purchase credits' }
}
entityType = 'organization'
entityId = subscription.referenceId
}
try {
const stripe = requireStripeClient()
const stripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId)
const customerId = getCustomerId(stripeSub.customer)
if (!customerId) {
return { success: false, error: 'Subscription missing customer' }
}
const { paymentMethodId: defaultPaymentMethod } = await resolveDefaultPaymentMethod(
stripe,
subscription.stripeSubscriptionId,
customerId
)
if (!defaultPaymentMethod) {
return {
success: false,
error: 'No payment method on file. Please update your billing info.',
}
}
const amountCents = Math.round(amountDollars * 100)
const idempotencyKey = `credit-purchase:${requestId}`
const creditMetadata = {
type: 'credit_purchase',
entityType,
entityId,
amountDollars: amountDollars.toString(),
purchasedBy: userId,
}
// Create invoice
const invoice = await stripe.invoices.create(
{
customer: customerId,
collection_method: 'charge_automatically',
auto_advance: false,
description: `Credit purchase - $${amountDollars}`,
metadata: creditMetadata,
default_payment_method: defaultPaymentMethod,
},
{ idempotencyKey: `${idempotencyKey}-invoice` }
)
// Add line item
await stripe.invoiceItems.create(
{
customer: customerId,
invoice: invoice.id,
amount: amountCents,
currency: 'usd',
description: `Prepaid credits ($${amountDollars})`,
metadata: creditMetadata,
},
{ idempotencyKey: `${idempotencyKey}-item` }
)
// Finalize and pay
if (!invoice.id) {
return { success: false, error: 'Failed to create invoice' }
}
const finalized = await stripe.invoices.finalizeInvoice(
invoice.id,
{},
{ idempotencyKey: `${idempotencyKey}-finalize` }
)
if (finalized.status === 'open' && finalized.id) {
await stripe.invoices.pay(
finalized.id,
{ payment_method: defaultPaymentMethod },
{ idempotencyKey: `${idempotencyKey}-pay` }
)
}
logger.info('Credit purchase invoice created and paid', {
invoiceId: invoice.id,
entityType,
entityId,
amountDollars,
purchasedBy: userId,
})
return { success: true }
} catch (error) {
logger.error('Failed to purchase credits', { error, userId, amountDollars })
const message = getErrorMessage(error, 'Failed to process payment')
return { success: false, error: message }
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Billing System - Main Entry Point
* Provides clean, organized exports for the billing system
*/
export * from '@/lib/billing/calculations/usage-monitor'
export * from '@/lib/billing/core/api-access'
export * from '@/lib/billing/core/billing'
export * from '@/lib/billing/core/organization'
export * from '@/lib/billing/core/subscription'
export {
getHighestPrioritySubscription as getActiveSubscription,
hasPaidSubscription,
hasSSOAccess,
isEnterpriseOrgAdminOrOwner,
isEnterprisePlan as hasEnterprisePlan,
isOrganizationOnEnterprisePlan,
isProPlan as hasProPlan,
isTeamPlan as hasTeamPlan,
isWorkspaceOnEnterprisePlan,
sendPlanWelcomeEmail,
} from '@/lib/billing/core/subscription'
export * from '@/lib/billing/core/usage'
export {
checkUsageStatus,
getUserUsageData as getUsageData,
getUserUsageLimit as getUsageLimit,
updateUserUsageLimit as updateUsageLimit,
} from '@/lib/billing/core/usage'
export * from '@/lib/billing/core/workspace-access'
export * from '@/lib/billing/credits/balance'
export * from '@/lib/billing/credits/purchase'
export {
blockOrgMembers,
getOrgMemberIds,
unblockOrgMembers,
} from '@/lib/billing/organizations/membership'
export * from '@/lib/billing/subscriptions/utils'
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
export * from '@/lib/billing/types'
export * from '@/lib/billing/validation/seat-management'
+157
View File
@@ -0,0 +1,157 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import {
getCoveredUsage,
getIsOnDemandActive,
getOnDemandOffLimit,
getPooledCreditsRemaining,
isOnDemandOffDisabled,
} from '@/lib/billing/on-demand'
describe('getPooledCreditsRemaining', () => {
it('returns limit minus usage, matching enforcement (usage >= limit blocks)', () => {
expect(getPooledCreditsRemaining(120, 62)).toBe(58)
expect(getPooledCreditsRemaining(30, 10)).toBe(20)
})
it('does not add the credit balance back (the double-count regression)', () => {
// team_6000, 2 seats: planBase $60 + credits $60 → limit $120, usage ~$62.
// Remaining is $58 ≈ 11,600 credits — NOT $118 ≈ 23,600 (limit + credits - usage).
const remaining = getPooledCreditsRemaining(120, 62)
expect(remaining).toBe(58)
expect(dollarsToCredits(remaining)).toBe(11_600)
expect(dollarsToCredits(remaining)).not.toBe(23_600)
})
it('clamps at zero when usage meets or exceeds the limit', () => {
expect(getPooledCreditsRemaining(100, 100)).toBe(0)
expect(getPooledCreditsRemaining(60, 100)).toBe(0)
})
it('short-circuits the unlimited sentinel to ∞ instead of subtracting usage', () => {
expect(getPooledCreditsRemaining(ON_DEMAND_UNLIMITED, 500)).toBe(ON_DEMAND_UNLIMITED)
expect(getPooledCreditsRemaining(ON_DEMAND_UNLIMITED + 1, 0)).toBe(ON_DEMAND_UNLIMITED)
})
})
describe('getCoveredUsage', () => {
it('sums the plan included amount and the goodwill credit balance', () => {
expect(getCoveredUsage(60, 60)).toBe(120)
expect(getCoveredUsage(30, 0)).toBe(30)
})
})
describe('getIsOnDemandActive', () => {
it('reads OFF when the limit only covers planBase + credits (credit grant is not on-demand)', () => {
// The concrete regression case: limit == covered, so the toggle reads OFF.
expect(
getIsOnDemandActive({
isPaid: true,
planIncludedAmount: 60,
effectiveUsageLimit: 120,
covered: getCoveredUsage(60, 60),
})
).toBe(false)
})
it('reads ON when the limit is raised above the covered ceiling', () => {
expect(
getIsOnDemandActive({
isPaid: true,
planIncludedAmount: 60,
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
covered: getCoveredUsage(60, 60),
})
).toBe(true)
expect(
getIsOnDemandActive({
isPaid: true,
planIncludedAmount: 60,
effectiveUsageLimit: 121,
covered: getCoveredUsage(60, 60),
})
).toBe(true)
})
it('is never active for non-paid plans or a zero included allowance', () => {
expect(
getIsOnDemandActive({
isPaid: false,
planIncludedAmount: 60,
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
covered: 120,
})
).toBe(false)
expect(
getIsOnDemandActive({
isPaid: true,
planIncludedAmount: 0,
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
covered: 0,
})
).toBe(false)
})
it('behaves equivalently on the personal Pro path (no credits)', () => {
const covered = getCoveredUsage(30, 0)
expect(
getIsOnDemandActive({
isPaid: true,
planIncludedAmount: 30,
effectiveUsageLimit: 30,
covered,
})
).toBe(false)
expect(
getIsOnDemandActive({
isPaid: true,
planIncludedAmount: 30,
effectiveUsageLimit: ON_DEMAND_UNLIMITED,
covered,
})
).toBe(true)
})
})
describe('getOnDemandOffLimit', () => {
it('drops the limit to the covered ceiling when usage is below it', () => {
expect(getOnDemandOffLimit(62, 120)).toBe(120)
expect(getOnDemandOffLimit(10, 30)).toBe(30)
})
it('never lowers the limit below current usage', () => {
expect(getOnDemandOffLimit(150, 120)).toBe(150)
})
it('lands on covered when usage equals it', () => {
expect(getOnDemandOffLimit(120, 120)).toBe(120)
})
})
describe('isOnDemandOffDisabled', () => {
it('disables the toggle when on-demand is on and usage is above covered', () => {
// Turning off here would re-cap at usage (150) and bounce back on, so lock it.
expect(
isOnDemandOffDisabled({ isOnDemandActive: true, effectiveCurrentUsage: 150, covered: 120 })
).toBe(true)
})
it('allows turning off when usage is at or below covered', () => {
expect(
isOnDemandOffDisabled({ isOnDemandActive: true, effectiveCurrentUsage: 120, covered: 120 })
).toBe(false)
expect(
isOnDemandOffDisabled({ isOnDemandActive: true, effectiveCurrentUsage: 62, covered: 120 })
).toBe(false)
})
it('never disables when on-demand is already off (turning on stays allowed)', () => {
expect(
isOnDemandOffDisabled({ isOnDemandActive: false, effectiveCurrentUsage: 150, covered: 120 })
).toBe(false)
})
})
+82
View File
@@ -0,0 +1,82 @@
/**
* On-demand / pooled usage display and toggle math.
*
* DB values are dollars; these helpers operate on dollars and are the single
* source of truth shared by the credits chip and the billing settings toggle so
* the two surfaces can never disagree about what "remaining" or "on-demand on"
* means.
*/
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
/**
* Dollars of pooled plan allowance still available before usage is capped.
*
* Mirrors enforcement exactly — `buildUsageData` in usage-monitor blocks when
* `currentUsage >= limit`, so remaining is `limit - currentUsage` and nothing
* more. Goodwill credits are already folded into `usageLimit` by
* `setUsageLimitForCredits`, so they must NOT be added back here; doing so
* double-counts the balance and overstates what's left. The unlimited sentinel
* short-circuits to itself — rendered as ∞ — instead of subtracting usage from
* the sentinel value.
*/
export function getPooledCreditsRemaining(usageLimit: number, currentUsage: number): number {
if (usageLimit >= ON_DEMAND_UNLIMITED) return ON_DEMAND_UNLIMITED
return Math.max(0, usageLimit - currentUsage)
}
/**
* The maximum usage that is never billed: the plan's included allowance
* (`planBase`) plus any goodwill credit balance. `setUsageLimitForCredits` raises
* the usage limit to exactly this value when credits are granted, so the stored
* limit already reflects the credits.
*/
export function getCoveredUsage(planIncludedAmount: number, creditBalance: number): number {
return planIncludedAmount + creditBalance
}
/**
* Whether on-demand (past-included) usage is enabled: the usage limit sits above
* the covered ceiling. Only meaningful for a paid plan with a positive included
* allowance. Comparing against `covered` — not `planIncludedAmount` alone — is
* what keeps a credit grant, which raises the limit to `planBase + creditBalance`,
* from being misread as on-demand having been switched on.
*/
export function getIsOnDemandActive(params: {
isPaid: boolean
planIncludedAmount: number
effectiveUsageLimit: number
covered: number
}): boolean {
const { isPaid, planIncludedAmount, effectiveUsageLimit, covered } = params
if (!isPaid || planIncludedAmount <= 0) return false
return effectiveUsageLimit > covered
}
/**
* The usage limit to persist when turning on-demand OFF: drop back to the covered
* ceiling, but never below current usage — lowering the limit under usage would
* retroactively put the account over its cap. When usage already exceeds covered
* the limit lands on current usage and the toggle stays on until usage resets;
* that is an accepted edge, never a blocked action.
*/
export function getOnDemandOffLimit(currentUsage: number, covered: number): number {
return Math.max(currentUsage, covered)
}
/**
* Whether the on-demand toggle should render disabled: it is on, but usage has
* already passed the covered ceiling, so turning it off would only re-cap the
* limit at current usage ({@link getOnDemandOffLimit}) and the control would
* spring straight back on. The UI disables it with an explanatory tooltip rather
* than accepting a no-op click. The state clears on its own once usage drops back
* to or below covered (e.g. at the next billing reset).
*/
export function isOnDemandOffDisabled(params: {
isOnDemandActive: boolean
effectiveCurrentUsage: number
covered: number
}): boolean {
const { isOnDemandActive, effectiveCurrentUsage, covered } = params
return isOnDemandActive && effectiveCurrentUsage > covered
}
+73
View File
@@ -0,0 +1,73 @@
/**
* @vitest-environment node
*/
import { dbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCreateOrganizationWithOwner,
mockAttachOwnedWorkspacesToOrganization,
mockGetOrganizationIdForSubscriptionReference,
} = vi.hoisted(() => ({
mockCreateOrganizationWithOwner: vi.fn(),
mockAttachOwnedWorkspacesToOrganization: vi.fn(),
mockGetOrganizationIdForSubscriptionReference: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/billing/core/billing', () => ({
getPlanPricing: vi.fn(),
}))
vi.mock('@/lib/billing/core/subscription', () => ({
getOrganizationIdForSubscriptionReference: mockGetOrganizationIdForSubscriptionReference,
}))
vi.mock('@/lib/billing/core/usage', () => ({
syncUsageLimitsFromSubscription: vi.fn(),
}))
vi.mock('@/lib/billing/plan-helpers', () => ({
isOrgPlan: (plan: string) => plan === 'team' || plan === 'enterprise',
isTeam: (plan: string) => plan === 'team',
}))
vi.mock('@/lib/billing/organizations/create-organization', () => ({
createOrganizationWithOwner: mockCreateOrganizationWithOwner,
}))
vi.mock('@/lib/workspaces/organization-workspaces', () => ({
attachOwnedWorkspacesToOrganization: mockAttachOwnedWorkspacesToOrganization,
}))
import { ensureOrganizationForTeamSubscription } from '@/lib/billing/organization'
describe('ensureOrganizationForTeamSubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetOrganizationIdForSubscriptionReference.mockResolvedValue(null)
})
it('treats existing legacy organization ids as organization references', async () => {
mockGetOrganizationIdForSubscriptionReference.mockResolvedValue('legacy-org-id')
const result = await ensureOrganizationForTeamSubscription({
id: 'sub-1',
plan: 'team',
referenceId: 'legacy-org-id',
status: 'active',
seats: 5,
})
expect(result).toEqual({
id: 'sub-1',
plan: 'team',
referenceId: 'legacy-org-id',
status: 'active',
seats: 5,
})
expect(mockCreateOrganizationWithOwner).not.toHaveBeenCalled()
expect(mockAttachOwnedWorkspacesToOrganization).not.toHaveBeenCalled()
})
})
+428
View File
@@ -0,0 +1,428 @@
import { db } from '@sim/db'
import {
member,
organization,
subscription as subscriptionTable,
user,
userStats,
} 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 { getPlanPricing } from '@/lib/billing/core/billing'
import { getOrganizationIdForSubscriptionReference } from '@/lib/billing/core/subscription'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { createOrganizationWithOwner } from '@/lib/billing/organizations/create-organization'
import { isEnterprise, isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import { attachOwnedWorkspacesToOrganization } from '@/lib/workspaces/organization-workspaces'
const logger = createLogger('BillingOrganization')
type SubscriptionData = {
id: string
plan: string
referenceId: string
status: string | null
seats?: number | null
}
/**
* Check if a user already owns an organization
*/
async function getUserOwnedOrganization(userId: string): Promise<string | null> {
const existingMemberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(and(eq(member.userId, userId), eq(member.role, 'owner')))
.limit(1)
if (existingMemberships.length > 0) {
const [existingOrg] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, existingMemberships[0].organizationId))
.limit(1)
return existingOrg?.id || null
}
return null
}
export async function createOrganizationForTeamPlan(
userId: string,
userName?: string,
userEmail?: string,
organizationSlug?: string
): Promise<string> {
try {
const existingOrgId = await getUserOwnedOrganization(userId)
if (existingOrgId) {
return existingOrgId
}
const organizationName = userName || `${userEmail || 'User'}'s Team`
const slug =
organizationSlug ||
`${userId}-team-${Date.now()}`
.toLowerCase()
.replace(/[^a-z0-9-_]+/g, '-')
.replace(/^-|-$/g, '')
const { organizationId: orgId } = await createOrganizationWithOwner({
ownerUserId: userId,
name: organizationName,
slug,
metadata: {
createdForTeamPlan: true,
originalUserId: userId,
},
})
logger.info('Created organization for team/enterprise plan', {
userId,
organizationId: orgId,
organizationName,
})
return orgId
} catch (error) {
logger.error('Failed to create organization for team/enterprise plan', {
userId,
error,
})
throw error
}
}
export async function ensureOrganizationForTeamSubscription(
subscription: SubscriptionData
): Promise<SubscriptionData> {
if (!isOrgPlan(subscription.plan)) {
return subscription
}
const referencedOrganizationId = await getOrganizationIdForSubscriptionReference(
subscription.referenceId
)
if (referencedOrganizationId) {
return {
...subscription,
referenceId: referencedOrganizationId,
}
}
const userId = subscription.referenceId
logger.info('Creating organization for team subscription', {
subscriptionId: subscription.id,
userId,
})
const existingMembership = await db
.select({
id: member.id,
organizationId: member.organizationId,
role: member.role,
})
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (existingMembership.length > 0) {
const membership = existingMembership[0]
if (isOrgAdminRole(membership.role)) {
/**
* Atomic duplicate-subscription check + referenceId transfer.
*
* Row-level locks (`FOR UPDATE`) on the subscription and target
* organization rows prevent a TOCTOU race between the "org has no
* paid subscription" check and the transfer write — which could
* otherwise let two concurrent webhook deliveries or org-creation
* flows both pass the check and attach two subscriptions to the
* same organization.
*/
await db.transaction(async (tx) => {
const [lockedSub] = await tx
.select({
id: subscriptionTable.id,
referenceId: subscriptionTable.referenceId,
})
.from(subscriptionTable)
.where(eq(subscriptionTable.id, subscription.id))
.for('update')
if (!lockedSub) {
throw new Error(`Subscription ${subscription.id} not found during transfer`)
}
if (lockedSub.referenceId === membership.organizationId) {
return
}
const [lockedOrg] = await tx
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, membership.organizationId))
.for('update')
if (!lockedOrg) {
throw new Error(`Organization ${membership.organizationId} not found during transfer`)
}
const [existingOrgSub] = await tx
.select({ id: subscriptionTable.id })
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, membership.organizationId),
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
if (existingOrgSub) {
logger.error('Organization already has an active subscription', {
userId,
organizationId: membership.organizationId,
newSubscriptionId: subscription.id,
})
throw new Error('Organization already has an active subscription')
}
await tx
.update(subscriptionTable)
.set({ referenceId: membership.organizationId })
.where(eq(subscriptionTable.id, subscription.id))
})
logger.info('User already owns/admins an org, using it', {
userId,
organizationId: membership.organizationId,
})
await attachOwnedWorkspacesToOrganization({
ownerUserId: userId,
organizationId: membership.organizationId,
externalMemberPolicy: 'keep-external',
})
return { ...subscription, referenceId: membership.organizationId }
}
logger.error('User is member of org but not owner/admin - cannot create team subscription', {
userId,
existingOrgId: membership.organizationId,
subscriptionId: subscription.id,
})
throw new Error('User is already member of another organization')
}
const [userData] = await db
.select({ name: user.name, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
const orgId = await createOrganizationForTeamPlan(
userId,
userData?.name || undefined,
userData?.email || undefined
)
await db
.update(subscriptionTable)
.set({ referenceId: orgId })
.where(eq(subscriptionTable.id, subscription.id))
await attachOwnedWorkspacesToOrganization({
ownerUserId: userId,
organizationId: orgId,
externalMemberPolicy: 'keep-external',
})
logger.info('Created organization and updated subscription referenceId', {
subscriptionId: subscription.id,
userId,
organizationId: orgId,
})
return { ...subscription, referenceId: orgId }
}
/**
* Sync usage limits for subscription members
* Updates usage limits for all users associated with the subscription
*/
export async function syncSubscriptionUsageLimits(subscription: SubscriptionData) {
try {
logger.info('Syncing subscription usage limits', {
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
plan: subscription.plan,
})
const organizationId = await getOrganizationIdForSubscriptionReference(subscription.referenceId)
if (!organizationId) {
const users = await db
.select({ id: user.id })
.from(user)
.where(eq(user.id, subscription.referenceId))
.limit(1)
if (users.length === 0) {
throw new Error(
`Subscription reference ${subscription.referenceId} does not match a user or organization`
)
}
// Individual user subscription - sync their usage limits
await syncUsageLimitsFromSubscription(subscription.referenceId)
logger.info('Synced usage limits for individual user subscription', {
userId: subscription.referenceId,
subscriptionId: subscription.id,
plan: subscription.plan,
})
} else {
// Organization subscription - set org usage limit and sync member limits
// Set orgUsageLimit for any paid non-enterprise plan attached to
// the org. Enterprise is set via webhook with custom pricing.
// Min = basePrice × seats, mirroring Stripe's `price × quantity`.
if (isPaid(subscription.plan) && !isEnterprise(subscription.plan)) {
const { basePrice } = getPlanPricing(subscription.plan)
const seats = subscription.seats || 1
const orgLimit = seats * basePrice
// Only set if not already set or if updating to a higher value based on seats
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
const currentLimit =
orgData.length > 0 && orgData[0].orgUsageLimit
? toNumber(toDecimal(orgData[0].orgUsageLimit))
: 0
// Update if no limit set, or if new seat-based minimum is higher
if (currentLimit < orgLimit) {
await db
.update(organization)
.set({
orgUsageLimit: orgLimit.toFixed(2),
updatedAt: new Date(),
})
.where(eq(organization.id, organizationId))
logger.info('Set organization usage limit', {
organizationId,
plan: subscription.plan,
seats,
basePrice,
orgLimit,
previousLimit: currentLimit,
})
}
}
// Sync usage limits for all members
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
if (members.length > 0) {
for (const m of members) {
try {
await syncUsageLimitsFromSubscription(m.userId)
} catch (memberError) {
logger.error('Failed to sync usage limits for organization member', {
userId: m.userId,
organizationId,
subscriptionId: subscription.id,
error: memberError,
})
}
}
logger.info('Synced usage limits for organization members', {
organizationId,
memberCount: members.length,
subscriptionId: subscription.id,
plan: subscription.plan,
})
// Bulk version of the per-member transfer in invitation-accept:
// catches members whose personal bytes never made it into the
// org pool (e.g. org upgraded free → paid after they joined).
// `.for('update')` row-locks so concurrent increment/decrement
// calls cannot slip between the snapshot SELECT and the
// zeroing UPDATE and get silently dropped. Idempotent — zeroed
// rows are filtered out.
if (isPaid(subscription.plan)) {
try {
const memberIds = members.map((m) => m.userId)
await db.transaction(async (tx) => {
const personalStorageRows = await tx
.select({
userId: userStats.userId,
bytes: userStats.storageUsedBytes,
})
.from(userStats)
.where(inArray(userStats.userId, memberIds))
.for('update')
const toTransfer = personalStorageRows.filter((r) => (r.bytes ?? 0) > 0)
const totalBytes = toTransfer.reduce((acc, r) => acc + (r.bytes ?? 0), 0)
if (totalBytes === 0) return
await tx
.update(organization)
.set({
storageUsedBytes: sql`${organization.storageUsedBytes} + ${totalBytes}`,
})
.where(eq(organization.id, organizationId))
await tx
.update(userStats)
.set({ storageUsedBytes: 0 })
.where(
inArray(
userStats.userId,
toTransfer.map((r) => r.userId)
)
)
logger.info('Transferred personal storage bytes to org pool during sync', {
organizationId,
subscriptionId: subscription.id,
memberCount: toTransfer.length,
totalBytes,
})
})
} catch (storageError) {
logger.error('Failed to transfer personal storage to org pool', {
organizationId,
subscriptionId: subscription.id,
error: storageError,
})
}
}
}
}
} catch (error) {
logger.error('Failed to sync subscription usage limits', {
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
error,
})
throw error
}
}
@@ -0,0 +1,88 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGenerateId } = vi.hoisted(() => ({
mockGenerateId: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/utils/id', () => ({
generateId: mockGenerateId,
generateShortId: vi.fn(() => 'short-id'),
}))
import {
createOrganizationWithOwner,
OrganizationSlugTakenError,
validateOrganizationSlugOrThrow,
} from '@/lib/billing/organizations/create-organization'
function insertedValuesFor(predicate: (values: Record<string, unknown>) => boolean) {
return dbChainMockFns.values.mock.calls
.map((call) => call[0] as Record<string, unknown>)
.filter(predicate)
}
describe('createOrganizationWithOwner', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates an organization with a Better Auth-compatible id prefix', async () => {
mockGenerateId.mockReturnValueOnce('abc123').mockReturnValueOnce('member456')
dbChainMockFns.limit.mockResolvedValueOnce([])
const result = await createOrganizationWithOwner({
ownerUserId: 'user-1',
name: 'My Org',
slug: 'my-org',
metadata: { source: 'test' },
})
expect(result).toEqual({
organizationId: 'org_abc123',
memberId: 'member456',
})
expect(insertedValuesFor((v) => 'slug' in v)).toEqual([
expect.objectContaining({
id: 'org_abc123',
name: 'My Org',
slug: 'my-org',
metadata: { source: 'test' },
}),
])
expect(insertedValuesFor((v) => !('slug' in v))).toEqual([
expect.objectContaining({
id: 'member456',
userId: 'user-1',
organizationId: 'org_abc123',
role: 'owner',
}),
])
})
it('throws a typed error when the organization slug is already taken', async () => {
mockGenerateId.mockReturnValueOnce('abc123').mockReturnValueOnce('member456')
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'existing-org' }])
await expect(
createOrganizationWithOwner({
ownerUserId: 'user-1',
name: 'My Org',
slug: 'my-org',
})
).rejects.toBeInstanceOf(OrganizationSlugTakenError)
expect(insertedValuesFor(() => true)).toEqual([])
})
it('rejects invalid organization slugs before writing anything', () => {
expect(() => validateOrganizationSlugOrThrow('Invalid Slug!')).toThrow(
'Organization slug "Invalid Slug!" is invalid'
)
})
})
@@ -0,0 +1,110 @@
import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, ne } from 'drizzle-orm'
const ORGANIZATION_SLUG_REGEX = /^[a-z0-9-_]+$/
export class OrganizationSlugInvalidError extends Error {
constructor(slug: string) {
super(`Organization slug "${slug}" is invalid`)
this.name = 'OrganizationSlugInvalidError'
}
}
export class OrganizationSlugTakenError extends Error {
constructor(slug: string) {
super(`Organization slug "${slug}" is already taken`)
this.name = 'OrganizationSlugTakenError'
}
}
interface CreateOrganizationWithOwnerParams {
ownerUserId: string
name: string
slug: string
metadata?: Record<string, unknown>
}
interface EnsureOrganizationSlugAvailableParams {
slug: string
excludeOrganizationId?: string
}
interface CreateOrganizationWithOwnerResult {
organizationId: string
memberId: string
}
export function validateOrganizationSlugOrThrow(slug: string): void {
if (!ORGANIZATION_SLUG_REGEX.test(slug)) {
throw new OrganizationSlugInvalidError(slug)
}
}
export async function ensureOrganizationSlugAvailable({
slug,
excludeOrganizationId,
}: EnsureOrganizationSlugAvailableParams): Promise<void> {
const whereClause = excludeOrganizationId
? and(eq(organization.slug, slug), ne(organization.id, excludeOrganizationId))
: eq(organization.slug, slug)
const existingOrganization = await db
.select({ id: organization.id })
.from(organization)
.where(whereClause)
.limit(1)
if (existingOrganization.length > 0) {
throw new OrganizationSlugTakenError(slug)
}
}
export async function createOrganizationWithOwner({
ownerUserId,
name,
slug,
metadata = {},
}: CreateOrganizationWithOwnerParams): Promise<CreateOrganizationWithOwnerResult> {
validateOrganizationSlugOrThrow(slug)
const organizationId = `org_${generateId()}`
const memberId = generateId()
const now = new Date()
await db.transaction(async (tx) => {
const whereClause = eq(organization.slug, slug)
const existingOrganization = await tx
.select({ id: organization.id })
.from(organization)
.where(whereClause)
.limit(1)
if (existingOrganization.length > 0) {
throw new OrganizationSlugTakenError(slug)
}
await tx.insert(organization).values({
id: organizationId,
name,
slug,
metadata,
createdAt: now,
updatedAt: now,
})
await tx.insert(member).values({
id: memberId,
userId: ownerUserId,
organizationId,
role: 'owner',
createdAt: now,
})
})
return {
organizationId,
memberId,
}
}
@@ -0,0 +1,97 @@
/**
* @vitest-environment node
*
* Lock-order regression guard: the paid-org join billing transaction must lock
* the personal Pro subscription BEFORE userStats, matching
* restoreUserProSubscription's subscription → userStats order. Snapshotting
* userStats before locking the subscription inverts that pair and deadlocks a
* concurrent Pro restore for the same user.
*/
import { subscription as subscriptionTable, userStats } from '@sim/db/schema'
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { reapplyPaidOrgJoinBillingForExistingMember } from '@/lib/billing/organizations/membership'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/core/outbox/service', () => ({
enqueueOutboxEvent: vi.fn(),
}))
/**
* A superset row that satisfies every read in the join path: a paid org sub, a
* still-active personal Pro to pause, non-zero usage to snapshot, and zero
* storage (so the conditional org storage-transfer write is skipped — the org
* lock under test is the canonical pre-lock, not the storage update).
*/
const GENERIC_ROW = {
id: 'sub-1',
plan: 'team',
referenceId: 'user-1',
status: 'active',
cancelAtPeriodEnd: false,
stripeSubscriptionId: 'stripe-1',
currentPeriodCost: '5',
proPeriodCostSnapshot: '0',
storageUsedBytes: 0,
}
type LockOp = { op: 'lock' | 'update' | 'insert'; table: unknown }
function createRecordingTx() {
const ops: LockOp[] = []
const select = () => {
const ctx: { table: unknown } = { table: undefined }
const chain = {
from: (table: unknown) => {
ctx.table = table
return chain
},
where: () => chain,
for: () => {
ops.push({ op: 'lock', table: ctx.table })
return chain
},
limit: async () => [GENERIC_ROW],
}
return chain
}
const tx = {
select,
update: (table: unknown) => ({
set: () => ({
where: async () => {
ops.push({ op: 'update', table })
},
}),
}),
insert: (table: unknown) => ({
values: async () => {
ops.push({ op: 'insert', table })
},
}),
execute: async () => [],
}
return { tx, ops }
}
describe('paid-org join billing lock ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('locks the personal subscription before mutating userStats', async () => {
const { tx, ops } = createRecordingTx()
dbChainMockFns.transaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx))
await reapplyPaidOrgJoinBillingForExistingMember('user-1', 'org-1')
const firstUserStatsUpdate = ops.findIndex((o) => o.op === 'update' && o.table === userStats)
const subscriptionLock = ops.findIndex((o) => o.op === 'lock' && o.table === subscriptionTable)
expect(firstUserStatsUpdate).toBeGreaterThanOrEqual(0)
expect(subscriptionLock).toBeGreaterThanOrEqual(0)
expect(subscriptionLock).toBeLessThan(firstUserStatsUpdate)
})
})
@@ -0,0 +1,161 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockDbState,
mockInsert,
mockInsertValues,
mockOnConflictDoUpdate,
mockDelete,
mockDeleteWhere,
mockGetOrganizationSubscription,
mockGetOrgWorkspaceUsageCostForUser,
} = vi.hoisted(() => ({
mockDbState: { selectResults: [] as unknown[] },
mockInsert: vi.fn(),
mockInsertValues: vi.fn(),
mockOnConflictDoUpdate: vi.fn(),
mockDelete: vi.fn(),
mockDeleteWhere: vi.fn(),
mockGetOrganizationSubscription: vi.fn(),
mockGetOrgWorkspaceUsageCostForUser: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(() => {
const chain: Record<string, unknown> = {}
chain.from = vi.fn(() => chain)
chain.where = vi.fn(() => chain)
chain.limit = vi.fn(() => Promise.resolve(mockDbState.selectResults.shift() ?? []))
chain.then = (cb: (rows: unknown) => unknown) =>
Promise.resolve(cb(mockDbState.selectResults.shift() ?? []))
return chain
}),
insert: mockInsert,
delete: mockDelete,
},
}))
vi.mock('@sim/db/schema', () => ({
organizationMemberUsageLimit: {
id: 'oml.id',
organizationId: 'oml.organizationId',
userId: 'oml.userId',
usageLimit: 'oml.usageLimit',
setBy: 'oml.setBy',
createdAt: 'oml.createdAt',
updatedAt: 'oml.updatedAt',
},
workspace: { id: 'workspace.id', organizationId: 'workspace.organizationId' },
}))
vi.mock('@/lib/billing/core/billing', () => ({
getOrganizationSubscription: mockGetOrganizationSubscription,
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
getOrgWorkspaceUsageCostForUser: mockGetOrgWorkspaceUsageCostForUser,
}))
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import {
getOrgMemberUsageLimit,
getOrgMemberWorkspaceUsage,
setOrgMemberUsageLimit,
} from '@/lib/billing/organizations/member-limits'
beforeEach(() => {
vi.clearAllMocks()
mockDbState.selectResults = []
mockInsert.mockReturnValue({ values: mockInsertValues })
mockInsertValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate })
mockOnConflictDoUpdate.mockResolvedValue(undefined)
mockDelete.mockReturnValue({ where: mockDeleteWhere })
mockDeleteWhere.mockResolvedValue(undefined)
})
describe('getOrgMemberUsageLimit', () => {
it('returns null when no row exists', async () => {
mockDbState.selectResults = [[]]
await expect(getOrgMemberUsageLimit('org-1', 'user-2')).resolves.toBeNull()
})
it('returns the stored dollar limit as a number', async () => {
mockDbState.selectResults = [[{ usageLimit: '2' }]]
await expect(getOrgMemberUsageLimit('org-1', 'user-2')).resolves.toBe(2)
})
})
describe('setOrgMemberUsageLimit', () => {
it('upserts when given a dollar amount', async () => {
await setOrgMemberUsageLimit('org-1', 'user-2', 2, 'admin-1')
expect(mockInsert).toHaveBeenCalledTimes(1)
expect(mockDelete).not.toHaveBeenCalled()
const values = mockInsertValues.mock.calls[0][0]
expect(values).toMatchObject({
organizationId: 'org-1',
userId: 'user-2',
usageLimit: '2',
setBy: 'admin-1',
})
expect(mockOnConflictDoUpdate).toHaveBeenCalledTimes(1)
})
it('deletes the row when limit is null', async () => {
await setOrgMemberUsageLimit('org-1', 'user-2', null, 'admin-1')
expect(mockDelete).toHaveBeenCalledTimes(1)
expect(mockDeleteWhere).toHaveBeenCalledTimes(1)
expect(mockInsert).not.toHaveBeenCalled()
})
})
describe('getOrgMemberWorkspaceUsage', () => {
it("returns the member's usage within the org subscription window", async () => {
const periodStart = new Date('2026-06-01T00:00:00.000Z')
const periodEnd = new Date('2026-07-01T00:00:00.000Z')
mockGetOrganizationSubscription.mockResolvedValue({ periodStart, periodEnd })
mockGetOrgWorkspaceUsageCostForUser.mockResolvedValue(5)
const result = await getOrgMemberWorkspaceUsage('org-1', 'user-2')
expect(result).toBe(5)
expect(mockGetOrgWorkspaceUsageCostForUser).toHaveBeenCalledWith('org-1', 'user-2', {
start: periodStart,
end: periodEnd,
})
})
it('falls back to the all-time window when the org has no subscription', async () => {
mockGetOrganizationSubscription.mockResolvedValue(null)
mockGetOrgWorkspaceUsageCostForUser.mockResolvedValue(7)
const result = await getOrgMemberWorkspaceUsage('org-1', 'user-2')
expect(result).toBe(7)
expect(mockGetOrgWorkspaceUsageCostForUser).toHaveBeenCalledWith(
'org-1',
'user-2',
defaultBillingPeriod()
)
})
it('falls back to the all-time window when the subscription is missing periodEnd', async () => {
mockGetOrganizationSubscription.mockResolvedValue({
periodStart: new Date('2026-06-01T00:00:00.000Z'),
periodEnd: null,
})
mockGetOrgWorkspaceUsageCostForUser.mockResolvedValue(3)
const result = await getOrgMemberWorkspaceUsage('org-1', 'user-2')
expect(result).toBe(3)
expect(mockGetOrgWorkspaceUsageCostForUser).toHaveBeenCalledWith(
'org-1',
'user-2',
defaultBillingPeriod()
)
})
})
@@ -0,0 +1,114 @@
import { db } from '@sim/db'
import { organizationMemberUsageLimit } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import { getOrgWorkspaceUsageCostForUser } from '@/lib/billing/core/usage-log'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
const logger = createLogger('OrgMemberLimits')
/**
* Read a member's per-organization usage limit (dollars). Returns `null` when no
* cap is set for the `(organization, user)` pair — meaning only the pooled org
* limit applies. Independent of `user_stats.current_usage_limit` (the user's
* personal subscription cap), so it covers external members without clobbering
* their personal limit.
*/
export async function getOrgMemberUsageLimit(
organizationId: string,
userId: string
): Promise<number | null> {
const rows = await db
.select({ usageLimit: organizationMemberUsageLimit.usageLimit })
.from(organizationMemberUsageLimit)
.where(
and(
eq(organizationMemberUsageLimit.organizationId, organizationId),
eq(organizationMemberUsageLimit.userId, userId)
)
)
.limit(1)
if (rows.length === 0) return null
return toNumber(toDecimal(rows[0].usageLimit))
}
/**
* Upsert (or clear) a member's per-organization usage limit. Passing `null` for
* `limitDollars` deletes the row, removing the per-member cap. The target need
* not be an organization `member` row, so external members are supported.
*/
export async function setOrgMemberUsageLimit(
organizationId: string,
userId: string,
limitDollars: number | null,
setBy?: string
): Promise<void> {
if (limitDollars === null) {
await db
.delete(organizationMemberUsageLimit)
.where(
and(
eq(organizationMemberUsageLimit.organizationId, organizationId),
eq(organizationMemberUsageLimit.userId, userId)
)
)
logger.info('Cleared per-member usage limit', { organizationId, userId, setBy })
return
}
await db
.insert(organizationMemberUsageLimit)
.values({
id: generateId(),
organizationId,
userId,
usageLimit: limitDollars.toString(),
setBy: setBy ?? null,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [organizationMemberUsageLimit.organizationId, organizationMemberUsageLimit.userId],
set: {
usageLimit: limitDollars.toString(),
setBy: setBy ?? null,
updatedAt: new Date(),
},
})
logger.info('Set per-member usage limit', { organizationId, userId, limitDollars, setBy })
}
/**
* Compute a member's current-period usage (dollars) inside the organization's
* own workspaces.
*
* Sums `usage_log` by `created_at` within the org subscription window across the
* org's workspaces, scoped to the given user (a single indexed aggregation — see
* {@link getOrgWorkspaceUsageCostForUser}). Filtering by workspace (not billing
* entity) is what captures external members and mothership/copilot cost. Raw
* usage — daily-refresh credits are a pooled concept and intentionally not
* deducted here.
*
* When the org has no resolvable subscription window, falls back to the open
* (all-time) window, matching how the rest of the billing layer resolves a
* missing period (e.g. {@link deriveBillingContext}, the pooled-org and user
* usage paths). A hosted org with a per-member cap normally has a period, so
* this fallback is an edge case; using the shared convention keeps this path
* consistent with every other usage read rather than special-casing it.
*/
export async function getOrgMemberWorkspaceUsage(
organizationId: string,
userId: string
): Promise<number> {
const subscription = await getOrganizationSubscription(organizationId)
const billingPeriod =
subscription?.periodStart && subscription.periodEnd
? { start: subscription.periodStart, end: subscription.periodEnd }
: defaultBillingPeriod()
return getOrgWorkspaceUsageCostForUser(organizationId, userId, billingPeriod)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,247 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetOrganizationSubscription,
mockGetHighestPriorityPersonalSubscription,
mockEnsureOrganizationForTeamSubscription,
mockGetPlanByName,
enqueueMock,
updateCalls,
} = vi.hoisted(() => ({
mockGetOrganizationSubscription: vi.fn(),
mockGetHighestPriorityPersonalSubscription: vi.fn(),
mockEnsureOrganizationForTeamSubscription: vi.fn(),
mockGetPlanByName: vi.fn(),
enqueueMock: vi.fn(),
updateCalls: { value: [] as Array<Record<string, unknown>> },
}))
vi.mock('@sim/db', () => {
const update = () => ({
set: (values: Record<string, unknown>) => {
updateCalls.value.push(values)
return { where: () => Promise.resolve([]) }
},
})
const txMock = { update }
const dbMock = {
update,
transaction: async (cb: (tx: typeof txMock) => Promise<unknown>) => cb(txMock),
}
return { db: dbMock }
})
vi.mock('@/lib/billing/core/billing', () => ({
getOrganizationSubscription: mockGetOrganizationSubscription,
}))
vi.mock('@/lib/billing/core/plan', () => ({
getHighestPriorityPersonalSubscription: mockGetHighestPriorityPersonalSubscription,
}))
vi.mock('@/lib/billing/organization', () => ({
ensureOrganizationForTeamSubscription: mockEnsureOrganizationForTeamSubscription,
}))
vi.mock('@/lib/billing/plans', () => ({
getPlanByName: mockGetPlanByName,
}))
vi.mock('@/lib/core/outbox/service', () => ({
enqueueOutboxEvent: enqueueMock,
}))
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({
OUTBOX_EVENT_TYPES: {
STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end',
STRIPE_SYNC_SUBSCRIPTION_SEATS: 'stripe.sync-subscription-seats',
},
}))
import { ensureTeamOrganizationForAcceptance } from '@/lib/billing/organizations/provision-seat'
describe('ensureTeamOrganizationForAcceptance', () => {
beforeEach(() => {
vi.clearAllMocks()
updateCalls.value = []
mockGetPlanByName.mockReturnValue({
priceId: 'price_team_month',
annualDiscountPriceId: 'price_team_year',
})
})
it('is a no-op for enterprise organizations (fixed seats)', async () => {
mockGetOrganizationSubscription.mockResolvedValue({
id: 'sub-ent',
plan: 'enterprise',
status: 'active',
stripeSubscriptionId: 'stripe_sub',
seats: 5,
})
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: 'org-1',
})
expect(result).toEqual({ success: true, organizationId: 'org-1', fixedSeats: true })
expect(updateCalls.value).toHaveLength(0)
})
it('is a no-op for an existing Team organization (org + plan already correct)', async () => {
mockGetOrganizationSubscription.mockResolvedValue({
id: 'sub-team',
plan: 'team_6000',
status: 'active',
seats: 1,
stripeSubscriptionId: 'stripe_sub',
})
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: 'org-1',
})
expect(result).toEqual({ success: true, organizationId: 'org-1', fixedSeats: false })
expect(updateCalls.value).toHaveLength(0)
})
it('moves an org-scoped Pro subscription to the equivalent Team plan', async () => {
mockGetOrganizationSubscription.mockResolvedValue({
id: 'sub-pro',
plan: 'pro_6000',
status: 'active',
seats: 1,
stripeSubscriptionId: 'stripe_sub',
})
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: 'org-1',
})
expect(result).toEqual({ success: true, organizationId: 'org-1', fixedSeats: false })
expect(updateCalls.value).toContainEqual(expect.objectContaining({ plan: 'team_6000' }))
// The Pro→Team price migration is durably enqueued at conversion time.
expect(enqueueMock).toHaveBeenCalledWith(
expect.anything(),
'stripe.sync-subscription-seats',
expect.objectContaining({ subscriptionId: 'sub-pro' })
)
})
it('returns upgrade-required when the personal owner has no usable subscription', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null)
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: null,
})
expect(result).toEqual({ success: false, failureCode: 'upgrade-required' })
expect(mockEnsureOrganizationForTeamSubscription).not.toHaveBeenCalled()
})
it('converts a personal Pro subscription into a Team organization', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
id: 'sub-pro',
plan: 'pro_6000',
status: 'active',
stripeSubscriptionId: 'stripe_sub',
cancelAtPeriodEnd: false,
})
mockEnsureOrganizationForTeamSubscription.mockResolvedValue({ referenceId: 'org-new' })
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: null,
})
expect(result).toEqual({ success: true, organizationId: 'org-new', fixedSeats: false })
expect(updateCalls.value).toContainEqual(
expect.objectContaining({ plan: 'team_6000', cancelAtPeriodEnd: false })
)
expect(mockEnsureOrganizationForTeamSubscription).toHaveBeenCalledWith(
expect.objectContaining({ plan: 'team_6000', referenceId: 'owner-1' })
)
// The plan change enqueues the price seat-sync...
expect(enqueueMock).toHaveBeenCalledWith(
expect.anything(),
'stripe.sync-subscription-seats',
expect.objectContaining({ subscriptionId: 'sub-pro' })
)
// ...but with no scheduled cancellation there is no cancel-sync event.
expect(enqueueMock).not.toHaveBeenCalledWith(
expect.anything(),
'stripe.sync-cancel-at-period-end',
expect.anything()
)
})
it('clears a scheduled cancellation when converting a Pro subscription', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
id: 'sub-pro',
plan: 'pro_6000',
status: 'active',
stripeSubscriptionId: 'stripe_sub',
cancelAtPeriodEnd: true,
})
mockEnsureOrganizationForTeamSubscription.mockResolvedValue({ referenceId: 'org-new' })
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: null,
})
expect(result.success).toBe(true)
expect(enqueueMock).toHaveBeenCalledWith(
expect.anything(),
'stripe.sync-cancel-at-period-end',
expect.objectContaining({ stripeSubscriptionId: 'stripe_sub', subscriptionId: 'sub-pro' })
)
})
it('provisions an org for a legacy personal-scoped Team subscription without a plan change', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
id: 'sub-team',
plan: 'team',
status: 'active',
stripeSubscriptionId: 'stripe_sub',
})
mockEnsureOrganizationForTeamSubscription.mockResolvedValue({ referenceId: 'org-new' })
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: null,
})
expect(result).toEqual({ success: true, organizationId: 'org-new', fixedSeats: false })
expect(mockEnsureOrganizationForTeamSubscription).toHaveBeenCalledWith(
expect.objectContaining({ plan: 'team', referenceId: 'owner-1' })
)
// No plan change and no scheduled cancellation: nothing to push to Stripe.
expect(enqueueMock).not.toHaveBeenCalled()
})
it('returns upgrade-required (no downgrade) when no eligible Team tier exists', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
id: 'sub-max',
plan: 'pro_25000',
status: 'active',
stripeSubscriptionId: 'stripe_sub',
})
// Team Max price is unconfigured in this deployment.
mockGetPlanByName.mockReturnValue(undefined)
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: null,
})
expect(result).toEqual({ success: false, failureCode: 'upgrade-required' })
expect(mockEnsureOrganizationForTeamSubscription).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,255 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { subscription as subscriptionTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { CREDIT_TIERS } from '@/lib/billing/constants'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan'
import { ensureOrganizationForTeamSubscription } from '@/lib/billing/organization'
import {
buildPlanName,
getPlanTierCredits,
isEnterprise,
isPro,
isTeam,
} from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('ProvisionSeat')
interface RecordPlanConversionParams {
organizationId: string
actorId: string
fromPlan: string
toPlan: string
}
/**
* Record telemetry for a Pro→Team plan conversion triggered by invite
* acceptance. Fire-and-forget — `recordAudit` and `captureServerEvent` never
* throw, so this can never break the conversion flow. The billing interval is
* preserved across the conversion, so it is reported as `unchanged`.
*/
function recordPlanConversion({
organizationId,
actorId,
fromPlan,
toPlan,
}: RecordPlanConversionParams): void {
recordAudit({
workspaceId: null,
actorId,
action: AuditAction.ORG_PLAN_CONVERTED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: `Converted ${fromPlan} to ${toPlan}`,
metadata: {
fromPlan,
toPlan,
trigger: 'invite-acceptance',
},
})
captureServerEvent(actorId, 'subscription_changed', {
from_plan: fromPlan,
to_plan: toPlan,
interval: 'unchanged',
})
}
export type EnsureTeamOrganizationFailureCode = 'upgrade-required' | 'server-error'
export type EnsureTeamOrganizationResult =
| { success: true; organizationId: string; fixedSeats: boolean }
| { success: false; failureCode: EnsureTeamOrganizationFailureCode }
interface EnsureTeamOrganizationParams {
billingOwnerUserId: string
workspaceOrganizationId: string | null
}
/**
* Ensure the organization an invitee is about to enter exists and is on a Team
* plan, WITHOUT calling Stripe. Returns the organization to add the member to
* and whether seats are fixed (Enterprise).
*
* Seat purchasing is intentionally decoupled from this synchronous path: after
* the member is added, the caller reconciles the seat count and the actual
* Stripe charge happens asynchronously via the seat-sync outbox. A failed
* charge then blocks the org through the existing billing-blocked system rather
* than blocking acceptance synchronously. This keeps the accept path pure-DB,
* with no external call held under a lock.
*
* - Organization (Team): no-op; the org already exists on a Team plan.
* - Organization (Enterprise): `fixedSeats: true` — the caller keeps the
* fixed-seat validation and seats do not auto-grow.
* - Organization (org-scoped Pro): move the plan to the equivalent Team tier.
* - Personal/grandfathered (Pro, or legacy personal Team): create the org,
* attach workspaces, and move to a Team plan.
* - Free / no usable subscription / no eligible Team tier: `upgrade-required`.
*/
export async function ensureTeamOrganizationForAcceptance(
params: EnsureTeamOrganizationParams
): Promise<EnsureTeamOrganizationResult> {
const { billingOwnerUserId, workspaceOrganizationId } = params
try {
if (workspaceOrganizationId) {
return await ensureOrganizationOnTeamPlan(workspaceOrganizationId, billingOwnerUserId)
}
return await convertPersonalSubscriptionToTeam(billingOwnerUserId)
} catch (error) {
logger.error('Failed to ensure team organization for acceptance', {
billingOwnerUserId,
workspaceOrganizationId,
error,
})
return { success: false, failureCode: 'server-error' }
}
}
async function ensureOrganizationOnTeamPlan(
organizationId: string,
actorId: string
): Promise<EnsureTeamOrganizationResult> {
const orgSub = await getOrganizationSubscription(organizationId)
if (!orgSub || !hasUsableSubscriptionStatus(orgSub.status)) {
return { success: false, failureCode: 'upgrade-required' }
}
if (isEnterprise(orgSub.plan)) {
return { success: true, organizationId, fixedSeats: true }
}
if (!isTeam(orgSub.plan)) {
const targetPlan = mapToTeamPlanName(orgSub.plan)
if (!targetPlan) {
return { success: false, failureCode: 'upgrade-required' }
}
await activateTeamSubscription(orgSub, targetPlan, { planChanged: true })
recordPlanConversion({
organizationId,
actorId,
fromPlan: orgSub.plan,
toPlan: targetPlan,
})
}
return { success: true, organizationId, fixedSeats: false }
}
async function convertPersonalSubscriptionToTeam(
userId: string
): Promise<EnsureTeamOrganizationResult> {
const personalSub = await getHighestPriorityPersonalSubscription(userId)
if (!personalSub || !hasUsableSubscriptionStatus(personalSub.status)) {
return { success: false, failureCode: 'upgrade-required' }
}
const alreadyTeam = isTeam(personalSub.plan)
if (!alreadyTeam && !isPro(personalSub.plan)) {
return { success: false, failureCode: 'upgrade-required' }
}
const targetPlan = mapToTeamPlanName(personalSub.plan)
if (!targetPlan) {
return { success: false, failureCode: 'upgrade-required' }
}
await activateTeamSubscription(personalSub, targetPlan, { planChanged: !alreadyTeam })
const updated = await ensureOrganizationForTeamSubscription({
id: personalSub.id,
plan: targetPlan,
referenceId: userId,
status: personalSub.status,
seats: personalSub.seats ?? 1,
})
logger.info('Converted personal subscription to Team on invite acceptance', {
userId,
organizationId: updated.referenceId,
plan: targetPlan,
upgradedFromPro: !alreadyTeam,
})
if (!alreadyTeam) {
recordPlanConversion({
organizationId: updated.referenceId,
actorId: userId,
fromPlan: personalSub.plan,
toPlan: targetPlan,
})
}
return { success: true, organizationId: updated.referenceId, fixedSeats: false }
}
/**
* Activate a subscription as a Team plan in one transaction and durably enqueue
* the Stripe reconciliation. When the plan actually changes (Pro→Team), the
* seat-sync event is enqueued so the handler migrates the Stripe price (and
* quantity) at processing time — guaranteeing the price change lands even if
* the post-join seat reconcile is skipped or fails. Any scheduled cancellation
* is cleared (DB + Stripe) so a freshly-activated Team is not left scheduled to
* cancel, including the legacy personal-scoped Team case where the plan is
* unchanged.
*/
async function activateTeamSubscription(
sub: { id: string; cancelAtPeriodEnd?: boolean | null; stripeSubscriptionId: string | null },
targetPlan: string,
{ planChanged }: { planChanged: boolean }
): Promise<void> {
const shouldClearCancellation =
Boolean(sub.cancelAtPeriodEnd) && Boolean(sub.stripeSubscriptionId)
await db.transaction(async (tx) => {
await tx
.update(subscriptionTable)
.set({ plan: targetPlan, cancelAtPeriodEnd: false })
.where(eq(subscriptionTable.id, sub.id))
if (planChanged) {
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS, {
subscriptionId: sub.id,
reason: 'pro-to-team-conversion',
})
}
if (shouldClearCancellation) {
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
stripeSubscriptionId: sub.stripeSubscriptionId as string,
subscriptionId: sub.id,
reason: 'pro-to-team-conversion',
})
}
})
}
/**
* Map a Pro (or legacy) plan to a Team tier, choosing the smallest Team tier
* whose credit allowance is at least the current plan's so an upgrade never
* silently drops credits. Returns `null` when no eligible Team tier exists
* (e.g. a Max owner when the Team Max price is unconfigured) so the caller can
* surface `upgrade-required` instead of downgrading.
*/
function mapToTeamPlanName(plan: string): string | null {
if (isTeam(plan)) return plan
const credits = getPlanTierCredits(plan)
const eligibleTiers = [...CREDIT_TIERS]
.filter((tier) => tier.credits >= credits)
.sort((a, b) => a.credits - b.credits)
for (const tier of eligibleTiers) {
const candidate = buildPlanName('team', tier.credits)
if (getPlanByName(candidate)) {
return candidate
}
}
return null
}
@@ -0,0 +1,108 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockReconcileOrganizationSeats, selectRows, mockFeatureFlags } = vi.hoisted(() => ({
mockReconcileOrganizationSeats: vi.fn(),
selectRows: { value: [] as unknown[] },
mockFeatureFlags: { isBillingEnabled: true },
}))
vi.mock('@sim/db', () => {
const makeChain = () => {
const chain: Record<string, unknown> = {}
chain.from = () => chain
chain.innerJoin = () => chain
chain.where = () => chain
chain.groupBy = () => chain
chain.having = () => chain
chain.orderBy = () => Promise.resolve(selectRows.value)
return chain
}
return { db: { select: () => makeChain() } }
})
vi.mock('@/lib/billing/organizations/seats', () => ({
reconcileOrganizationSeats: mockReconcileOrganizationSeats,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isBillingEnabled() {
return mockFeatureFlags.isBillingEnabled
},
}))
import { reconcileTeamSeatDrift } from '@/lib/billing/organizations/seat-drift'
describe('reconcileTeamSeatDrift', () => {
beforeEach(() => {
vi.clearAllMocks()
selectRows.value = []
mockFeatureFlags.isBillingEnabled = true
mockReconcileOrganizationSeats.mockResolvedValue({ changed: true, previousSeats: 1, seats: 2 })
})
it('reconciles each drifted Team org returned by the query', async () => {
// The SQL WHERE (Team-only) + HAVING (seats != member count) already
// restrict the result to drifted Team orgs; the function reconciles each.
selectRows.value = [{ organizationId: 'org-1' }, { organizationId: 'org-2' }]
const result = await reconcileTeamSeatDrift()
expect(result).toEqual({ drifted: 2, reconciled: 2 })
expect(mockReconcileOrganizationSeats).toHaveBeenCalledTimes(2)
expect(mockReconcileOrganizationSeats).toHaveBeenCalledWith({
organizationId: 'org-1',
reason: 'seat-drift-sweep',
})
expect(mockReconcileOrganizationSeats).toHaveBeenCalledWith({
organizationId: 'org-2',
reason: 'seat-drift-sweep',
})
})
it('counts only reconciles that changed the seat count', async () => {
selectRows.value = [{ organizationId: 'org-a' }, { organizationId: 'org-b' }]
mockReconcileOrganizationSeats
.mockResolvedValueOnce({ changed: true, seats: 2 })
.mockResolvedValueOnce({ changed: false })
const result = await reconcileTeamSeatDrift()
expect(result).toEqual({ drifted: 2, reconciled: 1 })
})
it('continues past a reconcile failure', async () => {
selectRows.value = [{ organizationId: 'org-a' }, { organizationId: 'org-b' }]
mockReconcileOrganizationSeats
.mockRejectedValueOnce(new Error('db error'))
.mockResolvedValueOnce({ changed: true, seats: 3 })
const result = await reconcileTeamSeatDrift()
expect(result).toEqual({ drifted: 2, reconciled: 1 })
expect(mockReconcileOrganizationSeats).toHaveBeenCalledTimes(2)
})
it('no-ops when billing is disabled', async () => {
mockFeatureFlags.isBillingEnabled = false
const result = await reconcileTeamSeatDrift()
expect(result).toEqual({ drifted: 0, reconciled: 0 })
expect(mockReconcileOrganizationSeats).not.toHaveBeenCalled()
})
it('caps reconciles per run while still reporting the full drift count', async () => {
selectRows.value = Array.from({ length: 150 }, (_, i) => ({
organizationId: `org-${i}`,
}))
const result = await reconcileTeamSeatDrift()
expect(result.drifted).toBe(150)
expect(result.reconciled).toBe(100)
expect(mockReconcileOrganizationSeats).toHaveBeenCalledTimes(100)
})
})
@@ -0,0 +1,94 @@
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNotNull, like, or, sql } from 'drizzle-orm'
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
const logger = createLogger('SeatDriftSweep')
/** Max orgs reconciled per sweep run so a mass-drift event can't run away. */
const MAX_RECONCILES_PER_RUN = 100
export interface SeatDriftSweepResult {
/** How many Team orgs were found with a seat/member-count mismatch. */
drifted: number
/** How many of those were reconciled (seat count changed) this run. */
reconciled: number
}
/**
* Periodic backstop that re-aligns Team organizations whose stored seat count
* has drifted from their actual member count — e.g. when a post-join or
* post-removal `reconcileOrganizationSeats` transaction failed before
* committing. Each drifted org is reconciled, which re-enqueues the Stripe
* seat-sync.
*
* This only catches drift where the DB seat count is wrong. The opposite case —
* the DB committed but the Stripe sync dead-lettered — is surfaced via the
* dead-letter report, since the seat counts already match here.
*
* Candidates are sampled in random order so that, when more than
* `MAX_RECONCILES_PER_RUN` orgs drift, a subset that keeps failing can't starve
* the rest — each run reconciles a different random slice until all converge.
* Subscriptions without a Stripe id are excluded since their drift is not
* resolvable here (reconcile can't push to Stripe).
*/
export async function reconcileTeamSeatDrift(): Promise<SeatDriftSweepResult> {
if (!isBillingEnabled) {
return { drifted: 0, reconciled: 0 }
}
/**
* The filter runs entirely in SQL: only Team plans (`team` or `team_*`), with
* a usable status and a Stripe id, whose stored `seats` differs from their
* live member count (`HAVING`). Non-Team orgs are never materialized.
*/
const driftedRows = await db
.select({ organizationId: subscription.referenceId })
.from(subscription)
.innerJoin(member, eq(member.organizationId, subscription.referenceId))
.where(
and(
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES),
isNotNull(subscription.stripeSubscriptionId),
or(eq(subscription.plan, 'team'), like(subscription.plan, 'team\\_%'))
)
)
.groupBy(subscription.referenceId, subscription.plan, subscription.seats)
.having(sql`coalesce(${subscription.seats}, 1) <> count(${member.id})`)
.orderBy(sql`random()`)
const batch = driftedRows.slice(0, MAX_RECONCILES_PER_RUN)
let reconciled = 0
for (const row of batch) {
try {
const result = await reconcileOrganizationSeats({
organizationId: row.organizationId,
reason: 'seat-drift-sweep',
})
if (result.changed) reconciled++
} catch (error) {
logger.error('Failed to reconcile seat drift for organization', {
organizationId: row.organizationId,
error,
})
}
}
if (driftedRows.length > batch.length) {
logger.warn('Seat drift sweep hit its per-run cap; remaining orgs reconcile next run', {
drifted: driftedRows.length,
cap: MAX_RECONCILES_PER_RUN,
})
} else if (driftedRows.length > 0) {
logger.info('Seat drift sweep reconciled organizations', {
drifted: driftedRows.length,
reconciled,
})
}
return { drifted: driftedRows.length, reconciled }
}
@@ -0,0 +1,211 @@
/**
* @vitest-environment node
*/
import { auditMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSyncSubscriptionUsageLimits, enqueueMock, setMock, queryQueue, mockFeatureFlags } =
vi.hoisted(() => ({
mockSyncSubscriptionUsageLimits: vi.fn(),
enqueueMock: vi.fn(),
setMock: vi.fn(),
queryQueue: { value: [] as unknown[][] },
mockFeatureFlags: { isBillingEnabled: true },
}))
vi.mock('@sim/db', () => {
const makeSelectChain = () => {
const chain: Record<string, unknown> = {}
chain.from = () => chain
chain.where = () => chain
chain.for = () => chain
chain.limit = () => Promise.resolve(queryQueue.value.shift() ?? [])
chain.then = (resolve: (rows: unknown[]) => unknown, reject?: (e: unknown) => unknown) =>
Promise.resolve(queryQueue.value.shift() ?? []).then(resolve, reject)
return chain
}
const update = () => ({
set: (values: Record<string, unknown>) => {
setMock(values)
return { where: () => Promise.resolve([]) }
},
})
const txMock = { select: () => makeSelectChain(), update }
const dbMock = {
select: () => makeSelectChain(),
update,
transaction: async (cb: (tx: typeof txMock) => Promise<unknown>) => cb(txMock),
}
return { db: dbMock }
})
vi.mock('@/lib/billing/organization', () => ({
syncSubscriptionUsageLimits: mockSyncSubscriptionUsageLimits,
}))
vi.mock('@/lib/core/outbox/service', () => ({
enqueueOutboxEvent: enqueueMock,
}))
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({
OUTBOX_EVENT_TYPES: {
STRIPE_SYNC_SUBSCRIPTION_SEATS: 'stripe.sync-subscription-seats',
},
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isBillingEnabled() {
return mockFeatureFlags.isBillingEnabled
},
}))
vi.mock('@sim/audit', () => auditMock)
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
const teamSub = {
id: 'sub-1',
plan: 'team_6000',
status: 'active',
seats: 1,
stripeSubscriptionId: 'stripe_sub',
}
describe('reconcileOrganizationSeats', () => {
beforeEach(() => {
vi.clearAllMocks()
queryQueue.value = []
enqueueMock.mockResolvedValue('evt-1')
mockFeatureFlags.isBillingEnabled = true
})
it('grows seats to the member count and enqueues a Stripe sync', async () => {
queryQueue.value = [[teamSub], [{ value: 2 }]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-accepted-invite',
})
expect(result).toEqual({
changed: true,
previousSeats: 1,
seats: 2,
reason: undefined,
outboxEventId: 'evt-1',
})
expect(setMock).toHaveBeenCalledWith({ seats: 2 })
expect(enqueueMock).toHaveBeenCalledWith(expect.anything(), 'stripe.sync-subscription-seats', {
subscriptionId: 'sub-1',
reason: 'member-accepted-invite',
})
expect(mockSyncSubscriptionUsageLimits).toHaveBeenCalledWith(
expect.objectContaining({ id: 'sub-1', referenceId: 'org-1', seats: 2 })
)
})
it('still records the seat audit when the post-commit usage-limit sync fails', async () => {
queryQueue.value = [[teamSub], [{ value: 2 }]]
mockSyncSubscriptionUsageLimits.mockRejectedValueOnce(new Error('sync unavailable'))
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-accepted-invite',
actorId: 'user-1',
})
expect(result.changed).toBe(true)
expect(setMock).toHaveBeenCalledWith({ seats: 2 })
expect(auditMock.recordAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'user-1',
action: auditMock.AuditAction.ORG_SEAT_PROVISIONED,
resourceId: 'org-1',
})
)
})
it('reduces seats to the member count on removal', async () => {
queryQueue.value = [[{ ...teamSub, seats: 3 }], [{ value: 2 }]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-removed',
})
expect(result.changed).toBe(true)
expect(result.seats).toBe(2)
expect(setMock).toHaveBeenCalledWith({ seats: 2 })
expect(enqueueMock).toHaveBeenCalled()
})
it('is a no-op when seats already match the member count', async () => {
queryQueue.value = [[{ ...teamSub, seats: 2 }], [{ value: 2 }]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-removed',
})
expect(result).toEqual({
changed: false,
previousSeats: 2,
seats: 2,
reason: undefined,
outboxEventId: undefined,
})
expect(setMock).not.toHaveBeenCalled()
expect(enqueueMock).not.toHaveBeenCalled()
expect(mockSyncSubscriptionUsageLimits).not.toHaveBeenCalled()
})
it('never drops below one seat', async () => {
queryQueue.value = [[{ ...teamSub, seats: 3 }], [{ value: 0 }]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-removed',
})
expect(result.seats).toBe(1)
expect(setMock).toHaveBeenCalledWith({ seats: 1 })
})
it('skips non-Team subscriptions', async () => {
queryQueue.value = [[{ ...teamSub, plan: 'pro_6000' }]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-accepted-invite',
})
expect(result.changed).toBe(false)
expect(result.reason).toMatch(/Team/)
expect(setMock).not.toHaveBeenCalled()
expect(enqueueMock).not.toHaveBeenCalled()
})
it('skips when the organization has no usable subscription', async () => {
queryQueue.value = [[]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-accepted-invite',
})
expect(result).toEqual({ changed: false, reason: 'No active subscription found' })
expect(enqueueMock).not.toHaveBeenCalled()
})
it('no-ops when billing is disabled', async () => {
mockFeatureFlags.isBillingEnabled = false
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-accepted-invite',
})
expect(result).toEqual({ changed: false, reason: 'Billing is not enabled' })
expect(enqueueMock).not.toHaveBeenCalled()
})
})
+196
View File
@@ -0,0 +1,196 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, inArray } from 'drizzle-orm'
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
import { isTeam } from '@/lib/billing/plan-helpers'
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('OrganizationSeats')
export interface ReconcileOrganizationSeatsResult {
changed: boolean
previousSeats?: number
seats?: number
reason?: string
outboxEventId?: string
}
interface ReconcileOrganizationSeatsParams {
organizationId: string
reason: string
/**
* Real `user.id` of the actor whose action triggered this reconcile, used to
* attribute the seat-change audit log and analytics event. Omit for system
* reconciles (e.g. the seat-drift sweep) that have no acting user; the audit
* is then skipped and analytics fall back to the organization id.
*/
actorId?: string
}
/**
* Reconcile a Team organization's seat count to its current member count and
* enqueue an outbox event to push the change to Stripe. This is the single
* seat-accounting path for both joins and removals: paid seats always equal
* the number of organization members.
*
* The DB write and the outbox enqueue commit atomically; the actual Stripe
* charge/credit (proration via `always_invoice`) happens asynchronously in the
* seat-sync handler. A failed charge surfaces through Stripe dunning and blocks
* the org via the existing billing-blocked system rather than under this lock.
* Concurrent joins/removals serialize on the subscription row's `FOR UPDATE`
* lock and each reconciles to the live member count, so the final seat count is
* always correct regardless of interleaving.
*/
export async function reconcileOrganizationSeats({
organizationId,
reason,
actorId,
}: ReconcileOrganizationSeatsParams): Promise<ReconcileOrganizationSeatsResult> {
if (!isBillingEnabled) {
return { changed: false, reason: 'Billing is not enabled' }
}
type ReconcileOutcome =
| { kind: 'skip'; reason: string }
| { kind: 'noop'; seats: number }
| {
kind: 'changed'
previousSeats: number
seats: number
outboxEventId: string
sync: { id: string; plan: string; status: string | null }
}
const outcome = await db.transaction<ReconcileOutcome>(async (tx) => {
const [orgSubscription] = await tx
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.for('update')
.limit(1)
if (!orgSubscription) {
return { kind: 'skip', reason: 'No active subscription found' }
}
if (!isTeam(orgSubscription.plan)) {
return { kind: 'skip', reason: 'Seat changes are only available for Team plans' }
}
if (!orgSubscription.stripeSubscriptionId) {
return { kind: 'skip', reason: 'No Stripe subscription found for this organization' }
}
const [memberCountRow] = await tx
.select({ value: count() })
.from(member)
.where(eq(member.organizationId, organizationId))
const targetSeats = Math.max(1, memberCountRow?.value ?? 1)
const currentSeats = orgSubscription.seats ?? 1
if (targetSeats === currentSeats) {
return { kind: 'noop', seats: currentSeats }
}
await tx
.update(subscription)
.set({ seats: targetSeats })
.where(eq(subscription.id, orgSubscription.id))
const outboxEventId = await enqueueOutboxEvent(
tx,
OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS,
{
subscriptionId: orgSubscription.id,
reason,
}
)
return {
kind: 'changed',
previousSeats: currentSeats,
seats: targetSeats,
outboxEventId,
sync: {
id: orgSubscription.id,
plan: orgSubscription.plan,
status: orgSubscription.status,
},
}
})
if (outcome.kind === 'skip') {
return { changed: false, reason: outcome.reason }
}
if (outcome.kind === 'noop') {
return { changed: false, previousSeats: outcome.seats, seats: outcome.seats }
}
try {
await syncSubscriptionUsageLimits({
id: outcome.sync.id,
plan: outcome.sync.plan,
referenceId: organizationId,
status: outcome.sync.status,
seats: outcome.seats,
})
} catch (error) {
// Seats already committed in the transaction above; a failure here must not
// suppress the audit/analytics events below for a change that already happened.
logger.error('Failed to sync usage limits after seat reconciliation', {
organizationId,
previousSeats: outcome.previousSeats,
seats: outcome.seats,
error,
})
}
logger.info('Reconciled organization seats to member count', {
organizationId,
previousSeats: outcome.previousSeats,
seats: outcome.seats,
reason,
outboxEventId: outcome.outboxEventId,
})
const increased = outcome.seats > outcome.previousSeats
if (actorId) {
recordAudit({
workspaceId: null,
actorId,
action: increased ? AuditAction.ORG_SEAT_PROVISIONED : AuditAction.ORG_SEAT_DEPROVISIONED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: `${increased ? 'Provisioned' : 'Deprovisioned'} organization seats: ${outcome.previousSeats}${outcome.seats}`,
metadata: { previousSeats: outcome.previousSeats, seats: outcome.seats, reason },
})
}
captureServerEvent(
actorId ?? organizationId,
increased ? 'seats_provisioned' : 'seats_deprovisioned',
{
organization_id: organizationId,
previous_seats: outcome.previousSeats,
seats: outcome.seats,
reason,
},
{ groups: { organization: organizationId } }
)
return {
changed: true,
previousSeats: outcome.previousSeats,
seats: outcome.seats,
outboxEventId: outcome.outboxEventId,
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Plan type helpers for the credit-tier billing system.
*
* Plan names follow the convention `{type}_{credits}`:
* - `pro_6000` (Pro), `pro_25000` (Max)
* - `team_6000` (Team Pro), `team_25000` (Team Max)
* - `free`, `enterprise` (unchanged)
*
* Legacy plan names (`pro`, `team`) are also recognized for backward compat
* and map to their original dollar amounts ($20 / $40).
*/
import type { AnyColumn } from 'drizzle-orm'
import { eq, like, or, type SQL } from 'drizzle-orm'
import {
CREDIT_TIERS,
DEFAULT_PRO_TIER_COST_LIMIT,
DEFAULT_TEAM_TIER_COST_LIMIT,
} from '@/lib/billing/constants'
export type PlanCategory = 'free' | 'pro' | 'team' | 'enterprise'
export function isPro(plan: string | null | undefined): boolean {
if (!plan) return false
return plan === 'pro' || plan.startsWith('pro_')
}
export function isMax(plan: string | null | undefined): boolean {
return isPro(plan) && getPlanTierCredits(plan) >= 25000
}
export function isTeam(plan: string | null | undefined): boolean {
if (!plan) return false
return plan === 'team' || plan.startsWith('team_')
}
export function isFree(plan: string | null | undefined): boolean {
return !plan || plan === 'free'
}
export function isEnterprise(plan: string | null | undefined): boolean {
return plan === 'enterprise'
}
export function isPaid(plan: string | null | undefined): boolean {
return isPro(plan) || isTeam(plan) || isEnterprise(plan)
}
/**
* True when the plan **name** is a team/enterprise plan. This is a
* plan-name check, NOT a scope check — a `pro_*` plan attached to an
* organization is org-scoped at the billing level even though this
* returns `false` for it. For scope decisions use
* `isOrgScopedSubscription` (sync) or `isSubscriptionOrgScoped` (async).
*/
export function isOrgPlan(plan: string | null | undefined): boolean {
return isTeam(plan) || isEnterprise(plan)
}
/**
* Extract the credit count from a plan name (e.g. `'pro_6000'` => `6000`).
* Legacy names map to their original dollar values:
* `'pro'` => 4000 credits ($20 at 1:200), `'team'` => 8000 credits ($40 at 1:200).
*/
export function getPlanTierCredits(plan: string | null | undefined): number {
if (!plan) return 0
const match = plan.match(/_(\d+)$/)
if (match) return Number.parseInt(match[1], 10)
if (plan === 'pro') return 4000
if (plan === 'team') return 8000
return 0
}
/**
* Get the dollar value of a plan's credit tier.
* Looks up from CREDIT_TIERS for exact mapping, with legacy plan fallbacks.
*/
export function getPlanTierDollars(plan: string | null | undefined): number {
if (!plan) return 0
const credits = getPlanTierCredits(plan)
const tier = CREDIT_TIERS.find((t) => t.credits === credits)
if (tier) return tier.dollars
if (plan === 'pro') return DEFAULT_PRO_TIER_COST_LIMIT
if (plan === 'team') return DEFAULT_TEAM_TIER_COST_LIMIT
return 0
}
/**
* Return the broad plan category regardless of tier suffix.
*/
export function getPlanType(plan: string | null | undefined): PlanCategory {
if (isPro(plan)) return 'pro'
if (isTeam(plan)) return 'team'
if (isEnterprise(plan)) return 'enterprise'
return 'free'
}
/**
* Return the plan category used for rate limits, storage, and execution timeouts.
* Max plans (>= 25K credits) are promoted to team-level limits.
*/
export function getPlanTypeForLimits(plan: string | null | undefined): PlanCategory {
const credits = getPlanTierCredits(plan)
if (credits >= 25000 && isPro(plan)) return 'team'
return getPlanType(plan)
}
/**
* Build the canonical plan name for a given type and credit tier.
* @example buildPlanName('pro', 6000) => 'pro_6000'
*/
export function buildPlanName(type: 'pro' | 'team', credits: number): string {
return `${type}_${credits}`
}
/**
* Get the list of valid plan names for a given category.
*/
export function getValidPlanNames(type: 'pro' | 'team'): string[] {
return CREDIT_TIERS.map((t) => buildPlanName(type, t.credits))
}
/**
* Get the user-facing display name for a plan.
* @example getDisplayPlanName('pro_25000') => 'Max'
* @example getDisplayPlanName('team_6000') => 'Pro for Teams'
* @example getDisplayPlanName('pro') => 'Legacy Pro'
*/
/**
* SQL-level plan filters for Drizzle queries.
* These are the SQL equivalents of the JS helpers above.
*/
export function sqlIsPro(column: AnyColumn): SQL | undefined {
return or(eq(column, 'pro'), like(column, 'pro_%'))
}
export function sqlIsTeam(column: AnyColumn): SQL | undefined {
return or(eq(column, 'team'), like(column, 'team_%'))
}
export function sqlIsPaid(column: AnyColumn): SQL | undefined {
return or(sqlIsPro(column)!, sqlIsTeam(column)!, eq(column, 'enterprise'))
}
export function getDisplayPlanName(plan: string | null | undefined): string {
if (!plan || isFree(plan)) return 'Free'
if (isEnterprise(plan)) return 'Enterprise'
const credits = getPlanTierCredits(plan)
const tier = CREDIT_TIERS.find((t) => t.credits === credits)
const isLegacy = plan === 'pro' || plan === 'team'
const tierName = tier?.name ?? (plan === 'team' ? 'Max' : 'Pro')
const prefix = isLegacy ? 'Legacy ' : ''
const suffix = isTeam(plan) ? ' for Teams' : ''
return `${prefix}${tierName}${suffix}`
}
+143
View File
@@ -0,0 +1,143 @@
import type Stripe from 'stripe'
import { CREDIT_TIERS } from '@/lib/billing/constants'
import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion'
import { isTeam } from '@/lib/billing/plan-helpers'
import { getFreeTierLimit } from '@/lib/billing/subscriptions/utils'
import { env } from '@/lib/core/config/env'
export interface BillingPlan {
name: string
priceId: string
annualDiscountPriceId?: string
limits: {
cost: number
}
}
/**
* Build the billing plans for the Better Auth Stripe plugin.
*
* Plans:
* - free
* - pro_6000 (Pro, $25/mo) + team_6000
* - pro_25000 (Max, $100/mo) + team_25000
* - enterprise (dynamic pricing)
*
* Legacy subscriptions with plan='pro' or plan='team' are handled by
* plan-helpers.ts which maps them to their original dollar amounts.
*/
export function getPlans(): BillingPlan[] {
const plans: BillingPlan[] = [
{
name: 'free',
priceId: env.STRIPE_FREE_PRICE_ID || '',
limits: { cost: getFreeTierLimit() },
},
]
const proPriceMap: Record<number, { monthly: string; annual: string }> = {
25: {
monthly: env.STRIPE_PRICE_TIER_25_MO || '',
annual: env.STRIPE_PRICE_TIER_25_YR || '',
},
100: {
monthly: env.STRIPE_PRICE_TIER_100_MO || '',
annual: env.STRIPE_PRICE_TIER_100_YR || '',
},
}
const teamPriceMap: Record<number, { monthly: string; annual: string }> = {
25: {
monthly: env.STRIPE_PRICE_TEAM_25_MO || '',
annual: env.STRIPE_PRICE_TEAM_25_YR || '',
},
100: {
monthly: env.STRIPE_PRICE_TEAM_100_MO || '',
annual: env.STRIPE_PRICE_TEAM_100_YR || '',
},
}
for (const tier of CREDIT_TIERS) {
const proPrices = proPriceMap[tier.dollars]
const teamPrices = teamPriceMap[tier.dollars]
const creditValueDollars = tier.credits / CREDIT_MULTIPLIER
if (proPrices?.monthly) {
plans.push({
name: `pro_${tier.credits}`,
priceId: proPrices.monthly,
annualDiscountPriceId: proPrices.annual || undefined,
limits: { cost: creditValueDollars },
})
}
if (teamPrices?.monthly) {
plans.push({
name: `team_${tier.credits}`,
priceId: teamPrices.monthly,
annualDiscountPriceId: teamPrices.annual || undefined,
limits: { cost: creditValueDollars },
})
}
}
plans.push({
name: 'enterprise',
priceId: 'price_dynamic',
limits: { cost: 200 },
})
return plans
}
/**
* Get a specific plan by name
*/
export function getPlanByName(planName: string): BillingPlan | undefined {
return getPlans().find((plan) => plan.name === planName)
}
/**
* Get a specific plan by Stripe price ID.
* Matches against both monthly (`priceId`) and annual (`annualDiscountPriceId`) prices.
*/
export function getPlanByPriceId(priceId: string): BillingPlan | undefined {
if (!priceId) return undefined
return getPlans().find(
(plan) => plan.priceId === priceId || plan.annualDiscountPriceId === priceId
)
}
/**
* Get plan limits for a given plan name
*/
export function getPlanLimits(planName: string): number {
const plan = getPlanByName(planName)
return plan?.limits.cost ?? getFreeTierLimit()
}
export interface StripePlanResolution {
priceId: string | undefined
planFromStripe: string | null
isTeamPlan: boolean
isAnnual: boolean
}
/**
* Resolve plan information from a Stripe subscription object.
*/
export function resolvePlanFromStripeSubscription(
stripeSubscription: Stripe.Subscription
): StripePlanResolution {
const priceId = stripeSubscription?.items?.data?.[0]?.price?.id
const interval = stripeSubscription?.items?.data?.[0]?.price?.recurring?.interval
const plan = priceId ? getPlanByPriceId(priceId) : undefined
return {
priceId,
planFromStripe: plan?.name ?? null,
isTeamPlan: plan ? isTeam(plan.name) : false,
isAnnual: interval === 'year',
}
}
+247
View File
@@ -0,0 +1,247 @@
/**
* @vitest-environment node
*/
import type { DataRetentionSettings, PiiRedactionRule } from '@sim/db/schema'
import { describe, expect, it } from 'vitest'
import {
DEFAULT_PII_REDACTION,
resolveEffectivePiiRedaction,
resolveEffectiveRetentionHours,
} from '@/lib/billing/retention'
function settings(rules: PiiRedactionRule[]): DataRetentionSettings {
return { piiRedaction: { rules } }
}
const DISABLED = { enabled: false, entityTypes: [], language: 'en' }
describe('resolveEffectivePiiRedaction', () => {
const allRule: PiiRedactionRule = {
id: 'r-all',
entityTypes: ['EMAIL_ADDRESS', 'PHONE_NUMBER'],
workspaceId: null,
}
describe('legacy flat rules (back-compat)', () => {
it('resolves an all-workspaces flat rule to logs-only', () => {
const result = resolveEffectivePiiRedaction({
orgSettings: settings([allRule]),
workspaceId: 'ws-1',
})
expect(result).toEqual({
input: DISABLED,
blockOutputs: DISABLED,
logs: { enabled: true, entityTypes: ['EMAIL_ADDRESS', 'PHONE_NUMBER'], language: 'en' },
})
})
it('lets a workspace-specific flat rule override the all rule (logs-only)', () => {
const result = resolveEffectivePiiRedaction({
orgSettings: settings([
allRule,
{ id: 'r-1', entityTypes: ['US_SSN'], workspaceId: 'ws-1' },
]),
workspaceId: 'ws-1',
})
expect(result).toEqual({
input: DISABLED,
blockOutputs: DISABLED,
logs: { enabled: true, entityTypes: ['US_SSN'], language: 'en' },
})
})
it('carries the flat rule language through (defaults to en)', () => {
const result = resolveEffectivePiiRedaction({
orgSettings: settings([
{ id: 'r-es', entityTypes: ['ES_NIF'], workspaceId: 'ws-1', language: 'es' },
]),
workspaceId: 'ws-1',
})
expect(result.logs).toEqual({ enabled: true, entityTypes: ['ES_NIF'], language: 'es' })
})
it('falls back to en when a stored language is unsupported/stale', () => {
const result = resolveEffectivePiiRedaction({
orgSettings: settings([
{ id: 'r-de', entityTypes: ['EMAIL_ADDRESS'], workspaceId: 'ws-1', language: 'de' },
]),
workspaceId: 'ws-1',
})
expect(result.logs).toEqual({ enabled: true, entityTypes: ['EMAIL_ADDRESS'], language: 'en' })
})
it('exempts a workspace when its specific flat rule has no entity types', () => {
const result = resolveEffectivePiiRedaction({
orgSettings: settings([allRule, { id: 'r-1', entityTypes: [], workspaceId: 'ws-1' }]),
workspaceId: 'ws-1',
})
expect(result).toEqual(DEFAULT_PII_REDACTION)
})
})
describe('per-stage rules', () => {
const stage = (enabled: boolean, entityTypes: string[], language?: string) => ({
enabled,
entityTypes,
...(language ? { language } : {}),
})
it('resolves each stage independently', () => {
const result = resolveEffectivePiiRedaction({
orgSettings: settings([
{
id: 'r-1',
workspaceId: 'ws-1',
stages: {
input: stage(true, ['PERSON'], 'es'),
blockOutputs: stage(true, ['EMAIL_ADDRESS']),
logs: stage(true, ['US_SSN', 'PHONE_NUMBER']),
},
},
]),
workspaceId: 'ws-1',
})
expect(result).toEqual({
input: { enabled: true, entityTypes: ['PERSON'], language: 'es' },
blockOutputs: { enabled: true, entityTypes: ['EMAIL_ADDRESS'], language: 'en' },
logs: { enabled: true, entityTypes: ['US_SSN', 'PHONE_NUMBER'], language: 'en' },
})
})
it('disables a stage that is enabled but has no entity types (empty = off)', () => {
const result = resolveEffectivePiiRedaction({
orgSettings: settings([
{
id: 'r-1',
workspaceId: 'ws-1',
stages: {
input: stage(true, []),
blockOutputs: stage(false, ['PERSON']),
logs: stage(true, ['PERSON']),
},
},
]),
workspaceId: 'ws-1',
})
expect(result.input).toEqual(DISABLED)
expect(result.blockOutputs).toEqual(DISABLED)
expect(result.logs).toEqual({ enabled: true, entityTypes: ['PERSON'], language: 'en' })
})
it('selects the whole workspace rule over the all rule (no per-stage merge)', () => {
const result = resolveEffectivePiiRedaction({
orgSettings: settings([
allRule,
{
id: 'r-ws',
workspaceId: 'ws-1',
stages: {
input: stage(true, ['PERSON']),
blockOutputs: stage(false, []),
logs: stage(false, []),
},
},
]),
workspaceId: 'ws-1',
})
expect(result.input).toEqual({ enabled: true, entityTypes: ['PERSON'], language: 'en' })
// The all rule's logs entity types are NOT unioned in.
expect(result.logs).toEqual(DISABLED)
})
})
it('is the default when no rule matches and there is no all rule', () => {
expect(
resolveEffectivePiiRedaction({
orgSettings: settings([{ id: 'r-1', entityTypes: ['US_SSN'], workspaceId: 'ws-2' }]),
workspaceId: 'ws-1',
})
).toEqual(DEFAULT_PII_REDACTION)
})
it('is the default when there are no rules', () => {
expect(
resolveEffectivePiiRedaction({ orgSettings: settings([]), workspaceId: 'ws-1' })
).toEqual(DEFAULT_PII_REDACTION)
expect(resolveEffectivePiiRedaction({ orgSettings: null, workspaceId: 'ws-1' })).toEqual(
DEFAULT_PII_REDACTION
)
})
})
describe('resolveEffectiveRetentionHours', () => {
const orgSettings: DataRetentionSettings = {
logRetentionHours: 720,
softDeleteRetentionHours: 2160,
taskCleanupHours: null,
}
it('returns the org value when the workspace has no override', () => {
expect(
resolveEffectiveRetentionHours({ orgSettings, workspaceId: 'ws-1', key: 'logRetentionHours' })
).toBe(720)
})
it('returns the org value when an override exists but omits the field (inherit)', () => {
expect(
resolveEffectiveRetentionHours({
orgSettings: { ...orgSettings, retentionOverrides: [{ workspaceId: 'ws-1' }] },
workspaceId: 'ws-1',
key: 'logRetentionHours',
})
).toBe(720)
})
it('uses the override hours when the field is set to a number', () => {
expect(
resolveEffectiveRetentionHours({
orgSettings: {
...orgSettings,
retentionOverrides: [{ workspaceId: 'ws-1', logRetentionHours: 168 }],
},
workspaceId: 'ws-1',
key: 'logRetentionHours',
})
).toBe(168)
})
it('uses null (forever) when the override field is explicitly null', () => {
expect(
resolveEffectiveRetentionHours({
orgSettings: {
...orgSettings,
retentionOverrides: [{ workspaceId: 'ws-1', logRetentionHours: null }],
},
workspaceId: 'ws-1',
key: 'logRetentionHours',
})
).toBeNull()
})
it('only applies the override to its own workspace', () => {
const settingsWithOverride: DataRetentionSettings = {
...orgSettings,
retentionOverrides: [{ workspaceId: 'ws-1', logRetentionHours: 168 }],
}
expect(
resolveEffectiveRetentionHours({
orgSettings: settingsWithOverride,
workspaceId: 'ws-2',
key: 'logRetentionHours',
})
).toBe(720)
})
it('returns null when neither an override nor an org value is configured', () => {
expect(
resolveEffectiveRetentionHours({ orgSettings, workspaceId: 'ws-1', key: 'taskCleanupHours' })
).toBeNull()
expect(
resolveEffectiveRetentionHours({
orgSettings: null,
workspaceId: 'ws-1',
key: 'logRetentionHours',
})
).toBeNull()
})
})
+123
View File
@@ -0,0 +1,123 @@
import type { DataRetentionSettings, PiiStagePolicy } from '@sim/db/schema'
import { coercePiiLanguage, DEFAULT_PII_LANGUAGE } from '@/lib/guardrails/pii-entities'
/** Resolved policy for one redaction stage. */
export interface EffectivePiiStage {
enabled: boolean
/** Presidio entity types to mask. Empty = redact all detected PII. */
entityTypes: string[]
/** Language whose Presidio recognizers apply when masking. */
language: string
}
/**
* Effective PII redaction, resolved per stage. `input`/`blockOutputs` are
* execution-altering (mask the data the workflow computes on); `logs` is the
* observability-only persist-time stage.
*/
export interface EffectivePiiRedaction {
input: EffectivePiiStage
blockOutputs: EffectivePiiStage
logs: EffectivePiiStage
}
const DISABLED_STAGE: EffectivePiiStage = {
enabled: false,
entityTypes: [],
language: DEFAULT_PII_LANGUAGE,
}
export const DEFAULT_PII_REDACTION: EffectivePiiRedaction = {
input: DISABLED_STAGE,
blockOutputs: DISABLED_STAGE,
logs: DISABLED_STAGE,
}
function sanitizeEntityTypes(value: unknown): string[] {
return Array.isArray(value) ? value.filter((t): t is string => typeof t === 'string') : []
}
/**
* Expand a stored stage policy into its effective form. A stage redacts nothing
* unless it is enabled AND names at least one entity type. "Redact all" is not an
* expressible policy (the checkbox UI has no such control, and the contract
* rejects enabled-with-no-types), so an empty entity list always means "off" —
* consistent across the UI, the contract, and the masking layer.
*/
function toEffectiveStage(policy: PiiStagePolicy | undefined): EffectivePiiStage {
const types = sanitizeEntityTypes(policy?.entityTypes)
if (!policy?.enabled || types.length === 0) return DISABLED_STAGE
return {
enabled: true,
entityTypes: types,
language: coercePiiLanguage(policy.language) ?? DEFAULT_PII_LANGUAGE,
}
}
/**
* Resolve the effective per-stage PII redaction policy for a workspace from the
* org-level rules list, most-specific-wins (never unioned): the workspace's own
* rule takes precedence over the all-workspaces rule (`workspaceId: null`). Rule
* selection is whole-rule; the selected rule is then expanded into three stages.
*
* Back-compat: a legacy rule with no `stages` is treated exactly as it was before
* — logs-only, masking its flat `entityTypes` (input/blockOutputs disabled). A
* resolved stage with no entity types redacts nothing (an empty list is the
* workspace-exemption / off shape). Defensive about the loosely-typed JSON column.
*/
export function resolveEffectivePiiRedaction(params: {
orgSettings: DataRetentionSettings | null | undefined
workspaceId: string
}): EffectivePiiRedaction {
const rules = params.orgSettings?.piiRedaction?.rules
if (!Array.isArray(rules) || rules.length === 0) return DEFAULT_PII_REDACTION
const rule =
rules.find((r) => r?.workspaceId === params.workspaceId) ??
rules.find((r) => r?.workspaceId == null)
if (!rule) return DEFAULT_PII_REDACTION
if (!rule.stages) {
const types = sanitizeEntityTypes(rule.entityTypes)
if (types.length === 0) return DEFAULT_PII_REDACTION
return {
input: DISABLED_STAGE,
blockOutputs: DISABLED_STAGE,
logs: {
enabled: true,
entityTypes: types,
language: coercePiiLanguage(rule.language) ?? DEFAULT_PII_LANGUAGE,
},
}
}
return {
input: toEffectiveStage(rule.stages.input),
blockOutputs: toEffectiveStage(rule.stages.blockOutputs),
logs: toEffectiveStage(rule.stages.logs),
}
}
export type RetentionHoursKey =
| 'logRetentionHours'
| 'softDeleteRetentionHours'
| 'taskCleanupHours'
/**
* Resolve the effective retention hours for one workspace and job type. A
* workspace override wins when it sets the field (a number, or `null` for
* forever); an omitted field inherits the org-level value. Returns `null` when
* nothing is configured (the dispatcher treats `null` as "skip").
*/
export function resolveEffectiveRetentionHours(params: {
orgSettings: DataRetentionSettings | null | undefined
workspaceId: string
key: RetentionHoursKey
}): number | null {
const override = params.orgSettings?.retentionOverrides?.find(
(o) => o?.workspaceId === params.workspaceId
)
const overrideValue = override?.[params.key]
if (overrideValue !== undefined) return overrideValue
return params.orgSettings?.[params.key] ?? null
}
+8
View File
@@ -0,0 +1,8 @@
export { checkStorageQuota, getUserStorageLimit, getUserStorageUsage } from './limits'
export {
checkAndIncrementStorageUsageInTx,
decrementStorageUsage,
decrementStorageUsageInTx,
incrementStorageUsage,
maybeNotifyStorageLimit,
} from './tracking'
+206
View File
@@ -0,0 +1,206 @@
/**
* Storage limit management
* Similar to cost limits but for file storage quotas
*/
import { db } from '@sim/db'
import {
DEFAULT_ENTERPRISE_STORAGE_LIMIT_GB,
DEFAULT_FREE_STORAGE_LIMIT_GB,
DEFAULT_PRO_STORAGE_LIMIT_GB,
DEFAULT_TEAM_STORAGE_LIMIT_GB,
} from '@sim/db/constants'
import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getPlanTypeForLimits, isEnterprise, isFree } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { getEnv } from '@/lib/core/config/env'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
const logger = createLogger('StorageLimits')
/** Resolve the highest-priority subscription via a deferred import (avoids a static cycle). */
async function resolveSub(userId: string): Promise<HighestPrioritySubscription | null> {
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
return getHighestPrioritySubscription(userId)
}
/**
* Convert GB to bytes
*/
function gbToBytes(gb: number): number {
return gb * 1024 * 1024 * 1024
}
/**
* Get storage limits from environment variables with fallback to constants
* Returns limits in bytes
*/
export function getStorageLimits() {
return {
free: gbToBytes(
Number.parseInt(getEnv('FREE_STORAGE_LIMIT_GB') || String(DEFAULT_FREE_STORAGE_LIMIT_GB))
),
pro: gbToBytes(
Number.parseInt(getEnv('PRO_STORAGE_LIMIT_GB') || String(DEFAULT_PRO_STORAGE_LIMIT_GB))
),
team: gbToBytes(
Number.parseInt(getEnv('TEAM_STORAGE_LIMIT_GB') || String(DEFAULT_TEAM_STORAGE_LIMIT_GB))
),
enterpriseDefault: gbToBytes(
Number.parseInt(
getEnv('ENTERPRISE_STORAGE_LIMIT_GB') || String(DEFAULT_ENTERPRISE_STORAGE_LIMIT_GB)
)
),
}
}
/**
* Get storage limit for a specific plan
* Returns limit in bytes
*/
export function getStorageLimitForPlan(plan: string, metadata?: any): number {
const limits = getStorageLimits()
if (isEnterprise(plan)) {
if (metadata?.storageLimitGB) {
return gbToBytes(Number.parseInt(metadata.storageLimitGB))
}
return limits.enterpriseDefault
}
const effectivePlan = getPlanTypeForLimits(plan)
const limitByPlan: Record<'free' | 'pro' | 'team', number> = {
free: limits.free,
pro: limits.pro,
team: limits.team,
}
return limitByPlan[effectivePlan as 'free' | 'pro' | 'team'] ?? limits.free
}
/**
* Get storage limit for a user based on their subscription. Returns limit in
* bytes.
*
* @param prefetchedSub - Pass an already-resolved subscription (may be `null`)
* to skip the `getHighestPrioritySubscription` lookup on hot paths. Omit
* (leave `undefined`) to fetch it here.
*/
export async function getUserStorageLimit(
userId: string,
prefetchedSub?: HighestPrioritySubscription | null
): Promise<number> {
try {
const sub = prefetchedSub === undefined ? await resolveSub(userId) : prefetchedSub
const limits = getStorageLimits()
if (!sub || isFree(sub.plan)) {
return limits.free
}
if (isOrgScopedSubscription(sub, userId)) {
const metadata = sub.metadata as { customStorageLimitGB?: number } | null
if (metadata?.customStorageLimitGB) {
return metadata.customStorageLimitGB * 1024 * 1024 * 1024
}
return isEnterprise(sub.plan) ? limits.enterpriseDefault : limits.team
}
// Personally-scoped plans use the per-plan default storage cap.
const effectivePlan = getPlanTypeForLimits(sub.plan)
const limitByPlan: Record<'free' | 'pro' | 'team', number> = {
free: limits.free,
pro: limits.pro,
team: limits.team,
}
return limitByPlan[effectivePlan as 'free' | 'pro' | 'team'] ?? limits.free
} catch (error) {
logger.error('Error getting user storage limit:', error)
return getStorageLimits().free
}
}
/**
* Get current storage usage for a user. Returns usage in bytes.
*
* @param prefetchedSub - Pass an already-resolved subscription (may be `null`)
* to skip the `getHighestPrioritySubscription` lookup on hot paths.
*/
export async function getUserStorageUsage(
userId: string,
prefetchedSub?: HighestPrioritySubscription | null
): Promise<number> {
try {
const sub = prefetchedSub === undefined ? await resolveSub(userId) : prefetchedSub
// Org-scoped subs share pooled `organization.storageUsedBytes`;
// personal plans use `userStats`.
if (isOrgScopedSubscription(sub, userId) && sub) {
const orgRecord = await db
.select({ storageUsedBytes: organization.storageUsedBytes })
.from(organization)
.where(eq(organization.id, sub.referenceId))
.limit(1)
return orgRecord.length > 0 ? orgRecord[0].storageUsedBytes || 0 : 0
}
const stats = await db
.select({ storageUsedBytes: userStats.storageUsedBytes })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
return stats.length > 0 ? stats[0].storageUsedBytes || 0 : 0
} catch (error) {
logger.error('Error getting user storage usage:', error)
return 0
}
}
/**
* Check if user has storage quota available
* Always allows uploads when billing is disabled
*/
export async function checkStorageQuota(
userId: string,
additionalBytes: number
): Promise<{ allowed: boolean; currentUsage: number; limit: number; error?: string }> {
if (!isBillingEnabled) {
return {
allowed: true,
currentUsage: 0,
limit: Number.MAX_SAFE_INTEGER,
}
}
try {
const [currentUsage, limit] = await Promise.all([
getUserStorageUsage(userId),
getUserStorageLimit(userId),
])
const newUsage = currentUsage + additionalBytes
const allowed = newUsage <= limit
return {
allowed,
currentUsage,
limit,
error: allowed
? undefined
: `Storage limit exceeded. Used: ${(newUsage / (1024 * 1024 * 1024)).toFixed(2)}GB, Limit: ${(limit / (1024 * 1024 * 1024)).toFixed(0)}GB`,
}
} catch (error) {
logger.error('Error checking storage quota:', error)
return {
allowed: false,
currentUsage: 0,
limit: 0,
error: 'Failed to check storage quota',
}
}
}
+289
View File
@@ -0,0 +1,289 @@
/**
* Storage usage tracking
* Updates storage_used_bytes for users and organizations
* Only tracks when billing is enabled
*/
import { db } from '@sim/db'
import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, sql } from 'drizzle-orm'
import { maybeNotifyLimit } from '@/lib/billing/core/limit-notifications'
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage/limits'
import { getFreeTierLimit, isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
const logger = createLogger('StorageTracking')
/** Format bytes as a `GB` label for usage-limit emails (2dp usage, whole-number limit). */
function formatGb(bytes: number, decimals: number): string {
return `${(bytes / 1024 ** 3).toFixed(decimals)} GB`
}
/**
* Best-effort storage threshold evaluation after a usage change. Re-reads the
* (now updated) usage and plan limit, then delegates dedup + send to
* {@link maybeNotifyLimit}. Never throws.
*
* The caller passes the subscription it already resolved for the increment/
* decrement, so the whole path (usage read, limit, scope) reuses a single
* `getHighestPrioritySubscription` instead of re-fetching it three times.
*
* @param rearmOnly - True on decrements, so a shrink that leaves usage above a
* threshold re-arms but never sends (a drop is not a fresh crossing).
*/
export async function maybeNotifyStorageLimit(
userId: string,
workspaceId: string,
sub: HighestPrioritySubscription | null,
rearmOnly = false
): Promise<void> {
try {
const [usage, limit] = await Promise.all([
getUserStorageUsage(userId, sub),
getUserStorageLimit(userId, sub),
])
await maybeNotifyLimit({
category: 'storage',
billedUserId: userId,
workspaceId,
currentUsage: usage,
limit,
usageLabel: formatGb(usage, 2),
limitLabel: formatGb(limit, 0),
rearmOnly,
subscription: sub,
})
} catch (error) {
logger.error('Error evaluating storage limit notification:', error)
}
}
/**
* Increment storage usage after successful file upload
* Only tracks if billing is enabled
*
* @param workspaceId - When provided, evaluates the storage usage-limit email
* (80% / 100%) after the increment. Best-effort; never blocks the upload.
*/
export async function incrementStorageUsage(
userId: string,
bytes: number,
workspaceId?: string
): Promise<void> {
if (!isBillingEnabled) {
logger.debug('Billing disabled, skipping storage increment')
return
}
let sub: HighestPrioritySubscription | null = null
try {
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
sub = await getHighestPrioritySubscription(userId)
// Org-scoped subs pool at the org level; personal plans per-user.
if (isOrgScopedSubscription(sub, userId) && sub) {
await db
.update(organization)
.set({
storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytes}`,
})
.where(eq(organization.id, sub.referenceId))
logger.info(`Incremented org storage: ${bytes} bytes for org ${sub.referenceId}`)
} else {
await db
.update(userStats)
.set({
storageUsedBytes: sql`${userStats.storageUsedBytes} + ${bytes}`,
})
.where(eq(userStats.userId, userId))
logger.info(`Incremented user storage: ${bytes} bytes for user ${userId}`)
}
} catch (error) {
logger.error('Error incrementing storage usage:', error)
throw error
}
if (workspaceId) {
void maybeNotifyStorageLimit(userId, workspaceId, sub)
}
}
/**
* Decrement storage usage after file deletion
* Only tracks if billing is enabled
*
* @param workspaceId - When provided, re-evaluates the storage threshold state
* after the decrement. Usage only drops here, so this can only re-arm a
* previously-sent threshold (it never sends), keeping the re-warning correct
* after a shrink. Best-effort; never blocks the caller.
*/
export async function decrementStorageUsage(
userId: string,
bytes: number,
workspaceId?: string
): Promise<void> {
if (!isBillingEnabled) {
logger.debug('Billing disabled, skipping storage decrement')
return
}
let sub: HighestPrioritySubscription | null = null
try {
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
sub = await getHighestPrioritySubscription(userId)
if (isOrgScopedSubscription(sub, userId) && sub) {
await db
.update(organization)
.set({
storageUsedBytes: sql`GREATEST(0, ${organization.storageUsedBytes} - ${bytes})`,
})
.where(eq(organization.id, sub.referenceId))
logger.info(`Decremented org storage: ${bytes} bytes for org ${sub.referenceId}`)
} else {
await db
.update(userStats)
.set({
storageUsedBytes: sql`GREATEST(0, ${userStats.storageUsedBytes} - ${bytes})`,
})
.where(eq(userStats.userId, userId))
logger.info(`Decremented user storage: ${bytes} bytes for user ${userId}`)
}
} catch (error) {
logger.error('Error decrementing storage usage:', error)
throw error
}
if (workspaceId) {
void maybeNotifyStorageLimit(userId, workspaceId, sub, true)
}
}
type StorageTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
/**
* Decrement a user's (or their org's) storage counter inside an existing
* transaction, using a pre-resolved subscription. This lets a caller make the
* counter update atomic with the DB rows it is deleting (e.g. hard-deleting
* documents), so a failure of either rolls back both — no inflated counter, no
* over-decrement. The caller resolves the subscription (a read) before opening
* the transaction.
*/
export async function decrementStorageUsageInTx(
tx: StorageTransaction,
sub: HighestPrioritySubscription | null,
userId: string,
bytes: number
): Promise<void> {
if (!isBillingEnabled || bytes <= 0) return
if (isOrgScopedSubscription(sub, userId) && sub) {
await tx
.update(organization)
.set({ storageUsedBytes: sql`GREATEST(0, ${organization.storageUsedBytes} - ${bytes})` })
.where(eq(organization.id, sub.referenceId))
} else {
await tx
.update(userStats)
.set({ storageUsedBytes: sql`GREATEST(0, ${userStats.storageUsedBytes} - ${bytes})` })
.where(eq(userStats.userId, userId))
}
}
/**
* Atomically check quota and increment a user's (or their org's) storage
* counter inside an existing transaction, using a pre-resolved subscription.
* The check and the increment are a single conditional `UPDATE`, so two
* concurrent callers can no longer both read the same pre-increment usage,
* both pass the check, and both commit past the limit — the second caller's
* `UPDATE` re-evaluates the WHERE clause against the first caller's already
* -committed-within-the-same-DB-round-trip row and correctly fails. Replaces
* the old read-then-decide-then-increment-after-commit split (`checkStorageQuota`
* + a fire-and-forget `incrementStorageUsage` after the transaction), which left
* a window between the read and the increment.
*
* On success, callers should best-effort call {@link maybeNotifyStorageLimit}
* after the transaction commits (mirrors the existing post-increment threshold
* check) — this helper doesn't do it itself since it runs mid-transaction.
*
* For a personal (non-org-scoped) `userId`, this first upserts the
* `userStats` row on `tx` — a documented possibility for OAuth account
* linking (see `ensureUserStatsExists` in `lib/billing/core/usage.ts`, whose
* insert values this mirrors) — because the conditional `UPDATE` below
* matches 0 rows, and therefore reads as "quota exceeded", if that row
* doesn't exist yet. `ensureUserStatsExists` itself isn't reused here since
* it writes through the standalone `db` client, which would open a second
* pooled connection while this transaction's is held.
*/
export async function checkAndIncrementStorageUsageInTx(
tx: StorageTransaction,
sub: HighestPrioritySubscription | null,
userId: string,
bytes: number
): Promise<{ allowed: boolean; currentUsage: number; limit: number; error?: string }> {
if (!isBillingEnabled) {
return { allowed: true, currentUsage: 0, limit: Number.MAX_SAFE_INTEGER }
}
const limit = await getUserStorageLimit(userId, sub)
if (bytes <= 0) {
return { allowed: true, currentUsage: await getUserStorageUsage(userId, sub), limit }
}
const orgScoped = isOrgScopedSubscription(sub, userId) && sub
if (!orgScoped) {
await tx
.insert(userStats)
.values({
id: generateId(),
userId,
currentUsageLimit: getFreeTierLimit().toString(),
usageLimitUpdatedAt: new Date(),
})
.onConflictDoNothing({ target: userStats.userId })
}
const [updated] = orgScoped
? await tx
.update(organization)
.set({ storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytes}` })
.where(
and(
eq(organization.id, sub.referenceId),
sql`${organization.storageUsedBytes} + ${bytes} <= ${limit}`
)
)
.returning({ storageUsedBytes: organization.storageUsedBytes })
: await tx
.update(userStats)
.set({ storageUsedBytes: sql`${userStats.storageUsedBytes} + ${bytes}` })
.where(
and(
eq(userStats.userId, userId),
sql`${userStats.storageUsedBytes} + ${bytes} <= ${limit}`
)
)
.returning({ storageUsedBytes: userStats.storageUsedBytes })
if (updated) {
return { allowed: true, currentUsage: updated.storageUsedBytes - bytes, limit }
}
const currentUsage = await getUserStorageUsage(userId, sub)
const newUsage = currentUsage + bytes
return {
allowed: false,
currentUsage,
limit,
error: `Storage limit exceeded. Used: ${(newUsage / (1024 * 1024 * 1024)).toFixed(2)}GB, Limit: ${(limit / (1024 * 1024 * 1024)).toFixed(0)}GB`,
}
}
+87
View File
@@ -0,0 +1,87 @@
import { createLogger } from '@sim/logger'
import Stripe from 'stripe'
import { env } from '@/lib/core/config/env'
const logger = createLogger('StripeClient')
/**
* Check if Stripe credentials are valid
*/
export function hasValidStripeCredentials(): boolean {
return !!env.STRIPE_SECRET_KEY
}
/**
* Secure Stripe client singleton with initialization guard
*/
const createStripeClientSingleton = () => {
let stripeClient: Stripe | null = null
let isInitializing = false
return {
getInstance(): Stripe | null {
// If already initialized, return immediately
if (stripeClient) return stripeClient
// Prevent concurrent initialization attempts
if (isInitializing) {
logger.debug('Stripe client initialization already in progress')
return null
}
if (!hasValidStripeCredentials()) {
logger.warn('Stripe credentials not available - Stripe operations will be disabled')
return null
}
try {
isInitializing = true
stripeClient = new Stripe(env.STRIPE_SECRET_KEY || '', {
apiVersion: '2025-08-27.basil',
})
logger.info('Stripe client initialized successfully')
return stripeClient
} catch (error) {
logger.error('Failed to initialize Stripe client', { error })
stripeClient = null // Ensure cleanup on failure
return null
} finally {
isInitializing = false
}
},
// For testing purposes only - allows resetting the singleton
reset(): void {
stripeClient = null
isInitializing = false
},
}
}
const stripeClientSingleton = createStripeClientSingleton()
/**
* Get the Stripe client instance
* @returns Stripe client or null if credentials are not available
*/
export function getStripeClient(): Stripe | null {
return stripeClientSingleton.getInstance()
}
/**
* Get the Stripe client instance, throwing an error if not available
* Use this when Stripe operations are required
*/
export function requireStripeClient(): Stripe {
const client = getStripeClient()
if (!client) {
throw new Error(
'Stripe client is not available. Set STRIPE_SECRET_KEY in your environment variables.'
)
}
return client
}
@@ -0,0 +1,75 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type Stripe from 'stripe'
const logger = createLogger('StripePaymentMethod')
/**
* Extract the payment-method id from any of the shapes Stripe returns
* for a `default_payment_method` field (id string, full object, null,
* or undefined).
*/
function getPaymentMethodId(
pm: string | Stripe.PaymentMethod | null | undefined
): string | undefined {
return typeof pm === 'string' ? pm : pm?.id
}
/**
* Extract the customer id from any of the shapes Stripe returns for a
* `customer` field (id string, full `Customer`, or `DeletedCustomer`).
*/
export function getCustomerId(
customer: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined
): string | undefined {
if (!customer) return undefined
return typeof customer === 'string' ? customer : customer.id
}
/**
* Resolve a subscription's default payment method with fallback to the
* customer's invoice-settings PM. Used for ad-hoc invoices that are
* not directly linked to the subscription (overage, credits, threshold
* billing) so Stripe can auto-collect on finalize.
*
* Returns both the resolved PM id and the subscription's collection
* method so callers can pass it through to `invoices.create` without a
* second subscription retrieve. On any Stripe error the returned
* `collectionMethod` is `null` — callers should treat that as
* "unknown" and handle accordingly rather than assuming a default.
*/
export async function resolveDefaultPaymentMethod(
stripe: Stripe,
stripeSubscriptionId: string,
customerId: string
): Promise<{
paymentMethodId: string | undefined
collectionMethod: 'charge_automatically' | 'send_invoice' | null
}> {
let collectionMethod: 'charge_automatically' | 'send_invoice' | null = null
let paymentMethodId: string | undefined
try {
const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId)
collectionMethod =
sub.collection_method === 'send_invoice' ? 'send_invoice' : 'charge_automatically'
paymentMethodId = getPaymentMethodId(sub.default_payment_method)
if (!paymentMethodId && collectionMethod === 'charge_automatically') {
const customer = await stripe.customers.retrieve(customerId)
if (customer && !('deleted' in customer)) {
paymentMethodId = getPaymentMethodId(
(customer as Stripe.Customer).invoice_settings?.default_payment_method
)
}
}
} catch (error) {
logger.warn('Failed to resolve default payment method', {
stripeSubscriptionId,
customerId,
error: getErrorMessage(error),
})
}
return { paymentMethodId, collectionMethod }
}
@@ -0,0 +1,52 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
canEditUsageLimit,
checkEnterprisePlan,
checkProPlan,
checkTeamPlan,
getEffectiveSeats,
hasPaidSubscriptionStatus,
hasUsableSubscriptionAccess,
hasUsableSubscriptionStatus,
} from '@/lib/billing/subscriptions/utils'
describe('billing subscription status helpers', () => {
it('treats past_due as paid entitlement but not usable access', () => {
expect(hasPaidSubscriptionStatus('active')).toBe(true)
expect(hasPaidSubscriptionStatus('past_due')).toBe(true)
expect(hasPaidSubscriptionStatus('canceled')).toBe(false)
expect(hasUsableSubscriptionStatus('active')).toBe(true)
expect(hasUsableSubscriptionStatus('past_due')).toBe(false)
expect(hasUsableSubscriptionStatus('incomplete')).toBe(false)
expect(hasUsableSubscriptionAccess('active', false)).toBe(true)
expect(hasUsableSubscriptionAccess('active', true)).toBe(false)
expect(hasUsableSubscriptionAccess('past_due', false)).toBe(false)
})
it('keeps paid plan checks true for past_due subscriptions', () => {
expect(checkProPlan({ plan: 'pro_4000', status: 'past_due' })).toBe(true)
expect(checkTeamPlan({ plan: 'team_8000', status: 'past_due' })).toBe(true)
expect(checkEnterprisePlan({ plan: 'enterprise', status: 'past_due' })).toBe(true)
})
it('only allows usage limit editing for active usable subscriptions', () => {
expect(canEditUsageLimit({ plan: 'pro_4000', status: 'active' })).toBe(true)
expect(canEditUsageLimit({ plan: 'team_8000', status: 'active' })).toBe(true)
expect(canEditUsageLimit({ plan: 'pro_4000', status: 'past_due' })).toBe(false)
expect(canEditUsageLimit({ plan: 'team_8000', status: 'past_due' })).toBe(false)
expect(canEditUsageLimit({ plan: 'enterprise', status: 'active' })).toBe(false)
expect(canEditUsageLimit({ plan: 'free', status: 'active' })).toBe(false)
})
it('falls back to one seat for entitled team subscriptions before seats sync completes', () => {
expect(getEffectiveSeats({ plan: 'team_8000', status: 'active', seats: null })).toBe(1)
expect(getEffectiveSeats({ plan: 'team_8000', status: 'past_due', seats: undefined })).toBe(1)
expect(getEffectiveSeats({ plan: 'team_8000', status: 'canceled', seats: null })).toBe(0)
})
})
+194
View File
@@ -0,0 +1,194 @@
import {
DEFAULT_ENTERPRISE_TIER_COST_LIMIT,
DEFAULT_FREE_CREDITS,
DEFAULT_PRO_TIER_COST_LIMIT,
DEFAULT_TEAM_TIER_COST_LIMIT,
} from '@/lib/billing/constants'
import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion'
import {
getPlanTierCredits,
isEnterprise,
isFree,
isOrgPlan,
isPro,
isTeam,
} from '@/lib/billing/plan-helpers'
import { parseEnterpriseSubscriptionMetadata } from '@/lib/billing/types'
import { env, envNumber } from '@/lib/core/config/env'
export const ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'past_due'] as const
export const USABLE_SUBSCRIPTION_STATUSES = ['active'] as const
/**
* Returns true when a subscription should still count as a paid plan entitlement.
*/
export function hasPaidSubscriptionStatus(status: string | null | undefined): boolean {
return ENTITLED_SUBSCRIPTION_STATUSES.includes(
status as (typeof ENTITLED_SUBSCRIPTION_STATUSES)[number]
)
}
/**
* Returns true when a subscription status is usable for product access.
*/
export function hasUsableSubscriptionStatus(status: string | null | undefined): boolean {
return USABLE_SUBSCRIPTION_STATUSES.includes(
status as (typeof USABLE_SUBSCRIPTION_STATUSES)[number]
)
}
/**
* Returns true when a subscription is usable for product access.
*/
export function hasUsableSubscriptionAccess(
status: string | null | undefined,
billingBlocked: boolean | null | undefined
): boolean {
return hasUsableSubscriptionStatus(status) && !billingBlocked
}
/**
* Get the free tier limit from env or fallback to default
*/
export function getFreeTierLimit(): number {
return envNumber(env.FREE_TIER_COST_LIMIT, DEFAULT_FREE_CREDITS)
}
/**
* Get the pro tier limit from env or fallback to default
*/
export function getProTierLimit(): number {
return envNumber(env.PRO_TIER_COST_LIMIT, DEFAULT_PRO_TIER_COST_LIMIT)
}
/**
* Get the team tier limit per seat from env or fallback to default
*/
export function getTeamTierLimitPerSeat(): number {
return envNumber(env.TEAM_TIER_COST_LIMIT, DEFAULT_TEAM_TIER_COST_LIMIT)
}
/**
* Get the enterprise tier limit per seat from env or fallback to default
*/
export function getEnterpriseTierLimitPerSeat(): number {
return envNumber(env.ENTERPRISE_TIER_COST_LIMIT, DEFAULT_ENTERPRISE_TIER_COST_LIMIT)
}
export function checkEnterprisePlan(subscription: any): boolean {
return isEnterprise(subscription?.plan) && hasPaidSubscriptionStatus(subscription?.status)
}
export function getEffectiveSeats(subscription: any): number {
if (!subscription) {
return 0
}
if (isEnterprise(subscription.plan)) {
const metadata = parseEnterpriseSubscriptionMetadata(subscription.metadata)
if (metadata) {
return metadata.seats
}
return 0
}
// Mirrors the Stripe subscription's `quantity`. For personal Pro this
// is null in practice, so `?? 0` returns 0. For Team, fall back to 1
// while the seat value has not yet been synced from Stripe so invite
// and seat-validation flows don't transiently read as zero seats.
if (isTeam(subscription.plan)) {
return subscription.seats ?? (hasPaidSubscriptionStatus(subscription.status) ? 1 : 0)
}
if (isPro(subscription.plan)) {
return subscription.seats ?? 0
}
return 0
}
export function checkProPlan(subscription: any): boolean {
return isPro(subscription?.plan) && hasPaidSubscriptionStatus(subscription?.status)
}
export function checkTeamPlan(subscription: any): boolean {
return isTeam(subscription?.plan) && hasPaidSubscriptionStatus(subscription?.status)
}
/**
* True when the subscription's `referenceId` is an org (i.e. not the
* caller's own `userId`). Prefer this over plan-name checks for scope
* decisions — a `pro_*` sub attached to an org is org-scoped even though
* `isTeam` / `isOrgPlan` return false.
*/
export function isOrgScopedSubscription(
subscription: { referenceId?: string | null } | null | undefined,
userId: string
): boolean {
if (!subscription?.referenceId) return false
return subscription.referenceId !== userId
}
/**
* Get the minimum usage limit for an individual user (used for validation).
*
* Callers should only invoke this for **personally-scoped** subscriptions —
* any org-scoped subscription (team, enterprise, or `pro_*` attached to an
* organization) uses the organization-level limit instead. Callers are
* responsible for gating with `isOrgScopedSubscription` before calling.
*
* @param subscription The subscription object
* @returns The per-user minimum limit in dollars
*/
export function getPerUserMinimumLimit(subscription: any): number {
if (!subscription || !hasPaidSubscriptionStatus(subscription.status)) {
return getFreeTierLimit()
}
if (isPro(subscription.plan)) {
const tierCredits = getPlanTierCredits(subscription.plan)
if (tierCredits > 0) return tierCredits / CREDIT_MULTIPLIER
return getProTierLimit()
}
if (isOrgPlan(subscription.plan)) {
return 0
}
return getFreeTierLimit()
}
/**
* Check if a user can edit their usage limits based on their subscription
* Free and Enterprise plans cannot edit limits
* Pro and Team plans can increase their limits
* @param subscription The subscription object
* @returns Whether the user can edit their usage limits
*/
export function canEditUsageLimit(subscription: any): boolean {
if (!subscription || !hasUsableSubscriptionStatus(subscription.status)) {
return false // Free plan users cannot edit limits
}
// Only Pro and Team plans can edit limits
// Enterprise has fixed limits that match their monthly cost
return isPro(subscription.plan) || isTeam(subscription.plan)
}
/**
* Get pricing info for a plan. Supports both legacy names (`'pro'`, `'team'`)
* and new credit-tier names (`'pro_4000'`, `'team_8000'`).
*/
export function getPlanPricing(plan: string): { basePrice: number } {
if (isFree(plan)) return { basePrice: 0 }
if (isEnterprise(plan)) return { basePrice: getEnterpriseTierLimitPerSeat() }
if (isPro(plan) || isTeam(plan)) {
const tierCredits = getPlanTierCredits(plan)
if (tierCredits > 0) return { basePrice: tierCredits / CREDIT_MULTIPLIER }
return { basePrice: isPro(plan) ? getProTierLimit() : getTeamTierLimitPerSeat() }
}
return { basePrice: 0 }
}
@@ -0,0 +1,535 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCalculateSubscriptionOverage,
mockComputeOrgOverageAmount,
mockDbSelect,
mockDbTransaction,
mockEnqueueOutboxEvent,
mockGetEffectiveBillingStatus,
mockGetHighestPrioritySubscription,
mockGetBillingPeriodUsageCost,
mockGetOrganizationSubscriptionUsable,
mockHasUsableSubscriptionAccess,
mockIsEnterprise,
mockIsFree,
mockIsOrgScopedSubscription,
mockIsOrganizationBillingBlocked,
mockTxExecute,
mockTxSelect,
mockTxStatsLimit,
mockTxUpdate,
} = vi.hoisted(() => ({
mockCalculateSubscriptionOverage: vi.fn(),
mockComputeOrgOverageAmount: vi.fn(),
mockDbSelect: vi.fn(),
mockDbTransaction: vi.fn(),
mockEnqueueOutboxEvent: vi.fn(),
mockGetEffectiveBillingStatus: vi.fn(),
mockGetHighestPrioritySubscription: vi.fn(),
mockGetBillingPeriodUsageCost: vi.fn(),
mockGetOrganizationSubscriptionUsable: vi.fn(),
mockHasUsableSubscriptionAccess: vi.fn(),
mockIsEnterprise: vi.fn(),
mockIsFree: vi.fn(),
mockIsOrgScopedSubscription: vi.fn(),
mockIsOrganizationBillingBlocked: vi.fn(),
mockTxExecute: vi.fn(),
mockTxSelect: vi.fn(),
mockTxStatsLimit: vi.fn(),
mockTxUpdate: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: mockDbSelect,
transaction: mockDbTransaction,
},
}))
vi.mock('@sim/db/schema', () => ({
member: {
organizationId: 'member.organizationId',
role: 'member.role',
userId: 'member.userId',
},
organization: {
creditBalance: 'organization.creditBalance',
departedMemberUsage: 'organization.departedMemberUsage',
id: 'organization.id',
},
subscription: {
id: 'subscription.id',
stripeCustomerId: 'subscription.stripeCustomerId',
},
userStats: {
billedOverageThisPeriod: 'userStats.billedOverageThisPeriod',
creditBalance: 'userStats.creditBalance',
currentPeriodCost: 'userStats.currentPeriodCost',
lastPeriodCost: 'userStats.lastPeriodCost',
proPeriodCostSnapshot: 'userStats.proPeriodCostSnapshot',
proPeriodCostSnapshotAt: 'userStats.proPeriodCostSnapshotAt',
userId: 'userStats.userId',
},
}))
vi.mock('@/lib/billing/core/access', () => ({
getEffectiveBillingStatus: mockGetEffectiveBillingStatus,
isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked,
}))
vi.mock('@/lib/billing/core/billing', () => ({
calculateSubscriptionOverage: mockCalculateSubscriptionOverage,
computeOrgOverageAmount: mockComputeOrgOverageAmount,
}))
vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
getOrganizationSubscriptionUsable: mockGetOrganizationSubscriptionUsable,
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost,
}))
vi.mock('@/lib/billing/plan-helpers', () => ({
isEnterprise: mockIsEnterprise,
isFree: mockIsFree,
}))
vi.mock('@/lib/billing/subscriptions/utils', () => ({
hasUsableSubscriptionAccess: mockHasUsableSubscriptionAccess,
isOrgScopedSubscription: mockIsOrgScopedSubscription,
}))
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({
OUTBOX_EVENT_TYPES: {
STRIPE_THRESHOLD_OVERAGE_INVOICE: 'stripe.threshold-overage-invoice',
},
}))
vi.mock('@/lib/core/config/env', () => ({
env: {},
envNumber: vi.fn((_value: string | undefined, fallback: number) => fallback),
}))
vi.mock('@/lib/core/outbox/service', () => ({
enqueueOutboxEvent: mockEnqueueOutboxEvent,
}))
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
interface MockTx {
execute: typeof mockTxExecute
select: typeof mockTxSelect
update: typeof mockTxUpdate
}
const userSubscription = {
id: 'sub-db-1',
plan: 'pro',
referenceId: 'user-1',
seats: 1,
periodStart: new Date('2026-05-01T00:00:00.000Z'),
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_stripe_1',
status: 'active',
}
function buildSelectChain<T>(rows: T[]) {
const chain = {
from: vi.fn(() => chain),
leftJoin: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
where: vi.fn(() => result),
}
const result = {
limit: vi.fn(async () => rows),
then: (resolve: (value: T[]) => unknown, reject?: (reason: unknown) => unknown) =>
Promise.resolve(rows).then(resolve, reject),
}
return {
from: chain.from,
}
}
function buildPersonalSelectChain(customerId = 'cus_1') {
return buildSelectChain([
{
currentPeriodCost: '0',
proPeriodCostSnapshot: '0',
proPeriodCostSnapshotAt: null,
lastPeriodCost: '0',
stripeCustomerId: customerId,
},
])
}
function buildPersonalSnapshotSelectChain({
currentPeriodCost = '0',
proPeriodCostSnapshot = '0',
proPeriodCostSnapshotAt = null,
lastPeriodCost = '0',
}: {
currentPeriodCost?: string
proPeriodCostSnapshot?: string
proPeriodCostSnapshotAt?: Date | null
lastPeriodCost?: string
}) {
return buildSelectChain([
{
currentPeriodCost,
proPeriodCostSnapshot,
proPeriodCostSnapshotAt,
lastPeriodCost,
},
])
}
function buildStatsSelectChain() {
const result = {
for: vi.fn(() => result),
limit: mockTxStatsLimit,
then: (resolve: (value: unknown[]) => unknown, reject?: (reason: unknown) => unknown) =>
Promise.resolve(mockTxStatsLimit()).then(resolve, reject),
}
return {
from: vi.fn(() => ({
leftJoin: vi.fn(() => ({
innerJoin: vi.fn(() => ({
where: vi.fn(() => result),
})),
})),
where: vi.fn(() => result),
})),
}
}
function buildUpdateChain() {
return {
set: vi.fn(() => ({
where: vi.fn(async () => []),
})),
}
}
describe('checkAndBillOverageThreshold', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetHighestPrioritySubscription.mockResolvedValue(userSubscription)
mockGetEffectiveBillingStatus.mockResolvedValue({ billingBlocked: false })
mockHasUsableSubscriptionAccess.mockReturnValue(true)
mockIsFree.mockReturnValue(false)
mockIsEnterprise.mockReturnValue(false)
mockIsOrgScopedSubscription.mockReturnValue(false)
mockGetBillingPeriodUsageCost.mockResolvedValue(0)
mockDbSelect.mockImplementation(() => buildPersonalSelectChain())
mockTxSelect.mockImplementation(() => buildStatsSelectChain())
mockTxUpdate.mockImplementation(() => buildUpdateChain())
mockTxExecute.mockResolvedValue(undefined)
mockDbTransaction.mockImplementation(async (callback: (tx: MockTx) => Promise<void>) =>
callback({ execute: mockTxExecute, select: mockTxSelect, update: mockTxUpdate })
)
})
it('does not lock user_stats when calculated overage is below threshold', async () => {
mockCalculateSubscriptionOverage.mockResolvedValue(99)
await checkAndBillOverageThreshold('user-1')
expect(mockCalculateSubscriptionOverage).toHaveBeenCalledWith({
id: userSubscription.id,
plan: userSubscription.plan,
referenceId: userSubscription.referenceId,
seats: userSubscription.seats,
periodStart: userSubscription.periodStart,
periodEnd: userSubscription.periodEnd,
})
expect(mockDbTransaction).not.toHaveBeenCalled()
expect(mockDbSelect).toHaveBeenCalledTimes(1)
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
})
it('calculates overage before opening the short user_stats transaction', async () => {
mockCalculateSubscriptionOverage.mockResolvedValue(250)
mockTxStatsLimit.mockResolvedValue([
{
currentPeriodCost: '0',
proPeriodCostSnapshot: '0',
proPeriodCostSnapshotAt: null,
lastPeriodCost: '0',
billedOverageThisPeriod: '0',
creditBalance: '0',
},
])
await checkAndBillOverageThreshold('user-1')
expect(mockCalculateSubscriptionOverage).toHaveBeenCalled()
expect(mockDbTransaction).toHaveBeenCalled()
expect(mockCalculateSubscriptionOverage.mock.invocationCallOrder[0]).toBeLessThan(
mockDbTransaction.mock.invocationCallOrder[0]
)
expect(mockTxExecute).toHaveBeenCalledTimes(1)
expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1)
})
it('rechecks billed overage while locked before enqueueing an invoice', async () => {
mockCalculateSubscriptionOverage.mockResolvedValue(250)
mockTxStatsLimit.mockResolvedValue([
{
currentPeriodCost: '0',
proPeriodCostSnapshot: '0',
proPeriodCostSnapshotAt: null,
lastPeriodCost: '0',
billedOverageThisPeriod: '200',
creditBalance: '0',
},
])
await checkAndBillOverageThreshold('user-1')
expect(mockDbTransaction).toHaveBeenCalled()
expect(mockTxExecute).toHaveBeenCalledTimes(1)
expect(mockTxUpdate).not.toHaveBeenCalled()
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
})
it('skips personal threshold billing when locked usage inputs changed', async () => {
mockCalculateSubscriptionOverage.mockResolvedValue(250)
mockDbSelect
.mockImplementationOnce(() => buildPersonalSnapshotSelectChain({ currentPeriodCost: '250' }))
.mockImplementationOnce(() => buildPersonalSelectChain())
mockTxStatsLimit.mockResolvedValue([
{
currentPeriodCost: '0',
proPeriodCostSnapshot: '0',
proPeriodCostSnapshotAt: null,
lastPeriodCost: '250',
billedOverageThisPeriod: '0',
creditBalance: '0',
},
])
await checkAndBillOverageThreshold('user-1')
expect(mockDbTransaction).toHaveBeenCalled()
expect(mockTxUpdate).not.toHaveBeenCalled()
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
})
it('computes organization overage before opening the locked transaction', async () => {
mockIsOrgScopedSubscription.mockReturnValue(true)
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
mockGetOrganizationSubscriptionUsable.mockResolvedValue({
plan: 'team',
seats: 2,
periodStart: new Date('2026-05-01T00:00:00.000Z'),
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_team_1',
stripeCustomerId: 'cus_team_1',
})
mockDbSelect.mockImplementationOnce(() =>
buildSelectChain([
{
userId: 'owner-1',
role: 'owner',
currentPeriodCost: '350',
departedMemberUsage: '25',
},
])
)
mockComputeOrgOverageAmount.mockResolvedValue({
totalOverage: 250,
baseSubscriptionAmount: 100,
effectiveUsage: 350,
})
mockTxStatsLimit
.mockResolvedValueOnce([{ userId: 'owner-1' }])
.mockResolvedValueOnce([{ billedOverageThisPeriod: '0' }])
.mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '25' }])
.mockResolvedValueOnce([
{
userId: 'owner-1',
role: 'owner',
currentPeriodCost: '350',
departedMemberUsage: '25',
},
])
await checkAndBillOverageThreshold('user-1')
expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith({
plan: 'team',
seats: 2,
periodStart: new Date('2026-05-01T00:00:00.000Z'),
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
organizationId: userSubscription.referenceId,
pooledCurrentPeriodCost: 350,
departedMemberUsage: 25,
memberIds: ['owner-1'],
})
expect(mockDbTransaction).toHaveBeenCalled()
expect(mockComputeOrgOverageAmount.mock.invocationCallOrder[0]).toBeLessThan(
mockDbTransaction.mock.invocationCallOrder[0]
)
expect(mockTxExecute).toHaveBeenCalledTimes(1)
expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1)
})
it('skips stale organization overage when locked usage inputs changed', async () => {
mockIsOrgScopedSubscription.mockReturnValue(true)
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
mockGetOrganizationSubscriptionUsable.mockResolvedValue({
plan: 'team',
seats: 2,
periodStart: new Date('2026-05-01T00:00:00.000Z'),
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_team_1',
stripeCustomerId: 'cus_team_1',
})
mockDbSelect.mockImplementationOnce(() =>
buildSelectChain([
{
userId: 'owner-1',
role: 'owner',
currentPeriodCost: '350',
departedMemberUsage: '25',
},
])
)
mockComputeOrgOverageAmount.mockResolvedValue({
totalOverage: 250,
baseSubscriptionAmount: 100,
effectiveUsage: 350,
})
mockTxStatsLimit
.mockResolvedValueOnce([{ userId: 'owner-1' }])
.mockResolvedValueOnce([{ billedOverageThisPeriod: '0' }])
.mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '75' }])
.mockResolvedValueOnce([
{
userId: 'owner-1',
role: 'owner',
currentPeriodCost: '350',
departedMemberUsage: '75',
},
])
await checkAndBillOverageThreshold('user-1')
expect(mockDbTransaction).toHaveBeenCalled()
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
expect(mockTxUpdate).not.toHaveBeenCalled()
})
it('rechecks organization billed overage on the locked owner tracker', async () => {
mockIsOrgScopedSubscription.mockReturnValue(true)
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
mockGetOrganizationSubscriptionUsable.mockResolvedValue({
plan: 'team',
seats: 2,
periodStart: new Date('2026-05-01T00:00:00.000Z'),
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_team_1',
stripeCustomerId: 'cus_team_1',
})
mockDbSelect.mockImplementationOnce(() =>
buildSelectChain([
{
userId: 'owner-1',
role: 'owner',
currentPeriodCost: '350',
departedMemberUsage: '25',
},
])
)
mockComputeOrgOverageAmount.mockResolvedValue({
totalOverage: 250,
baseSubscriptionAmount: 100,
effectiveUsage: 350,
})
mockTxStatsLimit
.mockResolvedValueOnce([{ userId: 'owner-1' }])
.mockResolvedValueOnce([{ billedOverageThisPeriod: '200' }])
.mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '25' }])
.mockResolvedValueOnce([
{
userId: 'owner-1',
role: 'owner',
currentPeriodCost: '350',
departedMemberUsage: '25',
},
])
await checkAndBillOverageThreshold('user-1')
expect(mockDbTransaction).toHaveBeenCalled()
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
expect(mockTxUpdate).not.toHaveBeenCalled()
})
it('skips stale organization overage when owner identity changed', async () => {
mockIsOrgScopedSubscription.mockReturnValue(true)
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
mockGetOrganizationSubscriptionUsable.mockResolvedValue({
plan: 'team',
seats: 2,
periodStart: new Date('2026-05-01T00:00:00.000Z'),
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_team_1',
stripeCustomerId: 'cus_team_1',
})
mockDbSelect.mockImplementationOnce(() =>
buildSelectChain([
{
userId: 'owner-1',
role: 'owner',
currentPeriodCost: '350',
departedMemberUsage: '25',
},
{
userId: 'member-1',
role: 'member',
currentPeriodCost: '25',
departedMemberUsage: '25',
},
])
)
mockComputeOrgOverageAmount.mockResolvedValue({
totalOverage: 250,
baseSubscriptionAmount: 100,
effectiveUsage: 350,
})
mockTxStatsLimit
.mockResolvedValueOnce([{ userId: 'member-1' }])
.mockResolvedValueOnce([{ billedOverageThisPeriod: '0' }])
.mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '25' }])
.mockResolvedValueOnce([
{
userId: 'owner-1',
role: 'member',
currentPeriodCost: '350',
departedMemberUsage: '25',
},
{
userId: 'member-1',
role: 'owner',
currentPeriodCost: '25',
departedMemberUsage: '25',
},
])
await checkAndBillOverageThreshold('user-1')
expect(mockDbTransaction).toHaveBeenCalled()
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
expect(mockTxUpdate).not.toHaveBeenCalled()
})
})
+727
View File
@@ -0,0 +1,727 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization, subscription, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { BILLING_LOCK_TIMEOUT_MS, DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants'
import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { calculateSubscriptionOverage, computeOrgOverageAmount } from '@/lib/billing/core/billing'
import {
getHighestPrioritySubscription,
getOrganizationSubscriptionUsable,
} from '@/lib/billing/core/subscription'
import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
import { isEnterprise, isFree } from '@/lib/billing/plan-helpers'
import {
hasUsableSubscriptionAccess,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { env, envNumber } from '@/lib/core/config/env'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('ThresholdBilling')
const OVERAGE_THRESHOLD = envNumber(env.OVERAGE_THRESHOLD_DOLLARS, DEFAULT_OVERAGE_THRESHOLD)
const USAGE_TOTAL_EPSILON = 0.000001
interface PersonalUsageSnapshot {
currentPeriodCost: number
proPeriodCostSnapshot: number
proPeriodCostSnapshotAt: Date | null
lastPeriodCost: number
}
interface OrganizationUsageSnapshot {
memberIds: string[]
ownerId: string
memberSignature: string
pooledCurrentPeriodCost: number
departedMemberUsage: number
}
export async function checkAndBillOverageThreshold(userId: string): Promise<void> {
try {
const threshold = OVERAGE_THRESHOLD
const userSubscription = await getHighestPrioritySubscription(userId)
const billingStatus = await getEffectiveBillingStatus(userId)
if (
!userSubscription ||
!hasUsableSubscriptionAccess(userSubscription.status, billingStatus.billingBlocked)
) {
logger.debug('No active subscription for threshold billing', { userId })
return
}
if (isFree(userSubscription.plan) || isEnterprise(userSubscription.plan)) {
return
}
// Org-scoped subs are billed at the org level regardless of plan name.
if (isOrgScopedSubscription(userSubscription, userId)) {
logger.debug('Org-scoped subscription detected - triggering org-level threshold billing', {
userId,
organizationId: userSubscription.referenceId,
plan: userSubscription.plan,
})
await checkAndBillOrganizationOverageThreshold(userSubscription.referenceId)
return
}
const usageSnapshot = await getPersonalUsageSnapshot(userId)
if (!usageSnapshot) {
logger.warn('User stats not found for threshold billing', { userId })
return
}
const currentOverage = await calculateSubscriptionOverage({
id: userSubscription.id,
plan: userSubscription.plan,
referenceId: userSubscription.referenceId,
seats: userSubscription.seats,
periodStart: userSubscription.periodStart,
periodEnd: userSubscription.periodEnd,
})
if (currentOverage < threshold) {
logger.debug('Threshold billing check below threshold before locking user stats', {
userId,
plan: userSubscription.plan,
currentOverage,
threshold,
})
return
}
const stripeSubscriptionId = userSubscription.stripeSubscriptionId
if (!stripeSubscriptionId) {
logger.error('No Stripe subscription ID found', { userId })
return
}
const customerRows = await db
.select({ stripeCustomerId: subscription.stripeCustomerId })
.from(subscription)
.where(eq(subscription.id, userSubscription.id))
.limit(1)
const customerId = customerRows[0]?.stripeCustomerId
if (!customerId) {
logger.error('No Stripe customer ID found', { userId, subscriptionId: userSubscription.id })
return
}
const periodEnd = userSubscription.periodEnd
? Math.floor(userSubscription.periodEnd.getTime() / 1000)
: Math.floor(Date.now() / 1000)
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
const totalOverageCents = Math.round(currentOverage * 100)
const billedResult = await db.transaction(
async (
tx
): Promise<{
amount: number
creditsApplied: number
settledVia: 'stripe' | 'credits'
} | null> => {
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
const statsRecords = await tx
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.for('update')
.limit(1)
if (statsRecords.length === 0) {
logger.warn('User stats not found for threshold billing', { userId })
return null
}
const stats = statsRecords[0]
const lockedUsageSnapshot = personalUsageSnapshotFromStats(stats)
if (!personalUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot)) {
logger.debug('Personal usage changed during threshold billing check; retry later', {
userId,
usageSnapshot,
lockedUsageSnapshot,
})
return null
}
const billedOverageThisPeriod = toNumber(toDecimal(stats.billedOverageThisPeriod))
const unbilledOverage = Math.max(0, currentOverage - billedOverageThisPeriod)
logger.debug('Threshold billing check', {
userId,
plan: userSubscription.plan,
currentOverage,
billedOverageThisPeriod,
unbilledOverage,
threshold,
})
if (unbilledOverage < threshold) {
return null
}
// Apply credits to reduce the amount to bill (use stats from locked row)
let amountToBill = unbilledOverage
let creditsApplied = 0
const creditBalance = toNumber(toDecimal(stats.creditBalance))
if (creditBalance > 0) {
creditsApplied = Math.min(creditBalance, amountToBill)
await tx
.update(userStats)
.set({
creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${creditsApplied})`,
})
.where(eq(userStats.userId, userId))
amountToBill = amountToBill - creditsApplied
logger.info('Applied credits to reduce threshold overage', {
userId,
creditBalance,
creditsApplied,
remainingToBill: amountToBill,
})
}
// If credits covered everything, bump billed tracker but don't enqueue Stripe invoice.
if (amountToBill <= 0) {
await tx
.update(userStats)
.set({
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
})
.where(eq(userStats.userId, userId))
logger.info('Credits fully covered threshold overage', {
userId,
creditsApplied,
unbilledOverage,
})
return { amount: unbilledOverage, creditsApplied, settledVia: 'credits' }
}
const amountCents = Math.round(amountToBill * 100)
await tx
.update(userStats)
.set({
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
})
.where(eq(userStats.userId, userId))
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, {
customerId,
stripeSubscriptionId,
amountCents,
description: `Threshold overage billing ${billingPeriod}`,
itemDescription: `Usage overage ($${amountToBill.toFixed(2)})`,
billingPeriod,
invoiceIdemKeyStem: `threshold-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
itemIdemKeyStem: `threshold-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
metadata: {
type: 'overage_threshold_billing',
userId,
subscriptionId: stripeSubscriptionId,
billingPeriod,
totalOverageAtTimeOfBilling: currentOverage.toFixed(2),
},
})
logger.info('Queued threshold overage invoice for Stripe', {
userId,
plan: userSubscription.plan,
amountToBill,
billingPeriod,
creditsApplied,
totalProcessed: unbilledOverage,
newBilledTotal: billedOverageThisPeriod + unbilledOverage,
})
return { amount: amountToBill, creditsApplied, settledVia: 'stripe' }
}
)
if (billedResult) {
const { amount, creditsApplied, settledVia } = billedResult
const settledLabel = settledVia === 'credits' ? 'covered by credits' : 'billed'
recordAudit({
actorId: userId,
action: AuditAction.OVERAGE_BILLED,
resourceType: AuditResourceType.BILLING,
resourceId: userSubscription.id,
description: `Overage of $${amount.toFixed(2)} ${settledLabel} for user ${userId}`,
metadata: {
entityType: 'user',
referenceId: userId,
plan: userSubscription.plan,
amount,
currency: 'usd',
creditsApplied,
settledVia,
billingPeriod,
},
})
captureServerEvent(userId, 'overage_billed', {
amount,
currency: 'usd',
entity_type: 'user',
reference_id: userId,
settled_via: settledVia,
})
}
} catch (error) {
logger.error('Error in threshold billing check', {
userId,
error,
})
}
}
async function checkAndBillOrganizationOverageThreshold(organizationId: string): Promise<void> {
logger.info('=== ENTERED checkAndBillOrganizationOverageThreshold ===', { organizationId })
try {
const threshold = OVERAGE_THRESHOLD
if (await isOrganizationBillingBlocked(organizationId)) {
logger.debug('Organization billing blocked for threshold billing', { organizationId })
return
}
logger.debug('Starting organization threshold billing check', { organizationId, threshold })
const orgSubscription = await getOrganizationSubscriptionUsable(organizationId)
if (!orgSubscription) {
logger.debug('No active subscription for organization', { organizationId })
return
}
logger.debug('Found organization subscription', {
organizationId,
plan: orgSubscription.plan,
seats: orgSubscription.seats,
stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
})
if (isEnterprise(orgSubscription.plan) || isFree(orgSubscription.plan)) {
logger.debug('Organization plan not eligible for overage billing, skipping', {
organizationId,
plan: orgSubscription.plan,
})
return
}
const memberUsageRows = await db
.select({
userId: member.userId,
role: member.role,
currentPeriodCost: userStats.currentPeriodCost,
departedMemberUsage: organization.departedMemberUsage,
})
.from(member)
.leftJoin(userStats, eq(member.userId, userStats.userId))
.innerJoin(organization, eq(organization.id, member.organizationId))
.where(eq(member.organizationId, organizationId))
logger.debug('Found organization members', {
organizationId,
memberCount: memberUsageRows.length,
members: memberUsageRows.map((m) => ({ userId: m.userId, role: m.role })),
})
if (memberUsageRows.length === 0) {
logger.warn('No members found for organization', { organizationId })
return
}
const usageSnapshot = buildOrganizationUsageSnapshot(memberUsageRows)
if (!usageSnapshot) {
logger.error(
'Organization has no owner when running threshold billing — data integrity issue, skipping',
{ organizationId }
)
return
}
logger.debug('Found organization owner, starting transaction', {
organizationId,
ownerId: usageSnapshot.ownerId,
})
const ledgerUsage =
orgSubscription.periodStart && orgSubscription.periodEnd
? await getBillingPeriodUsageCost(
{ type: 'organization', id: organizationId },
{ start: orgSubscription.periodStart, end: orgSubscription.periodEnd }
)
: 0
const {
totalOverage: currentOverage,
baseSubscriptionAmount: basePrice,
effectiveUsage: effectiveTeamUsage,
} = await computeOrgOverageAmount({
plan: orgSubscription.plan,
seats: orgSubscription.seats ?? null,
periodStart: orgSubscription.periodStart ?? null,
periodEnd: orgSubscription.periodEnd ?? null,
organizationId,
pooledCurrentPeriodCost: usageSnapshot.pooledCurrentPeriodCost + ledgerUsage,
departedMemberUsage: usageSnapshot.departedMemberUsage,
memberIds: usageSnapshot.memberIds,
})
if (currentOverage < threshold) {
logger.debug('Organization threshold billing check below threshold before locking', {
organizationId,
totalTeamUsage:
usageSnapshot.pooledCurrentPeriodCost + ledgerUsage + usageSnapshot.departedMemberUsage,
ledgerUsage,
effectiveTeamUsage,
basePrice,
currentOverage,
threshold,
})
return
}
// Validate Stripe identifiers BEFORE mutating credits/trackers.
const stripeSubscriptionId = orgSubscription.stripeSubscriptionId
if (!stripeSubscriptionId) {
logger.error('No Stripe subscription ID for organization', { organizationId })
return
}
const customerId = orgSubscription.stripeCustomerId
if (!customerId) {
logger.error('No Stripe customer ID for organization', { organizationId })
return
}
const periodEnd = orgSubscription.periodEnd
? Math.floor(orgSubscription.periodEnd.getTime() / 1000)
: Math.floor(Date.now() / 1000)
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
const totalOverageCents = Math.round(currentOverage * 100)
const orgBilledResult = await db.transaction(
async (
tx
): Promise<{
amount: number
creditsApplied: number
ownerId: string
settledVia: 'stripe' | 'credits'
} | null> => {
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
const lockedOwnerRows = await tx
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
.for('update')
.limit(1)
const lockedOwnerId = lockedOwnerRows[0]?.userId
if (!lockedOwnerId) {
logger.error('Organization owner not found after locking organization', {
organizationId,
})
return null
}
const ownerStatsLock = await tx
.select()
.from(userStats)
.where(eq(userStats.userId, lockedOwnerId))
.for('update')
.limit(1)
if (ownerStatsLock.length === 0) {
logger.error('Owner stats not found', { organizationId, ownerId: lockedOwnerId })
return null
}
const orgLock = await tx
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.for('update')
.limit(1)
if (orgLock.length === 0) {
logger.error('Organization not found', { organizationId })
return null
}
const lockedMemberUsageRows = await tx
.select({
userId: member.userId,
role: member.role,
currentPeriodCost: userStats.currentPeriodCost,
departedMemberUsage: organization.departedMemberUsage,
})
.from(member)
.leftJoin(userStats, eq(member.userId, userStats.userId))
.innerJoin(organization, eq(organization.id, member.organizationId))
.where(eq(member.organizationId, organizationId))
const lockedUsageSnapshot = buildOrganizationUsageSnapshot(lockedMemberUsageRows)
if (
!lockedUsageSnapshot ||
lockedOwnerId !== usageSnapshot.ownerId ||
!organizationUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot)
) {
logger.debug('Organization usage changed during threshold billing check; retry later', {
organizationId,
usageSnapshot,
lockedUsageSnapshot,
lockedOwnerId,
})
return null
}
const totalBilledOverage = toNumber(toDecimal(ownerStatsLock[0].billedOverageThisPeriod))
const orgCreditBalance = toNumber(toDecimal(orgLock[0].creditBalance))
const unbilledOverage = Math.max(0, currentOverage - totalBilledOverage)
logger.debug('Organization threshold billing check', {
organizationId,
totalTeamUsage:
usageSnapshot.pooledCurrentPeriodCost + ledgerUsage + usageSnapshot.departedMemberUsage,
ledgerUsage,
effectiveTeamUsage,
basePrice,
currentOverage,
totalBilledOverage,
unbilledOverage,
threshold,
})
if (unbilledOverage < threshold) {
return null
}
let amountToBill = unbilledOverage
let creditsApplied = 0
if (orgCreditBalance > 0) {
creditsApplied = Math.min(orgCreditBalance, amountToBill)
await tx
.update(organization)
.set({
creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${creditsApplied})`,
})
.where(eq(organization.id, organizationId))
amountToBill = amountToBill - creditsApplied
logger.info('Applied org credits to reduce threshold overage', {
organizationId,
creditBalance: orgCreditBalance,
creditsApplied,
remainingToBill: amountToBill,
})
}
// If credits covered everything, bump billed tracker but don't enqueue Stripe invoice.
if (amountToBill <= 0) {
await tx
.update(userStats)
.set({
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
})
.where(eq(userStats.userId, lockedOwnerId))
logger.info('Credits fully covered org threshold overage', {
organizationId,
creditsApplied,
unbilledOverage,
})
return {
amount: unbilledOverage,
creditsApplied,
ownerId: lockedOwnerId,
settledVia: 'credits',
}
}
const amountCents = Math.round(amountToBill * 100)
// Bump billed tracker and enqueue Stripe invoice atomically.
// See user-path above for the full retry-invariant reasoning.
await tx
.update(userStats)
.set({
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
})
.where(eq(userStats.userId, lockedOwnerId))
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, {
customerId,
stripeSubscriptionId,
amountCents,
description: `Team threshold overage billing ${billingPeriod}`,
itemDescription: `Team usage overage ($${amountToBill.toFixed(2)})`,
billingPeriod,
invoiceIdemKeyStem: `threshold-overage-org-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
itemIdemKeyStem: `threshold-overage-org-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
metadata: {
type: 'overage_threshold_billing_org',
organizationId,
subscriptionId: stripeSubscriptionId,
billingPeriod,
totalOverageAtTimeOfBilling: currentOverage.toFixed(2),
},
})
logger.info('Queued organization threshold overage invoice for Stripe', {
organizationId,
ownerId: lockedOwnerId,
creditsApplied,
amountBilled: amountToBill,
totalProcessed: unbilledOverage,
billingPeriod,
})
return {
amount: amountToBill,
creditsApplied,
ownerId: lockedOwnerId,
settledVia: 'stripe',
}
}
)
if (orgBilledResult) {
const { amount, creditsApplied, ownerId, settledVia } = orgBilledResult
const settledLabel = settledVia === 'credits' ? 'covered by credits' : 'billed'
recordAudit({
actorId: ownerId,
action: AuditAction.OVERAGE_BILLED,
resourceType: AuditResourceType.BILLING,
resourceId: orgSubscription.id,
description: `Overage of $${amount.toFixed(2)} ${settledLabel} for organization ${organizationId}`,
metadata: {
entityType: 'organization',
referenceId: organizationId,
organizationId,
plan: orgSubscription.plan,
amount,
currency: 'usd',
creditsApplied,
settledVia,
billingPeriod,
},
})
captureServerEvent(ownerId, 'overage_billed', {
amount,
currency: 'usd',
entity_type: 'organization',
reference_id: organizationId,
settled_via: settledVia,
})
}
} catch (error) {
logger.error('Error in organization threshold billing', {
organizationId,
error,
})
}
}
async function getPersonalUsageSnapshot(userId: string): Promise<PersonalUsageSnapshot | null> {
const [stats] = await db
.select({
currentPeriodCost: userStats.currentPeriodCost,
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
proPeriodCostSnapshotAt: userStats.proPeriodCostSnapshotAt,
lastPeriodCost: userStats.lastPeriodCost,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
return stats ? personalUsageSnapshotFromStats(stats) : null
}
function personalUsageSnapshotFromStats(stats: {
currentPeriodCost: string | number | null
proPeriodCostSnapshot: string | number | null
proPeriodCostSnapshotAt: Date | null
lastPeriodCost: string | number | null
}): PersonalUsageSnapshot {
return {
currentPeriodCost: toNumber(toDecimal(stats.currentPeriodCost)),
proPeriodCostSnapshot: toNumber(toDecimal(stats.proPeriodCostSnapshot)),
proPeriodCostSnapshotAt: stats.proPeriodCostSnapshotAt,
lastPeriodCost: toNumber(toDecimal(stats.lastPeriodCost)),
}
}
function personalUsageSnapshotMatches(
expected: PersonalUsageSnapshot,
actual: PersonalUsageSnapshot
): boolean {
return (
Math.abs(expected.currentPeriodCost - actual.currentPeriodCost) <= USAGE_TOTAL_EPSILON &&
Math.abs(expected.proPeriodCostSnapshot - actual.proPeriodCostSnapshot) <=
USAGE_TOTAL_EPSILON &&
Math.abs(expected.lastPeriodCost - actual.lastPeriodCost) <= USAGE_TOTAL_EPSILON &&
nullableDateTime(expected.proPeriodCostSnapshotAt) ===
nullableDateTime(actual.proPeriodCostSnapshotAt)
)
}
function buildOrganizationUsageSnapshot(
rows: {
userId: string
role: string
currentPeriodCost: string | number | null
departedMemberUsage: string | number | null
}[]
): OrganizationUsageSnapshot | null {
const owner = rows.find((row) => row.role === 'owner')
if (!owner) return null
const sortedRows = [...rows].sort((a, b) => a.userId.localeCompare(b.userId))
let pooledCurrentPeriodCost = 0
for (const row of sortedRows) {
pooledCurrentPeriodCost += toNumber(toDecimal(row.currentPeriodCost))
}
return {
memberIds: sortedRows.map((row) => row.userId),
ownerId: owner.userId,
memberSignature: sortedRows
.map(
(row) =>
`${row.userId}:${row.role}:${toNumber(toDecimal(row.currentPeriodCost)).toFixed(6)}`
)
.join('|'),
pooledCurrentPeriodCost,
departedMemberUsage: toNumber(toDecimal(owner.departedMemberUsage)),
}
}
function organizationUsageSnapshotMatches(
expected: OrganizationUsageSnapshot,
actual: OrganizationUsageSnapshot
): boolean {
return (
expected.ownerId === actual.ownerId &&
expected.memberSignature === actual.memberSignature &&
Math.abs(expected.departedMemberUsage - actual.departedMemberUsage) <= USAGE_TOTAL_EPSILON
)
}
function nullableDateTime(value: Date | null): number | null {
return value?.getTime() ?? null
}
+243
View File
@@ -0,0 +1,243 @@
/**
* Billing System Types
* Centralized type definitions for the billing system
*/
import { z } from 'zod'
export const enterpriseSubscriptionMetadataSchema = z.object({
plan: z
.string()
.transform((v) => v.toLowerCase())
.pipe(z.literal('enterprise')),
// The referenceId must be provided in Stripe metadata to link to the organization
// This gets stored in the subscription.referenceId column
referenceId: z.string().min(1),
// The fixed monthly price for this enterprise customer (as string from Stripe metadata)
// This will be used to set the organization's usage limit
monthlyPrice: z.coerce.number().positive(),
// Number of seats for invitation limits (not for billing)
seats: z.coerce.number().int().positive(),
// Optional custom workspace concurrency limit for enterprise workspaces
workspaceConcurrencyLimit: z.coerce.number().int().positive().optional(),
})
export type EnterpriseSubscriptionMetadata = z.infer<typeof enterpriseSubscriptionMetadataSchema>
const enterpriseWorkspaceConcurrencyMetadataSchema = z.object({
workspaceConcurrencyLimit: z.coerce.number().int().positive().optional(),
})
export type EnterpriseWorkspaceConcurrencyMetadata = z.infer<
typeof enterpriseWorkspaceConcurrencyMetadataSchema
>
export function parseEnterpriseSubscriptionMetadata(
value: unknown
): EnterpriseSubscriptionMetadata | null {
const result = enterpriseSubscriptionMetadataSchema.safeParse(value)
return result.success ? result.data : null
}
export function parseEnterpriseWorkspaceConcurrencyMetadata(
value: unknown
): EnterpriseWorkspaceConcurrencyMetadata | null {
const result = enterpriseWorkspaceConcurrencyMetadataSchema.safeParse(value)
return result.success ? result.data : null
}
export interface UsageData {
currentUsage: number
limit: number
percentUsed: number
isWarning: boolean
isExceeded: boolean
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
lastPeriodCost: number
}
export interface UsageLimitInfo {
currentLimit: number
canEdit: boolean
minimumLimit: number
plan: string
updatedAt: Date | null
/**
* Whether the limit is stored on the user (`'user'`) or the organization
* (`'organization'`). Callers should route edits to the matching API
* context. Org-scoped includes any subscription whose `referenceId` is
* an organization id, regardless of plan name.
*/
scope: 'user' | 'organization'
/** Present only when `scope === 'organization'`. */
organizationId: string | null
}
export interface BillingData {
currentPeriodCost: number
projectedCost: number
limit: number
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
daysRemaining: number
}
interface SubscriptionPlan {
name: string
priceId: string
limits: {
cost: number
}
}
interface BillingEntity {
id: string
type: 'user' | 'organization'
referenceId: string
metadata?: { stripeCustomerId?: string; [key: string]: any } | null
createdAt: Date
updatedAt: Date
}
interface BillingConfig {
id: string
entityType: 'user' | 'organization'
entityId: string
usageLimit: number
limitSetBy?: string
limitUpdatedAt?: Date
billingPeriodType: 'monthly' | 'annual'
autoResetEnabled: boolean
createdAt: Date
updatedAt: Date
}
interface UsagePeriod {
id: string
entityType: 'user' | 'organization'
entityId: string
periodStart: Date
periodEnd: Date
totalCost: number
finalCost?: number
isCurrent: boolean
status: 'active' | 'finalized' | 'billed'
createdAt: Date
finalizedAt?: Date
}
interface BillingStatus {
status: 'ok' | 'warning' | 'exceeded'
usageData: UsageData
}
interface TeamUsageLimit {
userId: string
userName: string
userEmail: string
currentLimit: number
currentUsage: number
limitSetBy: string | null
limitUpdatedAt: Date | null
}
interface BillingSummary {
userId: string
email: string
name: string
currentPeriodCost: number
currentUsageLimit: number
currentUsagePercentage: number
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
plan: string
subscriptionStatus: string | null
seats: number | null
billingStatus: 'ok' | 'warning' | 'exceeded'
}
interface SubscriptionAPIResponse {
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
plan: string
status: string | null
seats: number | null
metadata: any | null
usage: UsageData
}
interface UsageLimitAPIResponse {
currentLimit: number
canEdit: boolean
minimumLimit: number
plan: string
setBy?: string
updatedAt?: Date
}
// Utility Types
export type PlanType = 'free' | 'pro' | 'team' | 'enterprise'
export type SubscriptionStatus =
| 'active'
| 'canceled'
| 'past_due'
| 'unpaid'
| 'trialing'
| 'incomplete'
| 'incomplete_expired'
export type BillingEntityType = 'user' | 'organization'
export type BillingPeriodType = 'monthly' | 'annual'
export type UsagePeriodStatus = 'active' | 'finalized' | 'billed'
export type BillingStatusType = 'ok' | 'warning' | 'exceeded'
// Error Types
interface BillingError {
code: string
message: string
details?: any
}
interface UpdateUsageLimitResult {
success: boolean
error?: string
}
// Hook Types for React
interface UseSubscriptionStateReturn {
subscription: {
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
isFree: boolean
plan: string
status?: string
seats?: number
metadata?: any
}
usage: UsageData
isLoading: boolean
error: Error | null
refetch: () => Promise<any>
isAtLeastPro: () => boolean
isAtLeastTeam: () => boolean
canUpgrade: () => boolean
getBillingStatus: () => BillingStatusType
getRemainingBudget: () => number
getDaysRemainingInPeriod: () => number | null
}
interface UseUsageLimitReturn {
currentLimit: number
canEdit: boolean
minimumLimit: number
plan: string
setBy?: string
updatedAt?: Date
updateLimit: (newLimit: number) => Promise<{ success: boolean }>
isLoading: boolean
error: Error | null
refetch: () => Promise<any>
}
@@ -0,0 +1,40 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildUpgradeHref,
isUpgradeReason,
UPGRADE_REASON_COPY,
UPGRADE_REASONS,
} from '@/lib/billing/upgrade-reasons'
describe('upgrade-reasons', () => {
it('has copy for every reason', () => {
for (const reason of UPGRADE_REASONS) {
const copy = UPGRADE_REASON_COPY[reason]
expect(copy.header).toMatch(/^Upgrade to scale/)
expect(copy.noun.length).toBeGreaterThan(0)
expect(copy.warningSubject.length).toBeGreaterThan(0)
expect(copy.reachedSubject.length).toBeGreaterThan(0)
}
})
it('uses Emirs header wording', () => {
expect(UPGRADE_REASON_COPY.seats.header).toBe('Upgrade to scale with your teammates')
expect(UPGRADE_REASON_COPY.tables.header).toBe('Upgrade to scale your tables')
expect(UPGRADE_REASON_COPY.storage.header).toBe('Upgrade to scale your storage')
})
it('builds hrefs with and without a reason', () => {
expect(buildUpgradeHref('ws-1')).toBe('/workspace/ws-1/upgrade')
expect(buildUpgradeHref('ws-1', 'tables')).toBe('/workspace/ws-1/upgrade?reason=tables')
})
it('guards known reasons', () => {
expect(isUpgradeReason('storage')).toBe(true)
expect(isUpgradeReason('seats')).toBe(true)
expect(isUpgradeReason('bogus')).toBe(false)
expect(isUpgradeReason(null)).toBe(false)
})
})
+88
View File
@@ -0,0 +1,88 @@
/**
* Upgrade-reason registry.
*
* Single source of truth for the language shown when a user is routed to the
* upgrade page after hitting a usage limit. The same copy drives both the
* upgrade-page header and the threshold/limit emails, so the in-app and email
* journeys never drift apart.
*/
/** The limit categories that can route a user to the upgrade page. */
export const UPGRADE_REASONS = ['credits', 'storage', 'tables', 'seats'] as const
export type UpgradeReason = (typeof UPGRADE_REASONS)[number]
/** URL query key the upgrade page reads to resolve the reason. */
export const UPGRADE_REASON_PARAM = 'reason' as const
/** Header shown on the upgrade page when no (or an invalid) reason is present. */
export const DEFAULT_UPGRADE_HEADER = 'Plans that scale with you' as const
interface UpgradeReasonCopy {
/** Upgrade-page `<h1>` header. */
header: string
/** Lowercase noun for the limited resource (e.g. "tables", "storage"). */
noun: string
/** Subject line for the 80% warning email. */
warningSubject: string
/** Subject line for the 100% limit-reached email. */
reachedSubject: string
/** One-line body lead for the warning email (running low). */
warningLead: string
/** One-line body lead for the limit-reached email. */
reachedLead: string
}
/**
* Per-reason copy. Headers follow the "Upgrade to scale ..." pattern; email
* subjects/leads reuse the same noun so a user sees consistent language whether
* they arrive from the app or from an email.
*/
export const UPGRADE_REASON_COPY: Record<UpgradeReason, UpgradeReasonCopy> = {
credits: {
header: 'Upgrade to scale your usage',
noun: 'credits',
warningSubject: "You're nearing your usage limit",
reachedSubject: "You've reached your usage limit",
warningLead: "You're approaching your usage limit.",
reachedLead: "You've reached your usage limit.",
},
storage: {
header: 'Upgrade to scale your storage',
noun: 'storage',
warningSubject: "You're running low on storage",
reachedSubject: "You've reached your storage limit",
warningLead: "You're running low on storage.",
reachedLead: "You've reached your storage limit.",
},
tables: {
header: 'Upgrade to scale your tables',
noun: 'table rows',
warningSubject: "You're running low on table space",
reachedSubject: "You've reached your table limit",
warningLead: "You're running low on table space.",
reachedLead: "You've reached your table limit.",
},
seats: {
header: 'Upgrade to scale with your teammates',
noun: 'seats',
warningSubject: "You're running low on seats",
reachedSubject: "You've used all your seats",
warningLead: "You're running low on seats for your team.",
reachedLead: "You've used all the seats on your plan.",
},
}
/** Type guard for a raw query value against the known reasons. */
export function isUpgradeReason(value: string | null | undefined): value is UpgradeReason {
return value != null && (UPGRADE_REASONS as readonly string[]).includes(value)
}
/**
* Build a link to the workspace upgrade page, optionally tagged with the reason
* that sent the user there so the page can swap its header.
*/
export function buildUpgradeHref(workspaceId: string, reason?: UpgradeReason): string {
const base = `/workspace/${workspaceId}/upgrade`
return reason ? `${base}?${UPGRADE_REASON_PARAM}=${reason}` : base
}
+36
View File
@@ -0,0 +1,36 @@
import Decimal from 'decimal.js'
/**
* Configure Decimal.js for billing precision.
* 20 significant digits is more than enough for currency calculations.
*/
Decimal.set({ precision: 20, rounding: Decimal.ROUND_HALF_UP })
/**
* Parse a value to Decimal for precise billing calculations.
* Handles null, undefined, empty strings, and number/string inputs.
*/
export function toDecimal(value: string | number | null | undefined): Decimal {
if (value === null || value === undefined || value === '') {
return new Decimal(0)
}
return new Decimal(value)
}
/**
* Convert Decimal back to number for storage/API responses.
* Use this at the final step when returning values.
*/
export function toNumber(value: Decimal): number {
return value.toNumber()
}
/**
* Format a Decimal to a fixed string for database storage.
* Uses 6 decimal places which matches current DB precision.
*/
export function toFixedString(value: Decimal, decimalPlaces = 6): string {
return value.toFixed(decimalPlaces)
}
export { Decimal }
@@ -0,0 +1,166 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockFeatureFlags, mockGetOrganizationSubscription, mockHasInflightOutboxEvent } =
vi.hoisted(() => ({
mockFeatureFlags: { isBillingEnabled: false },
mockGetOrganizationSubscription: vi.fn(),
mockHasInflightOutboxEvent: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/core/outbox/service', () => ({
hasInflightOutboxEvent: mockHasInflightOutboxEvent,
}))
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({
OUTBOX_EVENT_TYPES: {
STRIPE_SYNC_SUBSCRIPTION_SEATS: 'stripe.sync-subscription-seats',
},
}))
vi.mock('@/lib/billing/core/billing', () => ({
getOrganizationSubscription: mockGetOrganizationSubscription,
}))
vi.mock('@/lib/billing/plan-helpers', () => ({
isEnterprise: vi.fn().mockReturnValue(false),
isFree: vi.fn().mockReturnValue(false),
isPro: vi.fn().mockReturnValue(false),
}))
vi.mock('@/lib/billing/subscriptions/utils', () => ({
getEffectiveSeats: vi.fn().mockReturnValue(10),
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isBillingEnabled() {
return mockFeatureFlags.isBillingEnabled
},
}))
vi.mock('@/lib/messaging/email/validation', () => ({
quickValidateEmail: vi.fn((email: string) => ({ isValid: email.includes('@') })),
}))
import {
getOrganizationSeatInfo,
syncSeatsFromStripeQuantity,
validateSeatAvailability,
} from '@/lib/billing/validation/seat-management'
/**
* Queues the next N responses for `db.select().from(...).where(...)` calls,
* supporting both `.limit(1)` and directly-awaited `where` chains.
*/
function queueSelectResponses(responses: unknown[][]) {
const queue = [...responses]
dbChainMockFns.where.mockImplementation(() => {
const result = queue.shift() ?? []
const thenable = {
limit: vi.fn(() => Promise.resolve(result)),
orderBy: vi.fn(() => Promise.resolve(result)),
returning: vi.fn(() => Promise.resolve(result)),
groupBy: vi.fn(() => Promise.resolve(result)),
then: (onFulfilled: (rows: unknown) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve(result).then(onFulfilled, onRejected),
}
return thenable as unknown as ReturnType<typeof dbChainMockFns.where>
})
}
describe('getOrganizationSeatInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockFeatureFlags.isBillingEnabled = false
mockGetOrganizationSubscription.mockResolvedValue(null)
})
it('returns unlimited seat info when billing is disabled', async () => {
queueSelectResponses([[{ id: 'org-1', name: 'Acme' }], [{ count: 3 }], [{ count: 2 }]])
const result = await getOrganizationSeatInfo('org-1')
expect(result).toEqual({
organizationId: 'org-1',
organizationName: 'Acme',
currentSeats: 5,
maxSeats: Number.MAX_SAFE_INTEGER,
availableSeats: Number.MAX_SAFE_INTEGER,
subscriptionPlan: 'billing_disabled',
canAddSeats: false,
})
expect(mockGetOrganizationSubscription).not.toHaveBeenCalled()
})
})
describe('validateSeatAvailability', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockFeatureFlags.isBillingEnabled = true
mockGetOrganizationSubscription.mockResolvedValue({
id: 'sub-1',
plan: 'team',
status: 'active',
seats: 10,
})
})
it('uses the internal pending invitation count when checking seats', async () => {
queueSelectResponses([[{ count: 2 }], [{ count: 1 }]])
const result = await validateSeatAvailability('org-1', 1)
expect(result).toMatchObject({
canInvite: true,
currentSeats: 3,
maxSeats: 10,
availableSeats: 7,
})
})
})
describe('syncSeatsFromStripeQuantity', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockHasInflightOutboxEvent.mockResolvedValue(false)
})
it('does nothing when the Stripe quantity already matches the DB', async () => {
const result = await syncSeatsFromStripeQuantity('sub-1', 3, 3)
expect(result).toEqual({ synced: false, previousSeats: 3, newSeats: 3 })
expect(mockHasInflightOutboxEvent).not.toHaveBeenCalled()
expect(dbChainMockFns.set).not.toHaveBeenCalled()
})
it('writes the Stripe quantity to the DB when no seat-sync is in flight', async () => {
mockHasInflightOutboxEvent.mockResolvedValue(false)
const result = await syncSeatsFromStripeQuantity('sub-1', 2, 3)
expect(result).toEqual({ synced: true, previousSeats: 2, newSeats: 3 })
expect(dbChainMockFns.set).toHaveBeenCalledWith({ seats: 3 })
})
it('skips the Stripe-to-DB write while a seat-sync to Stripe is in flight', async () => {
mockHasInflightOutboxEvent.mockResolvedValue(true)
const result = await syncSeatsFromStripeQuantity('sub-1', 2, 3)
expect(result).toEqual({ synced: false, previousSeats: 2, newSeats: 2 })
expect(mockHasInflightOutboxEvent).toHaveBeenCalledWith(
'stripe.sync-subscription-seats',
'subscriptionId',
'sub-1'
)
expect(dbChainMockFns.set).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,399 @@
import { db } from '@sim/db'
import { invitation, member, organization, subscription, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { normalizeEmail } from '@sim/utils/string'
import { and, count, eq, gt, ne } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { isEnterprise, isFree } from '@/lib/billing/plan-helpers'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { hasInflightOutboxEvent } from '@/lib/core/outbox/service'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
const logger = createLogger('SeatManagement')
interface SeatValidationResult {
canInvite: boolean
reason?: string
currentSeats: number
maxSeats: number
availableSeats: number
}
interface OrganizationSeatInfo {
organizationId: string
organizationName: string
currentSeats: number
maxSeats: number
availableSeats: number
subscriptionPlan: string
canAddSeats: boolean
}
interface ValidateSeatOptions {
excludePendingInvitationId?: string
}
export async function validateSeatAvailability(
organizationId: string,
additionalSeats = 1,
options: ValidateSeatOptions = {}
): Promise<SeatValidationResult> {
try {
if (!isBillingEnabled) {
const memberCount = await db
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId))
const currentSeats = memberCount[0]?.count || 0
return {
canInvite: true,
currentSeats,
maxSeats: Number.MAX_SAFE_INTEGER,
availableSeats: Number.MAX_SAFE_INTEGER,
}
}
const subscription = await getOrganizationSubscription(organizationId)
if (!subscription) {
return {
canInvite: false,
reason: 'No active subscription found',
currentSeats: 0,
maxSeats: 0,
availableSeats: 0,
}
}
if (isFree(subscription.plan)) {
return {
canInvite: false,
reason: 'Organization features require a paid plan',
currentSeats: 0,
maxSeats: 0,
availableSeats: 0,
}
}
const [memberCount] = await db
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId))
const pendingFilters = [
eq(invitation.organizationId, organizationId),
eq(invitation.status, 'pending'),
ne(invitation.membershipIntent, 'external'),
gt(invitation.expiresAt, new Date()),
]
if (options.excludePendingInvitationId) {
pendingFilters.push(ne(invitation.id, options.excludePendingInvitationId))
}
const [pendingCount] = await db
.select({ count: count() })
.from(invitation)
.where(and(...pendingFilters))
const currentSeats = (memberCount?.count ?? 0) + (pendingCount?.count ?? 0)
const maxSeats = getEffectiveSeats(subscription)
const availableSeats = Math.max(0, maxSeats - currentSeats)
const canInvite = availableSeats >= additionalSeats
const result: SeatValidationResult = {
canInvite,
currentSeats,
maxSeats,
availableSeats,
}
if (!canInvite) {
if (additionalSeats === 1) {
result.reason = `No available seats. Currently using ${currentSeats} of ${maxSeats} seats.`
} else {
result.reason = `Not enough available seats. Need ${additionalSeats} seats, but only ${availableSeats} available.`
}
}
logger.debug('Seat validation result', {
organizationId,
additionalSeats,
result,
})
return result
} catch (error) {
logger.error('Failed to validate seat availability', { organizationId, additionalSeats, error })
return {
canInvite: false,
reason: 'Failed to check seat availability',
currentSeats: 0,
maxSeats: 0,
availableSeats: 0,
}
}
}
/**
* Get comprehensive seat information for an organization
*/
export async function getOrganizationSeatInfo(
organizationId: string
): Promise<OrganizationSeatInfo | null> {
try {
const organizationData = await db
.select({
id: organization.id,
name: organization.name,
})
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (organizationData.length === 0) {
return null
}
const [memberCountRow] = await db
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId))
const [pendingCountRow] = await db
.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 currentSeats = (memberCountRow?.count ?? 0) + (pendingCountRow?.count ?? 0)
if (!isBillingEnabled) {
return {
organizationId,
organizationName: organizationData[0].name,
currentSeats,
maxSeats: Number.MAX_SAFE_INTEGER,
availableSeats: Number.MAX_SAFE_INTEGER,
subscriptionPlan: 'billing_disabled',
canAddSeats: false,
}
}
const subscription = await getOrganizationSubscription(organizationId)
if (!subscription) {
return null
}
const maxSeats = getEffectiveSeats(subscription)
const canAddSeats = !isEnterprise(subscription.plan)
const availableSeats = Math.max(0, maxSeats - currentSeats)
return {
organizationId,
organizationName: organizationData[0].name,
currentSeats,
maxSeats,
availableSeats,
subscriptionPlan: subscription.plan,
canAddSeats,
}
} catch (error) {
logger.error('Failed to get organization seat info', { organizationId, error })
return null
}
}
/**
* Validate and reserve seats for bulk invitations
*/
export async function validateBulkInvitations(
organizationId: string,
emailList: string[]
): Promise<{
canInviteAll: boolean
validEmails: string[]
duplicateEmails: string[]
existingMembers: string[]
seatsNeeded: number
seatsAvailable: number
validationResult: SeatValidationResult
}> {
try {
const uniqueEmails = [...new Set(emailList)]
const validEmails = uniqueEmails.filter(
(email) => quickValidateEmail(normalizeEmail(email)).isValid
)
const duplicateEmails = emailList.filter((email, index) => emailList.indexOf(email) !== index)
const existingMembers = await db
.select({ userEmail: user.email })
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(eq(member.organizationId, organizationId))
const existingEmails = existingMembers.map((m) => m.userEmail)
const newEmails = validEmails.filter((email) => !existingEmails.includes(email))
const pendingInvitations = await db
.select({ email: invitation.email })
.from(invitation)
.where(
and(
eq(invitation.organizationId, organizationId),
eq(invitation.status, 'pending'),
ne(invitation.membershipIntent, 'external'),
gt(invitation.expiresAt, new Date())
)
)
const pendingEmails = pendingInvitations.map((i) => i.email)
const finalEmailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email))
const seatsNeeded = finalEmailsToInvite.length
const validationResult = await validateSeatAvailability(organizationId, seatsNeeded)
return {
canInviteAll: validationResult.canInvite && finalEmailsToInvite.length > 0,
validEmails: finalEmailsToInvite,
duplicateEmails,
existingMembers: validEmails.filter((email) => existingEmails.includes(email)),
seatsNeeded,
seatsAvailable: validationResult.availableSeats,
validationResult,
}
} catch (error) {
logger.error('Failed to validate bulk invitations', {
organizationId,
emailCount: emailList.length,
error,
})
const validationResult: SeatValidationResult = {
canInvite: false,
reason: 'Validation failed',
currentSeats: 0,
maxSeats: 0,
availableSeats: 0,
}
return {
canInviteAll: false,
validEmails: [],
duplicateEmails: [],
existingMembers: [],
seatsNeeded: 0,
seatsAvailable: 0,
validationResult,
}
}
}
/**
* Get seat usage analytics for an organization
*/
export async function getOrganizationSeatAnalytics(organizationId: string) {
try {
const seatInfo = await getOrganizationSeatInfo(organizationId)
if (!seatInfo) {
return null
}
const utilizationRate =
seatInfo.maxSeats > 0 ? (seatInfo.currentSeats / seatInfo.maxSeats) * 100 : 0
// Member activity analytics (active/inactive counts, memberActivity) were
// derived from userStats.lastActive, which is no longer written. Dropped
// rather than report frozen data; reintroduce with a real activity source.
return {
...seatInfo,
utilizationRate: Math.round(utilizationRate * 100) / 100,
}
} catch (error) {
logger.error('Failed to get organization seat analytics', { organizationId, error })
return null
}
}
/**
* Sync seat count from Stripe subscription quantity. Used by webhook handlers
* to keep the local DB in sync with Stripe. No-ops while a DB→Stripe seat-sync
* is still in flight for the subscription, so a stale webhook never clobbers a
* DB seat change that hasn't been pushed yet (DB is the source of truth).
*/
export async function syncSeatsFromStripeQuantity(
subscriptionId: string,
currentSeats: number | null,
stripeQuantity: number
): Promise<{ synced: boolean; previousSeats: number | null; newSeats: number }> {
const effectiveCurrentSeats = currentSeats ?? 0
// Only update if quantity differs
if (stripeQuantity === effectiveCurrentSeats) {
return {
synced: false,
previousSeats: effectiveCurrentSeats,
newSeats: stripeQuantity,
}
}
// The DB is the source of truth for Team seats; we push the DB value to
// Stripe asynchronously via the seat-sync outbox. While that sync is still in
// flight the DB is intentionally ahead of Stripe, so skip this Stripe-to-DB
// sync — otherwise a stale customer.subscription.updated webhook could clobber
// the not-yet-pushed value (e.g. revert a just-removed member's seat).
const seatSyncInFlight = await hasInflightOutboxEvent(
OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS,
'subscriptionId',
subscriptionId
)
if (seatSyncInFlight) {
logger.info('Skipping Stripe seat sync; a seat-sync to Stripe is still in flight', {
subscriptionId,
stripeQuantity,
currentSeats: effectiveCurrentSeats,
})
return {
synced: false,
previousSeats: effectiveCurrentSeats,
newSeats: effectiveCurrentSeats,
}
}
try {
await db
.update(subscription)
.set({ seats: stripeQuantity })
.where(eq(subscription.id, subscriptionId))
logger.info('Synced seat count from Stripe', {
subscriptionId,
previousSeats: effectiveCurrentSeats,
newSeats: stripeQuantity,
})
return {
synced: true,
previousSeats: effectiveCurrentSeats,
newSeats: stripeQuantity,
}
} catch (error) {
logger.error('Failed to sync seat count from Stripe', {
subscriptionId,
stripeQuantity,
error,
})
throw error
}
}
+59
View File
@@ -0,0 +1,59 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getEmailSubject, renderAbandonedCheckoutEmail } from '@/components/emails'
import { isProPlan } from '@/lib/billing/core/subscription'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getHelpEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
const logger = createLogger('CheckoutWebhooks')
/**
* Handles checkout.session.expired — fires when a user starts an upgrade but doesn't complete it.
* Sends a plain personal email to check in and offer help.
* Only fires for subscription-mode sessions to avoid misfires on credit purchase or setup sessions.
* Skips users who have already completed a subscription (session may expire after a successful upgrade).
*/
export async function handleAbandonedCheckout(event: Stripe.Event): Promise<void> {
const session = event.data.object as Stripe.Checkout.Session
if (session.mode !== 'subscription') return
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id
if (!customerId) {
logger.warn('No customer ID on expired session', { sessionId: session.id })
return
}
const [userData] = await db
.select({ id: user.id, email: user.email, name: user.name })
.from(user)
.where(eq(user.stripeCustomerId, customerId))
.limit(1)
if (!userData?.email) {
logger.warn('No user found for Stripe customer', { customerId, sessionId: session.id })
return
}
// Skip if the user already has a paid plan (direct or via org) — covers session expiring after a successful upgrade
const alreadySubscribed = await isProPlan(userData.id)
if (alreadySubscribed) return
const { from } = getPersonalEmailFrom()
const replyTo = getHelpEmailAddress()
const html = await renderAbandonedCheckoutEmail(userData.name || undefined)
await sendEmail({
to: userData.email,
subject: getEmailSubject('abandoned-checkout'),
html,
from,
replyTo,
emailType: 'notifications',
})
logger.info('Sent abandoned checkout email', { userId: userData.id, sessionId: session.id })
}
+226
View File
@@ -0,0 +1,226 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('DisputeWebhooks')
async function getCustomerIdFromDispute(dispute: Stripe.Dispute): Promise<string | null> {
const chargeId = typeof dispute.charge === 'string' ? dispute.charge : dispute.charge?.id
if (!chargeId) return null
const stripe = requireStripeClient()
const charge = await stripe.charges.retrieve(chargeId)
return typeof charge.customer === 'string' ? charge.customer : (charge.customer?.id ?? null)
}
async function getOrganizationOwnerId(organizationId: string): Promise<string | null> {
try {
const rows = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
.limit(1)
return rows[0]?.userId ?? null
} catch (error) {
logger.warn('Failed to resolve organization owner for dispute audit', { organizationId, error })
return null
}
}
/**
* Record audit + PostHog instrumentation for a charge dispute money event.
* `actorId` must be the responsible user (org owner for org-scoped charges).
*/
function recordDisputeInstrumentation(
status: 'opened' | 'closed',
dispute: Stripe.Dispute,
customerId: string,
actorId: string,
entity: { type: 'user' | 'organization'; id: string }
): void {
const amount = dispute.amount / 100
recordAudit({
actorId,
action:
status === 'opened' ? AuditAction.CHARGE_DISPUTE_OPENED : AuditAction.CHARGE_DISPUTE_CLOSED,
resourceType: AuditResourceType.BILLING,
resourceId: dispute.id,
description: `Charge dispute ${status} for $${amount.toFixed(2)} (${dispute.reason})`,
metadata: {
entityType: entity.type,
entityId: entity.id,
...(entity.type === 'organization' ? { organizationId: entity.id } : {}),
customerId,
amount,
currency: dispute.currency,
reason: dispute.reason,
status: dispute.status,
},
})
captureServerEvent(actorId, 'charge_disputed', {
amount,
currency: dispute.currency,
reason: dispute.reason,
status,
entity_type: entity.type,
reference_id: entity.id,
})
}
/**
* Handles charge.dispute.created - blocks the responsible user
*/
export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
const dispute = event.data.object as Stripe.Dispute
await stripeWebhookIdempotency.executeWithIdempotency(
'charge-dispute-opened',
event.id,
async () => {
const customerId = await getCustomerIdFromDispute(dispute)
if (!customerId) {
logger.warn('No customer ID found in dispute', { disputeId: dispute.id })
return
}
// Find user by stripeCustomerId (Pro plans)
const users = await db
.select({ id: user.id })
.from(user)
.where(eq(user.stripeCustomerId, customerId))
.limit(1)
if (users.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
.where(eq(userStats.userId, users[0].id))
logger.warn('Blocked user due to dispute', {
disputeId: dispute.id,
userId: users[0].id,
})
recordDisputeInstrumentation('opened', dispute, customerId, users[0].id, {
type: 'user',
id: users[0].id,
})
return
}
// Find subscription by stripeCustomerId (Team/Enterprise)
const subs = await db
.select({ referenceId: subscription.referenceId })
.from(subscription)
.where(eq(subscription.stripeCustomerId, customerId))
.limit(1)
if (subs.length > 0) {
const orgId = subs[0].referenceId
const memberCount = await blockOrgMembers(orgId, 'dispute')
if (memberCount > 0) {
logger.warn('Blocked all org members due to dispute', {
disputeId: dispute.id,
organizationId: orgId,
memberCount,
})
}
const actorId = (await getOrganizationOwnerId(orgId)) ?? orgId
recordDisputeInstrumentation('opened', dispute, customerId, actorId, {
type: 'organization',
id: orgId,
})
}
}
)
}
/**
* Handles charge.dispute.closed - unblocks user if dispute was won or warning closed
*
* Status meanings:
* - 'won': Merchant won, customer's chargeback denied → unblock
* - 'lost': Customer won, money refunded → stay blocked (they owe us)
* - 'warning_closed': Pre-dispute inquiry closed without chargeback → unblock (false alarm)
*/
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
const dispute = event.data.object as Stripe.Dispute
await stripeWebhookIdempotency.executeWithIdempotency(
'charge-dispute-closed',
event.id,
async () => {
const customerId = await getCustomerIdFromDispute(dispute)
if (!customerId) {
return
}
// Unblock only on won/warning_closed; a 'lost' dispute stays blocked. The close
// is audited in every case (dispute.status in metadata distinguishes the outcome).
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
const users = await db
.select({ id: user.id })
.from(user)
.where(eq(user.stripeCustomerId, customerId))
.limit(1)
if (users.length > 0) {
if (shouldUnblock) {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(
and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute'))
)
}
logger.info('Dispute closed for user', {
disputeId: dispute.id,
userId: users[0].id,
status: dispute.status,
unblocked: shouldUnblock,
})
recordDisputeInstrumentation('closed', dispute, customerId, users[0].id, {
type: 'user',
id: users[0].id,
})
return
}
const subs = await db
.select({ referenceId: subscription.referenceId })
.from(subscription)
.where(eq(subscription.stripeCustomerId, customerId))
.limit(1)
if (subs.length > 0) {
const orgId = subs[0].referenceId
if (shouldUnblock) {
await unblockOrgMembers(orgId, 'dispute')
}
logger.info('Dispute closed for organization', {
disputeId: dispute.id,
organizationId: orgId,
status: dispute.status,
unblocked: shouldUnblock,
})
const actorId = (await getOrganizationOwnerId(orgId)) ?? orgId
recordDisputeInstrumentation('closed', dispute, customerId, actorId, {
type: 'organization',
id: orgId,
})
}
}
)
}
+263
View File
@@ -0,0 +1,263 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { organization, subscription, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getEmailSubject, renderEnterpriseSubscriptionEmail } from '@/components/emails'
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { captureServerEvent } from '@/lib/posthog/server'
import { parseEnterpriseSubscriptionMetadata } from '../types'
const logger = createLogger('BillingEnterprise')
export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
const stripeSubscription = event.data.object as Stripe.Subscription
const metaPlan = (stripeSubscription.metadata?.plan as string | undefined)?.toLowerCase() || ''
if (metaPlan !== 'enterprise') {
logger.info('[subscription.created] Skipping non-enterprise subscription', {
subscriptionId: stripeSubscription.id,
plan: metaPlan || 'unknown',
})
return
}
const stripeCustomerId = stripeSubscription.customer as string
if (!stripeCustomerId) {
logger.error('[subscription.created] Missing Stripe customer ID', {
subscriptionId: stripeSubscription.id,
})
throw new Error('Missing Stripe customer ID on subscription')
}
const metadata = stripeSubscription.metadata || {}
const referenceId =
typeof metadata.referenceId === 'string' && metadata.referenceId.length > 0
? metadata.referenceId
: null
if (!referenceId) {
logger.error('[subscription.created] Unable to resolve referenceId', {
subscriptionId: stripeSubscription.id,
stripeCustomerId,
})
throw new Error('Unable to resolve referenceId for subscription')
}
const enterpriseMetadata = parseEnterpriseSubscriptionMetadata(metadata)
if (!enterpriseMetadata) {
logger.error('[subscription.created] Invalid enterprise metadata shape', {
subscriptionId: stripeSubscription.id,
metadata,
})
throw new Error('Invalid enterprise metadata for subscription')
}
const { seats, monthlyPrice } = enterpriseMetadata
// Get the first subscription item which contains the period information
const referenceItem = stripeSubscription.items?.data?.[0]
const subscriptionRow = {
id: generateId(),
plan: 'enterprise',
referenceId,
stripeCustomerId,
stripeSubscriptionId: stripeSubscription.id,
status: stripeSubscription.status || null,
periodStart: referenceItem?.current_period_start
? new Date(referenceItem.current_period_start * 1000)
: null,
periodEnd: referenceItem?.current_period_end
? new Date(referenceItem.current_period_end * 1000)
: null,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null,
seats: 1, // Enterprise uses metadata.seats for actual seat count, column is always 1
trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
: null,
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
metadata: metadata as Record<string, unknown>,
}
const existing = await db
.select({ id: subscription.id })
.from(subscription)
.where(eq(subscription.stripeSubscriptionId, stripeSubscription.id))
.limit(1)
if (existing.length > 0) {
await db
.update(subscription)
.set({
plan: subscriptionRow.plan,
referenceId: subscriptionRow.referenceId,
stripeCustomerId: subscriptionRow.stripeCustomerId,
status: subscriptionRow.status,
periodStart: subscriptionRow.periodStart,
periodEnd: subscriptionRow.periodEnd,
cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd,
seats: 1, // Enterprise uses metadata.seats for actual seat count, column is always 1
trialStart: subscriptionRow.trialStart,
trialEnd: subscriptionRow.trialEnd,
metadata: subscriptionRow.metadata,
})
.where(eq(subscription.stripeSubscriptionId, stripeSubscription.id))
} else {
await db.insert(subscription).values(subscriptionRow)
}
// Update the organization's usage limit to match the monthly price
// The referenceId for enterprise plans is the organization ID
try {
await db
.update(organization)
.set({
orgUsageLimit: monthlyPrice.toFixed(2),
updatedAt: new Date(),
})
.where(eq(organization.id, referenceId))
logger.info('[subscription.created] Updated organization usage limit', {
organizationId: referenceId,
usageLimit: monthlyPrice,
})
} catch (error) {
logger.error('[subscription.created] Failed to update organization usage limit', {
organizationId: referenceId,
usageLimit: monthlyPrice,
error,
})
// Don't throw - the subscription was created successfully, just log the error
}
logger.info('[subscription.created] Upserted enterprise subscription', {
subscriptionId: subscriptionRow.id,
referenceId: subscriptionRow.referenceId,
plan: subscriptionRow.plan,
status: subscriptionRow.status,
monthlyPrice,
seats,
note: 'Seats from metadata, Stripe quantity set to 1',
})
let actorId = referenceId
try {
const [provisioningUser] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.stripeCustomerId, stripeCustomerId))
.limit(1)
actorId = provisioningUser?.id ?? referenceId
} catch (error) {
logger.warn('Failed to resolve enterprise provisioning actor; falling back to reference id', {
referenceId,
error,
})
}
// Dedupe against Stripe redelivery — the upsert above is idempotent, but
// recordAudit/captureServerEvent are pure appends and would double-record.
await stripeWebhookIdempotency.executeWithIdempotency(
'enterprise-subscription-provisioned',
event.id,
async () => {
recordAudit({
actorId,
action: AuditAction.ENTERPRISE_SUBSCRIPTION_PROVISIONED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: subscriptionRow.id,
description: `Enterprise subscription provisioned for organization ${referenceId} (${seats} seats)`,
metadata: {
organizationId: referenceId,
stripeCustomerId,
stripeSubscriptionId: stripeSubscription.id,
seats,
monthlyPrice,
currency: 'usd',
},
})
captureServerEvent(actorId, 'enterprise_subscription_created', {
reference_id: referenceId,
seats,
monthly_price: monthlyPrice,
currency: 'usd',
})
}
)
try {
const userDetails = await db
.select({
id: user.id,
name: user.name,
email: user.email,
})
.from(user)
.where(eq(user.stripeCustomerId, stripeCustomerId))
.limit(1)
const orgDetails = await db
.select({
id: organization.id,
name: organization.name,
})
.from(organization)
.where(eq(organization.id, referenceId))
.limit(1)
if (userDetails.length > 0 && orgDetails.length > 0) {
const user = userDetails[0]
const org = orgDetails[0]
const html = await renderEnterpriseSubscriptionEmail(user.name || user.email)
const emailResult = await sendEmail({
to: user.email,
subject: getEmailSubject('enterprise-subscription'),
html,
from: getFromEmailAddress(),
emailType: 'transactional',
})
if (emailResult.success) {
logger.info('[subscription.created] Enterprise subscription email sent successfully', {
userId: user.id,
email: user.email,
organizationId: org.id,
subscriptionId: subscriptionRow.id,
})
} else {
logger.warn('[subscription.created] Failed to send enterprise subscription email', {
userId: user.id,
email: user.email,
error: emailResult.message,
})
}
} else {
logger.warn(
'[subscription.created] Could not find user or organization for email notification',
{
userFound: userDetails.length > 0,
orgFound: orgDetails.length > 0,
stripeCustomerId,
referenceId,
}
)
}
} catch (emailError) {
logger.error('[subscription.created] Error sending enterprise subscription email', {
error: emailError,
stripeCustomerId,
referenceId,
subscriptionId: subscriptionRow.id,
})
}
}
@@ -0,0 +1,39 @@
import { IdempotencyService } from '@/lib/core/idempotency/service'
/**
* Idempotency service for Stripe webhook handlers.
*
* Stripe delivers webhook events at-least-once and retries failed
* deliveries for up to 3 days. Handlers that perform non-idempotent work
* (crediting accounts, removing credits, resetting usage trackers, etc.)
* must be wrapped in a claim so duplicate deliveries are collapsed to a
* single execution.
*
* Storage is **forced to Postgres** regardless of whether Redis is
* configured. Billing handlers mutate `user_stats` / `organization` /
* `subscription` rows via DB transactions — keeping the idempotency
* record in the same Postgres closes the narrow window where the
* operation commits but a Redis `storeResult` fails, which would cause
* Stripe's next retry to re-run the money-affecting work. The latency
* cost (15 ms per claim/store) is invisible on webhook responses, and
* volume is low enough (roughly one event per customer per billing
* cycle) that DB storage scales comfortably.
*
* `retryFailures: true` means a thrown handler releases the claim so
* Stripe's next retry runs from scratch — without it, one transient
* failure would poison the key for the whole TTL window.
*
* TTL of 7 days is slightly longer than Stripe's 3-day retry horizon so
* late retries still dedupe against completed work. Rows past their TTL
* are handled two ways: `atomicallyClaimDb` reclaims stale rows inline
* via `ON CONFLICT DO UPDATE WHERE created_at < expired_before` (so
* correctness does not depend on cleanup running), and the external
* cleanup cron (scheduled from the infra repo) hits
* `/api/webhooks/cleanup/idempotency` to bound table size.
*/
export const stripeWebhookIdempotency = new IdempotencyService({
namespace: 'stripe-webhook',
ttlSeconds: 60 * 60 * 24 * 7,
retryFailures: true,
forceStorage: 'database',
})
@@ -0,0 +1,323 @@
/**
* @vitest-environment node
*/
import {
createMockStripeEvent,
dbChainMock,
dbChainMockFns,
drizzleOrmMock,
resetDbChainMock,
stripeClientMock,
stripePaymentMethodMock,
urlsMock,
urlsMockFns,
} from '@sim/testing'
import type Stripe from 'stripe'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockBlockOrgMembers, mockUnblockOrgMembers } = vi.hoisted(() => ({
mockBlockOrgMembers: vi.fn(),
mockUnblockOrgMembers: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('drizzle-orm', () => drizzleOrmMock)
vi.mock('@/components/emails', () => ({
PaymentFailedEmail: vi.fn(),
getEmailSubject: vi.fn(),
renderCreditPurchaseEmail: vi.fn(),
}))
vi.mock('@/lib/billing/core/billing', () => ({
calculateSubscriptionOverage: vi.fn(),
isSubscriptionOrgScoped: vi.fn().mockResolvedValue(true),
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
getBillingPeriodUsageCostByUser: vi.fn().mockResolvedValue(new Map()),
}))
vi.mock('@/lib/billing/credits/balance', () => ({
addCredits: vi.fn(),
getCreditBalance: vi.fn(),
removeCredits: vi.fn(),
}))
vi.mock('@/lib/billing/credits/purchase', () => ({
setUsageLimitForCredits: vi.fn(),
}))
vi.mock('@/lib/billing/organizations/membership', () => ({
blockOrgMembers: mockBlockOrgMembers,
unblockOrgMembers: mockUnblockOrgMembers,
}))
vi.mock('@/lib/billing/plan-helpers', () => ({
isEnterprise: vi.fn(() => false),
isOrgPlan: vi.fn((plan: string | null | undefined) => Boolean(plan?.startsWith('team'))),
isTeam: vi.fn((plan: string | null | undefined) => Boolean(plan?.startsWith('team'))),
}))
vi.mock('@/lib/billing/stripe-client', () => stripeClientMock)
vi.mock('@/lib/billing/stripe-payment-method', () => stripePaymentMethodMock)
vi.mock('@/lib/billing/subscriptions/utils', () => ({
ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'trialing', 'past_due'],
}))
vi.mock('@/lib/billing/utils/decimal', () => ({
toDecimal: vi.fn((v: string | number | null | undefined) => {
if (v === null || v === undefined || v === '') return { toNumber: () => 0 }
return { toNumber: () => Number(v) }
}),
toNumber: vi.fn((d: { toNumber: () => number }) => d.toNumber()),
}))
vi.mock('@/lib/billing/webhooks/idempotency', () => ({
stripeWebhookIdempotency: {
executeWithIdempotency: vi.fn(
async (_provider: string, _identifier: string, operation: () => Promise<unknown>) =>
operation()
),
},
}))
vi.mock('@/lib/core/utils/urls', () => urlsMock)
vi.mock('@/lib/messaging/email/mailer', () => ({
sendEmail: vi.fn(),
}))
vi.mock('@/lib/messaging/email/utils', () => ({
getPersonalEmailFrom: vi.fn(() => ({
from: 'billing@sim.test',
replyTo: 'support@sim.test',
})),
getHelpEmailAddress: vi.fn(() => 'help@sim.test'),
}))
vi.mock('@/lib/messaging/email/validation', () => ({
quickValidateEmail: vi.fn(() => ({ isValid: true })),
}))
vi.mock('@react-email/render', () => ({
render: vi.fn(),
}))
import {
handleInvoicePaymentFailed,
handleInvoicePaymentSucceeded,
resetUsageForSubscription,
} from '@/lib/billing/webhooks/invoices'
import { sendEmail } from '@/lib/messaging/email/mailer'
interface SelectResponse {
limitResult?: unknown
whereResult?: unknown
}
const selectResponses: SelectResponse[] = []
function queueSelectResponse(response: SelectResponse) {
selectResponses.push(response)
}
/**
* Override `where` so that each select-then-where chain pops the next queued
* response. Supports both `.limit(1)` terminals and directly-awaited `where()`.
*/
function installSelectResponseQueue() {
dbChainMockFns.where.mockImplementation(() => {
const next = selectResponses.shift()
if (!next) {
throw new Error('No queued db.select response')
}
const builder = {
for: vi.fn(() => builder),
limit: vi.fn(async () => next.limitResult ?? next.whereResult ?? []),
orderBy: vi.fn(async () => next.limitResult ?? next.whereResult ?? []),
returning: vi.fn(async () => next.limitResult ?? next.whereResult ?? []),
groupBy: vi.fn(async () => next.limitResult ?? next.whereResult ?? []),
then: (resolve: (value: unknown) => unknown, reject?: (reason: unknown) => unknown) =>
Promise.resolve(next.whereResult ?? next.limitResult ?? []).then(resolve, reject),
}
return builder as unknown as ReturnType<typeof dbChainMockFns.where>
})
}
function createInvoiceEvent(
type: 'invoice.payment_failed' | 'invoice.payment_succeeded',
invoice: Partial<Stripe.Invoice>
): Stripe.Event {
return createMockStripeEvent(type, invoice)
}
describe('invoice billing recovery', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
selectResponses.length = 0
installSelectResponseQueue()
urlsMockFns.mockGetBaseUrl.mockReturnValue('https://sim.test')
mockBlockOrgMembers.mockResolvedValue(2)
mockUnblockOrgMembers.mockResolvedValue(2)
})
it('blocks org members when a metadata-backed invoice payment fails', async () => {
queueSelectResponse({
limitResult: [
{
id: 'sub-db-1',
plan: 'team_8000',
referenceId: 'org-1',
stripeSubscriptionId: 'sub_stripe_1',
},
],
})
await handleInvoicePaymentFailed(
createInvoiceEvent('invoice.payment_failed', {
amount_due: 3582,
attempt_count: 2,
customer: 'cus_123',
customer_email: 'owner@sim.test',
hosted_invoice_url: 'https://stripe.test/invoices/in_123',
id: 'in_123',
metadata: {
billingPeriod: '2026-04',
subscriptionId: 'sub_stripe_1',
type: 'overage_threshold_billing_org',
},
})
)
expect(mockBlockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed')
expect(mockUnblockOrgMembers).not.toHaveBeenCalled()
})
it('sends the payment-failure email with the shared help inbox as reply-to', async () => {
queueSelectResponse({
limitResult: [
{
id: 'sub-db-1',
plan: 'team_8000',
referenceId: 'org-1',
stripeSubscriptionId: 'sub_stripe_1',
},
],
})
queueSelectResponse({ limitResult: [{ userId: 'owner-1' }] }) // resolveBillingActorId owner lookup (payment_failed instrumentation)
queueSelectResponse({
whereResult: [{ userId: 'owner-1', role: 'owner' }],
})
queueSelectResponse({
whereResult: [{ email: 'owner@sim.test', name: 'Owner' }],
})
await handleInvoicePaymentFailed(
createInvoiceEvent('invoice.payment_failed', {
amount_due: 3582,
attempt_count: 1,
customer: 'cus_123',
customer_email: 'owner@sim.test',
hosted_invoice_url: 'https://stripe.test/invoices/in_123',
id: 'in_123',
metadata: {
billingPeriod: '2026-04',
subscriptionId: 'sub_stripe_1',
type: 'overage_threshold_billing_org',
},
})
)
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'owner@sim.test',
from: 'billing@sim.test',
replyTo: 'help@sim.test',
})
)
})
it('unblocks org members when the matching metadata-backed invoice payment succeeds', async () => {
queueSelectResponse({
limitResult: [
{
id: 'sub-db-1',
plan: 'team_8000',
referenceId: 'org-1',
stripeSubscriptionId: 'sub_stripe_1',
},
],
})
queueSelectResponse({
whereResult: [{ userId: 'owner-1' }, { userId: 'member-1' }],
})
queueSelectResponse({
whereResult: [{ blocked: false }, { blocked: false }],
})
await handleInvoicePaymentSucceeded(
createInvoiceEvent('invoice.payment_succeeded', {
amount_paid: 3582,
billing_reason: 'manual',
customer: 'cus_123',
id: 'in_123',
metadata: {
billingPeriod: '2026-04',
subscriptionId: 'sub_stripe_1',
type: 'overage_threshold_billing_org',
},
})
)
expect(mockUnblockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed')
expect(mockBlockOrgMembers).not.toHaveBeenCalled()
})
it('locks member userStats before the organization row during usage reset', async () => {
queueSelectResponse({ limitResult: [{ userId: 'owner-1' }] }) // owner member row
queueSelectResponse({ limitResult: [{ userId: 'owner-1' }] }) // owner userStats
queueSelectResponse({ whereResult: [{ userId: 'owner-1' }, { userId: 'member-1' }] }) // member ids
queueSelectResponse({ whereResult: [] }) // all-member userStats FOR UPDATE (pre-org lock)
queueSelectResponse({ limitResult: [{ id: 'org-1' }] }) // organization
queueSelectResponse({
whereResult: [
{ userId: 'owner-1', current: '125', currentCopilot: '10' },
{ userId: 'member-1', current: '75', currentCopilot: '5' },
],
})
queueSelectResponse({ whereResult: [] })
queueSelectResponse({ whereResult: [] })
await resetUsageForSubscription({ plan: 'team', referenceId: 'org-1' })
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.update).toHaveBeenCalledTimes(2)
const whereArgs = dbChainMockFns.where.mock.calls.map(
(call) => call[0] as { type?: string; column?: string; left?: string }
)
const allMemberStatsLockIndex = whereArgs.findIndex(
(arg) => arg?.type === 'inArray' && arg?.column === 'userId'
)
const orgLockIndex = whereArgs.findIndex((arg) => arg?.type === 'eq' && arg?.left === 'id')
expect(allMemberStatsLockIndex).toBeGreaterThanOrEqual(0)
expect(orgLockIndex).toBeGreaterThanOrEqual(0)
expect(allMemberStatsLockIndex).toBeLessThan(orgLockIndex)
const statsReset = dbChainMockFns.set.mock.calls[0][0] as Record<string, unknown>
expect(statsReset.currentPeriodCost).not.toBe('0')
expect(statsReset.currentPeriodCopilotCost).not.toBe('0')
expect(statsReset.lastPeriodCost).toMatchObject({
toSQL: expect.any(Function),
})
expect((statsReset.lastPeriodCost as { toSQL: () => { sql: string } }).toSQL().sql).toContain(
'CASE'
)
expect(
(statsReset.currentPeriodCost as { toSQL: () => { sql: string } }).toSQL().sql
).toContain('GREATEST')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetPlanByName, mockResolveDefaultPaymentMethod, queryQueue, stripeMock } = vi.hoisted(
() => {
const stripeMock = {
subscriptions: {
retrieve: vi.fn(),
update: vi.fn(),
},
}
return {
mockGetPlanByName: vi.fn(),
mockResolveDefaultPaymentMethod: vi.fn(),
queryQueue: { value: [] as unknown[][] },
stripeMock,
}
}
)
vi.mock('@sim/db', () => {
const makeChain = () => {
const chain: Record<string, unknown> = {}
chain.from = () => chain
chain.where = () => chain
chain.limit = () => Promise.resolve(queryQueue.value.shift() ?? [])
return chain
}
return { db: { select: () => makeChain() } }
})
vi.mock('@/lib/billing/stripe-client', () => ({
requireStripeClient: () => stripeMock,
}))
vi.mock('@/lib/billing/plans', () => ({
getPlanByName: mockGetPlanByName,
}))
vi.mock('@/lib/billing/stripe-payment-method', () => ({
resolveDefaultPaymentMethod: mockResolveDefaultPaymentMethod,
}))
import { billingOutboxHandlers, OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
const seatSyncHandler = billingOutboxHandlers[OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS]
const ctx = {
eventId: 'evt-1',
eventType: OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS,
attempts: 0,
}
function stripeItem(overrides: {
quantity?: number
priceId?: string
interval?: 'month' | 'year'
}) {
return {
status: 'active',
items: {
data: [
{
id: 'si_1',
quantity: overrides.quantity ?? 1,
price: {
id: overrides.priceId ?? 'price_pro_month',
recurring: { interval: overrides.interval ?? 'month' },
},
},
],
},
}
}
describe('stripeSyncSubscriptionSeats outbox handler', () => {
beforeEach(() => {
vi.clearAllMocks()
queryQueue.value = []
mockGetPlanByName.mockReturnValue({
priceId: 'price_team_month',
annualDiscountPriceId: 'price_team_year',
})
stripeMock.subscriptions.update.mockResolvedValue({})
})
it('reconciles both price and quantity for a Pro→Team conversion', async () => {
const row = {
plan: 'team_6000',
seats: 2,
status: 'active',
stripeSubscriptionId: 'stripe_sub',
}
queryQueue.value = [[row], [row]]
stripeMock.subscriptions.retrieve.mockResolvedValue(
stripeItem({ quantity: 1, priceId: 'price_pro_month' })
)
await seatSyncHandler({ subscriptionId: 'sub-1' }, ctx)
expect(stripeMock.subscriptions.update).toHaveBeenCalledWith(
'stripe_sub',
expect.objectContaining({
items: [{ id: 'si_1', quantity: 2, price: 'price_team_month' }],
proration_behavior: 'always_invoice',
}),
expect.any(Object)
)
})
it('uses the annual price when the subscription bills yearly', async () => {
const row = {
plan: 'team_6000',
seats: 2,
status: 'active',
stripeSubscriptionId: 'stripe_sub',
}
queryQueue.value = [[row], [row]]
stripeMock.subscriptions.retrieve.mockResolvedValue(
stripeItem({ quantity: 1, priceId: 'price_pro_year', interval: 'year' })
)
await seatSyncHandler({ subscriptionId: 'sub-1' }, ctx)
expect(stripeMock.subscriptions.update).toHaveBeenCalledWith(
'stripe_sub',
expect.objectContaining({
items: [{ id: 'si_1', quantity: 2, price: 'price_team_year' }],
}),
expect.any(Object)
)
})
it('adjusts quantity only when the price already matches', async () => {
const row = {
plan: 'team_6000',
seats: 3,
status: 'active',
stripeSubscriptionId: 'stripe_sub',
}
queryQueue.value = [[row], [row]]
stripeMock.subscriptions.retrieve.mockResolvedValue(
stripeItem({ quantity: 2, priceId: 'price_team_month' })
)
await seatSyncHandler({ subscriptionId: 'sub-1' }, ctx)
const updateArg = stripeMock.subscriptions.update.mock.calls[0][1] as {
items: Array<{ price?: string; quantity: number }>
}
expect(updateArg.items[0].quantity).toBe(3)
expect(updateArg.items[0].price).toBeUndefined()
})
it('does nothing when price and quantity are already in sync', async () => {
const row = {
plan: 'team_6000',
seats: 2,
status: 'active',
stripeSubscriptionId: 'stripe_sub',
}
queryQueue.value = [[row], [row]]
stripeMock.subscriptions.retrieve.mockResolvedValue(
stripeItem({ quantity: 2, priceId: 'price_team_month' })
)
await seatSyncHandler({ subscriptionId: 'sub-1' }, ctx)
expect(stripeMock.subscriptions.update).not.toHaveBeenCalled()
})
it('skips non-Team subscriptions', async () => {
queryQueue.value = [
[{ plan: 'pro_6000', seats: 1, status: 'active', stripeSubscriptionId: 's' }],
]
await seatSyncHandler({ subscriptionId: 'sub-1' }, ctx)
expect(stripeMock.subscriptions.retrieve).not.toHaveBeenCalled()
expect(stripeMock.subscriptions.update).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,405 @@
import { db } from '@sim/db'
import { member, subscription as subscriptionTable, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq } from 'drizzle-orm'
import { isTeam } from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method'
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import type { OutboxHandler } from '@/lib/core/outbox/service'
const logger = createLogger('BillingOutboxHandlers')
export const OUTBOX_EVENT_TYPES = {
/**
* Sync a subscription's `cancel_at_period_end` flag from our DB to
* Stripe. The handler reads the current DB value at processing time
* — so rapid cancel→uncancel→cancel sequences always converge on
* the last-committed DB state regardless of outbox ordering. Callers
* enqueue this event after every DB change to `cancelAtPeriodEnd`.
*/
STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end',
/**
* Sync a Team subscription's price and seat quantity from our DB to
* Stripe. The handler reads the current DB plan + seats at processing
* time and reconciles the Stripe item's price (e.g. after a Pro→Team
* conversion) and quantity, charging the proration via `always_invoice`.
* A failed charge surfaces through Stripe dunning and the existing
* billing-blocked system, never under the synchronous accept path.
*/
STRIPE_SYNC_SUBSCRIPTION_SEATS: 'stripe.sync-subscription-seats',
STRIPE_THRESHOLD_OVERAGE_INVOICE: 'stripe.threshold-overage-invoice',
STRIPE_SYNC_CUSTOMER_CONTACT: 'stripe.sync-customer-contact',
} as const
interface StripeSyncCancelAtPeriodEndPayload {
stripeSubscriptionId: string
/** The DB subscription row id — also our source-of-truth pointer. */
subscriptionId: string
/** Optional: reason this was enqueued — e.g. 'member-joined-paid-org'. */
reason?: string
}
interface StripeSyncSubscriptionSeatsPayload {
/** The DB subscription row id — the handler reads current seats from this row. */
subscriptionId: string
reason?: string
}
interface StripeSyncCustomerContactPayload {
/** The DB subscription row id — handler resolves current owner/contact at processing time. */
subscriptionId: string
reason?: string
}
interface StripeThresholdOverageInvoicePayload {
customerId: string
stripeSubscriptionId: string
amountCents: number
description: string
itemDescription: string
billingPeriod: string
/** Stripe idempotency key stem — we append the outbox event id for per-retry safety. */
invoiceIdemKeyStem: string
itemIdemKeyStem: string
metadata?: Record<string, string>
}
async function getSubscriptionSeatSyncState(subscriptionId: string) {
const [row] = await db
.select({
plan: subscriptionTable.plan,
seats: subscriptionTable.seats,
status: subscriptionTable.status,
stripeSubscriptionId: subscriptionTable.stripeSubscriptionId,
})
.from(subscriptionTable)
.where(eq(subscriptionTable.id, subscriptionId))
.limit(1)
return row ?? null
}
const stripeSyncCancelAtPeriodEnd: OutboxHandler<StripeSyncCancelAtPeriodEndPayload> = async (
payload,
ctx
) => {
// Read the DB value at processing time (not at enqueue time). This
// makes the handler idempotent across racing enqueues: multiple
// events for the same subscription all push whatever the DB
// currently says, converging on the last committed value.
const rows = await db
.select({ cancelAtPeriodEnd: subscriptionTable.cancelAtPeriodEnd })
.from(subscriptionTable)
.where(eq(subscriptionTable.id, payload.subscriptionId))
.limit(1)
if (rows.length === 0) {
logger.warn('Subscription not found when syncing cancel_at_period_end', {
subscriptionId: payload.subscriptionId,
})
return
}
const desiredValue = Boolean(rows[0].cancelAtPeriodEnd)
const stripe = requireStripeClient()
await stripe.subscriptions.update(
payload.stripeSubscriptionId,
{ cancel_at_period_end: desiredValue },
{ idempotencyKey: `outbox:${ctx.eventId}` }
)
logger.info('Synced cancel_at_period_end from DB to Stripe', {
eventId: ctx.eventId,
stripeSubscriptionId: payload.stripeSubscriptionId,
subscriptionId: payload.subscriptionId,
desiredValue,
reason: payload.reason,
})
}
const stripeSyncSubscriptionSeats: OutboxHandler<StripeSyncSubscriptionSeatsPayload> = async (
payload,
ctx
) => {
const stripe = requireStripeClient()
const maxSyncAttempts = 2
for (let attempt = 1; attempt <= maxSyncAttempts; attempt++) {
const row = await getSubscriptionSeatSyncState(payload.subscriptionId)
if (!row) {
logger.warn('Subscription not found when syncing seats', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
})
return
}
if (!isTeam(row.plan)) {
logger.info('Skipping seat sync for non-Team subscription', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
plan: row.plan,
})
return
}
if (!row.stripeSubscriptionId) {
logger.warn('Subscription has no Stripe id when syncing seats', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
})
return
}
if (!hasUsableSubscriptionStatus(row.status)) {
logger.warn('Skipping seat sync for unusable DB subscription status', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
status: row.status,
})
return
}
const desiredSeats = row.seats || 1
const stripeSubscription = await stripe.subscriptions.retrieve(row.stripeSubscriptionId)
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
logger.warn('Skipping seat sync for unusable Stripe subscription', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
stripeSubscriptionId: row.stripeSubscriptionId,
stripeStatus: stripeSubscription.status,
})
return
}
const subscriptionItem = stripeSubscription.items.data[0]
if (!subscriptionItem) {
throw new Error(
`No subscription item found for Stripe subscription ${row.stripeSubscriptionId}`
)
}
// Reconcile the price too: a Pro→Team conversion moves the DB plan to a
// Team tier while Stripe is still on the Pro price. Resolve the target
// price for the DB plan at the item's current interval. If the target
// price is unconfigured we leave the price untouched and sync quantity
// only — `mapToTeamPlanName` guards conversions against missing tiers.
const targetPlan = getPlanByName(row.plan)
const interval = subscriptionItem.price?.recurring?.interval
const targetPriceId =
interval === 'year' ? targetPlan?.annualDiscountPriceId : targetPlan?.priceId
const priceNeedsChange = Boolean(targetPriceId) && subscriptionItem.price?.id !== targetPriceId
const quantityNeedsChange = subscriptionItem.quantity !== desiredSeats
if (priceNeedsChange || quantityNeedsChange) {
await stripe.subscriptions.update(
row.stripeSubscriptionId,
{
items: [
{
id: subscriptionItem.id,
quantity: desiredSeats,
...(priceNeedsChange ? { price: targetPriceId } : {}),
},
],
proration_behavior: 'always_invoice',
},
{ idempotencyKey: `outbox:${ctx.eventId}:${row.plan}:${desiredSeats}` }
)
}
const latest = await getSubscriptionSeatSyncState(payload.subscriptionId)
const latestSeats = latest?.seats || 1
if (latestSeats !== desiredSeats) {
logger.info('Subscription seats changed during Stripe sync; retrying latest value', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
stripeSubscriptionId: row.stripeSubscriptionId,
attemptedSeats: desiredSeats,
latestSeats,
attempt,
})
continue
}
logger.info('Synced subscription price and seats from DB to Stripe', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
stripeSubscriptionId: row.stripeSubscriptionId,
plan: row.plan,
seats: desiredSeats,
alreadySynced: !priceNeedsChange && !quantityNeedsChange,
reason: payload.reason,
})
return
}
throw new Error(`Subscription seats changed while syncing ${payload.subscriptionId}`)
}
const stripeThresholdOverageInvoice: OutboxHandler<StripeThresholdOverageInvoicePayload> = async (
payload,
ctx
) => {
const stripe = requireStripeClient()
// Resolve default PM from (subscription → customer) so Stripe can
// auto-collect when the invoice finalizes. Without this, an ad-hoc
// invoice (no subscription link) falls back to customer-level PM
// only, which may not be set for customers onboarded via Checkout
// Subscription flows.
const { paymentMethodId: defaultPaymentMethod } = await resolveDefaultPaymentMethod(
stripe,
payload.stripeSubscriptionId,
payload.customerId
)
// Compose Stripe idempotency keys from caller-provided stem + outbox
// event id so retries of the SAME outbox event collapse on Stripe's
// side.
const invoiceIdemKey = `${payload.invoiceIdemKeyStem}:${ctx.eventId}`
const itemIdemKey = `${payload.itemIdemKeyStem}:${ctx.eventId}`
const finalizeIdemKey = `${payload.invoiceIdemKeyStem}:finalize:${ctx.eventId}`
const payIdemKey = `${payload.invoiceIdemKeyStem}:pay:${ctx.eventId}`
// `auto_advance: false` + explicit finalize mirrors pre-refactor
// behavior: we control exactly when the invoice finalizes, so it
// doesn't silently convert to paid/open on Stripe's schedule while
// our retry state is still in flight.
const invoice = await stripe.invoices.create(
{
customer: payload.customerId,
collection_method: 'charge_automatically',
auto_advance: false,
description: payload.description,
metadata: payload.metadata,
...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}),
},
{ idempotencyKey: invoiceIdemKey }
)
if (!invoice.id) {
throw new Error('Stripe returned invoice without id')
}
await stripe.invoiceItems.create(
{
customer: payload.customerId,
invoice: invoice.id,
amount: payload.amountCents,
currency: 'usd',
description: payload.itemDescription,
metadata: payload.metadata,
},
{ idempotencyKey: itemIdemKey }
)
const finalized = await stripe.invoices.finalizeInvoice(
invoice.id,
{},
{ idempotencyKey: finalizeIdemKey }
)
if (finalized.status === 'open' && finalized.id && defaultPaymentMethod) {
try {
await stripe.invoices.pay(
finalized.id,
{ payment_method: defaultPaymentMethod },
{ idempotencyKey: payIdemKey }
)
} catch (payError) {
logger.warn('Auto-pay failed for threshold overage invoice — Stripe dunning will retry', {
invoiceId: finalized.id,
error: getErrorMessage(payError),
})
}
}
logger.info('Created threshold overage invoice via outbox', {
eventId: ctx.eventId,
invoiceId: invoice.id,
customerId: payload.customerId,
amountCents: payload.amountCents,
billingPeriod: payload.billingPeriod,
defaultPaymentMethod: defaultPaymentMethod ? 'resolved' : 'none',
})
}
const stripeSyncCustomerContact: OutboxHandler<StripeSyncCustomerContactPayload> = async (
payload,
ctx
) => {
const [subscriptionRow] = await db
.select({
referenceId: subscriptionTable.referenceId,
stripeCustomerId: subscriptionTable.stripeCustomerId,
})
.from(subscriptionTable)
.where(eq(subscriptionTable.id, payload.subscriptionId))
.limit(1)
if (!subscriptionRow) {
logger.warn('Subscription not found when syncing Stripe customer contact', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
})
return
}
if (!subscriptionRow.stripeCustomerId) {
logger.warn('Subscription has no Stripe customer id when syncing contact', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
})
return
}
const [owner] = await db
.select({
email: user.email,
name: user.name,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(and(eq(member.organizationId, subscriptionRow.referenceId), eq(member.role, 'owner')))
.limit(1)
if (!owner) {
logger.warn('Organization owner not found when syncing Stripe customer contact', {
eventId: ctx.eventId,
subscriptionId: payload.subscriptionId,
organizationId: subscriptionRow.referenceId,
})
return
}
const stripe = requireStripeClient()
await stripe.customers.update(
subscriptionRow.stripeCustomerId,
{
email: owner.email,
...(owner.name ? { name: owner.name } : {}),
},
{ idempotencyKey: `outbox:${ctx.eventId}` }
)
logger.info('Synced Stripe customer contact', {
eventId: ctx.eventId,
stripeCustomerId: subscriptionRow.stripeCustomerId,
subscriptionId: payload.subscriptionId,
reason: payload.reason,
})
}
export const billingOutboxHandlers = {
[OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END]:
stripeSyncCancelAtPeriodEnd as OutboxHandler<unknown>,
[OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS]:
stripeSyncSubscriptionSeats as OutboxHandler<unknown>,
[OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE]:
stripeThresholdOverageInvoice as OutboxHandler<unknown>,
[OUTBOX_EVENT_TYPES.STRIPE_SYNC_CUSTOMER_CONTACT]:
stripeSyncCustomerContact as OutboxHandler<unknown>,
} as const
@@ -0,0 +1,560 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, ne } from 'drizzle-orm'
import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
import {
getBilledOverageForSubscription,
resetUsageForSubscription,
} from '@/lib/billing/webhooks/invoices'
import { captureServerEvent } from '@/lib/posthog/server'
import { detachOrganizationWorkspaces } from '@/lib/workspaces/organization-workspaces'
const logger = createLogger('StripeSubscriptionWebhooks')
/**
* Resolve a real `user.id` to use as the audit actor for a subscription
* event. Org-scoped subscriptions resolve to the org owner; personally
* scoped subscriptions already reference a user.
*/
async function resolveSubscriptionActorId(referenceId: string): Promise<string> {
try {
const rows = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, referenceId), eq(member.role, 'owner')))
.limit(1)
return rows[0]?.userId ?? referenceId
} catch (error) {
logger.warn('Failed to resolve subscription actor; falling back to reference id', {
referenceId,
error,
})
return referenceId
}
}
/**
* Restore personal Pro subscriptions for every member of an organization
* when the team/enterprise subscription ends. Errors propagate so the
* enclosing webhook handler fails and Stripe retries the delivery.
*
* `restoreUserProSubscription` is idempotent: already-restored members
* are no-ops on retry, so a partial first attempt is safe to re-run.
*/
async function restoreMemberProSubscriptions(organizationId: string): Promise<number> {
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
let restoredCount = 0
for (const m of members) {
const result = await restoreUserProSubscription(m.userId)
if (result.restored) {
restoredCount++
}
}
if (restoredCount > 0) {
logger.info('Restored Pro subscriptions for team members', {
organizationId,
restoredCount,
totalMembers: members.length,
})
}
return restoredCount
}
export interface OrganizationDormantTransitionResult {
restoredProCount: number
membersSynced: number
workspacesDetached: number
organizationRetainsTeamOrEnterprise: boolean
}
/**
* Returns true when the organization is still covered by an **active
* Team or Enterprise** subscription other than `excludeSubscriptionId`.
* The org keeps its team-owned workspaces only while such a sub exists;
* a Pro sub on the org does not count.
*/
async function hasOtherActiveTeamOrEnterpriseSubscription(
organizationId: string,
excludeSubscriptionId: string | null
): Promise<boolean> {
const filters = [
eq(subscription.referenceId, organizationId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
]
if (excludeSubscriptionId) {
filters.push(ne(subscription.id, excludeSubscriptionId))
}
const rows = await db
.select({ plan: subscription.plan })
.from(subscription)
.where(and(...filters))
return rows.some((row) => isTeam(row.plan) || isEnterprise(row.plan))
}
async function transitionOrganizationToDormantState(
organizationId: string,
triggeringSubscriptionId: string | null
): Promise<OrganizationDormantTransitionResult> {
const memberUserIds = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
if (await hasOtherActiveTeamOrEnterpriseSubscription(organizationId, triggeringSubscriptionId)) {
logger.info(
'Skipping dormant transition - another Team/Enterprise subscription still covers this organization',
{ organizationId, triggeringSubscriptionId }
)
for (const m of memberUserIds) {
await syncUsageLimitsFromSubscription(m.userId)
}
return {
restoredProCount: 0,
membersSynced: memberUserIds.length,
workspacesDetached: 0,
organizationRetainsTeamOrEnterprise: true,
}
}
const { detachedWorkspaceIds } = await detachOrganizationWorkspaces(organizationId)
const restoredProCount = await restoreMemberProSubscriptions(organizationId)
for (const m of memberUserIds) {
await syncUsageLimitsFromSubscription(m.userId)
}
return {
restoredProCount,
membersSynced: memberUserIds.length,
workspacesDetached: detachedWorkspaceIds.length,
organizationRetainsTeamOrEnterprise: false,
}
}
export async function handleOrganizationPlanDowngrade(
subscriptionData: {
id: string
plan: string | null
referenceId: string
status: string | null
},
stripeEventId?: string
): Promise<void> {
if (!(await isSubscriptionOrgScoped({ referenceId: subscriptionData.referenceId }))) {
return
}
const stillTeamOrEnterprise = isTeam(subscriptionData.plan) || isEnterprise(subscriptionData.plan)
if (stillTeamOrEnterprise) {
return
}
const [currentRow] = await db
.select({ plan: subscription.plan })
.from(subscription)
.where(eq(subscription.id, subscriptionData.id))
.limit(1)
if (currentRow && (isTeam(currentRow.plan) || isEnterprise(currentRow.plan))) {
logger.info('Skipping plan downgrade transition - subscription is currently Team/Enterprise', {
subscriptionId: subscriptionData.id,
organizationId: subscriptionData.referenceId,
eventPlan: subscriptionData.plan,
currentPlan: currentRow.plan,
})
return
}
const idempotencyIdentifier = stripeEventId ?? `plan-downgrade:${subscriptionData.id}`
try {
await stripeWebhookIdempotency.executeWithIdempotency(
'organization-plan-downgrade',
idempotencyIdentifier,
async () => {
const result = await transitionOrganizationToDormantState(
subscriptionData.referenceId,
subscriptionData.id
)
if (result.workspacesDetached > 0 || result.restoredProCount > 0) {
logger.info('Transitioned organization to dormant state after plan downgrade', {
organizationId: subscriptionData.referenceId,
subscriptionId: subscriptionData.id,
plan: subscriptionData.plan,
...result,
})
}
return result
}
)
} catch (error) {
logger.error('Failed to transition organization to dormant state on plan downgrade', {
organizationId: subscriptionData.referenceId,
subscriptionId: subscriptionData.id,
plan: subscriptionData.plan,
error,
})
throw error
}
}
/**
* Handle new subscription creation - reset usage if transitioning from free to paid
*/
export async function handleSubscriptionCreated(
subscriptionData: {
id: string
referenceId: string
plan: string | null
status: string
periodStart?: Date | null
periodEnd?: Date | null
},
stripeEventId?: string
) {
const idempotencyIdentifier = stripeEventId ?? `sub-created:${subscriptionData.id}`
try {
await stripeWebhookIdempotency.executeWithIdempotency(
'subscription-created',
idempotencyIdentifier,
async () => {
const otherActiveSubscriptions = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, subscriptionData.referenceId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
ne(subscription.id, subscriptionData.id) // Exclude current subscription
)
)
const wasFreePreviously = otherActiveSubscriptions.length === 0
const isPaidPlan = isPaid(subscriptionData.plan)
if (wasFreePreviously && isPaidPlan) {
logger.info('Detected free -> paid transition, resetting usage', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
plan: subscriptionData.plan,
})
await resetUsageForSubscription({
plan: subscriptionData.plan,
referenceId: subscriptionData.referenceId,
periodStart: subscriptionData.periodStart ?? null,
periodEnd: subscriptionData.periodEnd ?? null,
})
logger.info('Successfully reset usage for free -> paid transition', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
plan: subscriptionData.plan,
})
} else {
logger.info('No usage reset needed', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
plan: subscriptionData.plan,
wasFreePreviously,
isPaidPlan,
otherActiveSubscriptionsCount: otherActiveSubscriptions.length,
})
}
if (wasFreePreviously && isPaidPlan) {
// Best-effort instrumentation; a transient DB error here must never abort
// the (already-committed) free -> paid usage reset above, so it's guarded.
try {
const actorId = await resolveSubscriptionActorId(subscriptionData.referenceId)
const isOrgScoped = await isSubscriptionOrgScoped({
referenceId: subscriptionData.referenceId,
})
recordAudit({
actorId,
action: AuditAction.SUBSCRIPTION_CREATED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: subscriptionData.id,
description: `Subscription created on ${subscriptionData.plan ?? 'unknown'} plan for ${subscriptionData.referenceId}`,
metadata: {
plan: subscriptionData.plan,
status: subscriptionData.status,
referenceId: subscriptionData.referenceId,
...(isOrgScoped ? { organizationId: subscriptionData.referenceId } : {}),
},
})
captureServerEvent(subscriptionData.referenceId, 'subscription_created', {
plan: subscriptionData.plan ?? 'unknown',
status: subscriptionData.status,
reference_id: subscriptionData.referenceId,
})
} catch (instrumentationError) {
logger.warn('Failed to record subscription-created instrumentation', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
error: instrumentationError,
})
}
}
}
)
} catch (error) {
logger.error('Failed to handle subscription creation usage reset', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
error,
})
throw error
}
}
/**
* Handles a subscription deletion (cancel) event. Bills any final-period
* overages, resets usage, and transitions the organization to a dormant
* state via `transitionOrganizationToDormantState` — the same path used
* by plan downgrades. Wrapped in `stripeWebhookIdempotency` so duplicate
* event deliveries collapse to one execution; if any step throws, the
* webhook retries from scratch.
*/
export async function handleSubscriptionDeleted(
subscription: {
id: string
plan: string | null
referenceId: string
stripeSubscriptionId: string | null
seats?: number | null
periodStart?: Date | null
periodEnd?: Date | null
},
stripeEventId?: string
) {
const stripeSubscriptionId = subscription.stripeSubscriptionId || ''
logger.info('Processing subscription deletion', {
stripeEventId,
stripeSubscriptionId,
subscriptionId: subscription.id,
})
// Fall back to the subscription DB id when we don't have an event id
// (e.g. called outside the Stripe webhook context). Still dedupes a
// single subscription's deletion, just not event-granular.
const idempotencyIdentifier = stripeEventId ?? `sub:${subscription.id}`
try {
await stripeWebhookIdempotency.executeWithIdempotency(
'subscription-deleted',
idempotencyIdentifier,
async () => {
const totalOverage = await calculateSubscriptionOverage(subscription)
const stripe = requireStripeClient()
if (isEnterprise(subscription.plan)) {
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
periodStart: subscription.periodStart ?? null,
periodEnd: subscription.periodEnd ?? null,
})
const dormantResult = await transitionOrganizationToDormantState(
subscription.referenceId,
subscription.id
)
logger.info('Successfully processed enterprise subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
...dormantResult,
})
const enterpriseActorId = await resolveSubscriptionActorId(subscription.referenceId)
recordAudit({
actorId: enterpriseActorId,
action: AuditAction.SUBSCRIPTION_CANCELLED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: subscription.id,
description: `Enterprise subscription cancelled for ${subscription.referenceId}`,
metadata: {
plan: subscription.plan,
referenceId: subscription.referenceId,
organizationId: subscription.referenceId,
kind: 'enterprise',
},
})
captureServerEvent(subscription.referenceId, 'subscription_cancelled', {
plan: subscription.plan ?? 'unknown',
reference_id: subscription.referenceId,
})
return { totalOverage: 0, kind: 'enterprise' as const }
}
const billedOverage = await getBilledOverageForSubscription(subscription)
const remainingOverage = Math.max(0, totalOverage - billedOverage)
logger.info('Subscription deleted overage calculation', {
subscriptionId: subscription.id,
totalOverage,
billedOverage,
remainingOverage,
})
if (remainingOverage > 0 && stripeSubscriptionId) {
const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId)
const customerId = stripeSubscription.customer as string
const cents = Math.round(remainingOverage * 100)
const endedAt = stripeSubscription.ended_at || Math.floor(Date.now() / 1000)
const billingPeriod = new Date(endedAt * 1000).toISOString().slice(0, 7)
const itemIdemKey = `final-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const invoiceIdemKey = `final-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const finalizeIdemKey = `final-overage-finalize:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const overageInvoice = await stripe.invoices.create(
{
customer: customerId,
collection_method: 'charge_automatically',
auto_advance: true,
description: `Final overage charges for ${subscription.plan} subscription (${billingPeriod})`,
metadata: {
type: 'final_overage_billing',
billingPeriod,
subscriptionId: stripeSubscriptionId,
cancelledAt: stripeSubscription.canceled_at?.toString() || '',
},
},
{ idempotencyKey: invoiceIdemKey }
)
await stripe.invoiceItems.create(
{
customer: customerId,
invoice: overageInvoice.id,
amount: cents,
currency: 'usd',
description: `Usage overage for ${subscription.plan} plan (Final billing period)`,
metadata: {
type: 'final_usage_overage',
usage: remainingOverage.toFixed(2),
totalOverage: totalOverage.toFixed(2),
billedOverage: billedOverage.toFixed(2),
billingPeriod,
},
},
{ idempotencyKey: itemIdemKey }
)
if (overageInvoice.id) {
await stripe.invoices.finalizeInvoice(
overageInvoice.id,
{},
{ idempotencyKey: finalizeIdemKey }
)
}
logger.info('Created final overage invoice for cancelled subscription', {
subscriptionId: subscription.id,
stripeSubscriptionId,
invoiceId: overageInvoice.id,
totalOverage,
billedOverage,
remainingOverage,
cents,
billingPeriod,
})
} else {
logger.info('No overage to bill for cancelled subscription', {
subscriptionId: subscription.id,
plan: subscription.plan,
})
}
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
periodStart: subscription.periodStart ?? null,
periodEnd: subscription.periodEnd ?? null,
})
let restoredProCount = 0
let membersSynced = 0
let workspacesDetached = 0
const isOrgScoped = await isSubscriptionOrgScoped(subscription)
if (isOrgScoped) {
const dormantResult = await transitionOrganizationToDormantState(
subscription.referenceId,
subscription.id
)
restoredProCount = dormantResult.restoredProCount
membersSynced = dormantResult.membersSynced
workspacesDetached = dormantResult.workspacesDetached
} else if (isPro(subscription.plan)) {
await syncUsageLimitsFromSubscription(subscription.referenceId)
membersSynced = 1
}
logger.info('Successfully processed subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
plan: subscription.plan,
totalOverage,
restoredProCount,
membersSynced,
workspacesDetached,
})
const cancelActorId = await resolveSubscriptionActorId(subscription.referenceId)
recordAudit({
actorId: cancelActorId,
action: AuditAction.SUBSCRIPTION_CANCELLED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: subscription.id,
description: `Subscription cancelled on ${subscription.plan ?? 'unknown'} plan for ${subscription.referenceId}`,
metadata: {
plan: subscription.plan,
referenceId: subscription.referenceId,
totalOverage,
...(isOrgScoped ? { organizationId: subscription.referenceId } : {}),
},
})
captureServerEvent(subscription.referenceId, 'subscription_cancelled', {
plan: subscription.plan ?? 'unknown',
reference_id: subscription.referenceId,
})
return { totalOverage, remainingOverage, restoredProCount, workspacesDetached }
}
)
} catch (error) {
logger.error('Failed to handle subscription deletion', {
subscriptionId: subscription.id,
stripeSubscriptionId,
error,
})
throw error
}
}