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
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { checkStorageQuota, getUserStorageLimit, getUserStorageUsage } from './limits'
|
||||
export {
|
||||
checkAndIncrementStorageUsageInTx,
|
||||
decrementStorageUsage,
|
||||
decrementStorageUsageInTx,
|
||||
incrementStorageUsage,
|
||||
maybeNotifyStorageLimit,
|
||||
} from './tracking'
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 Emir’s 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 (1–5 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user