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