fix(enterprise-plan): seats should be taken from metadata (#2200)

* fix(enterprise): seats need to be picked up from metadata not column

* fix env var access

* fix user avatar
This commit is contained in:
Vikhyath Mondreti
2025-12-04 16:22:29 -08:00
committed by GitHub
parent 8e7d8c93e3
commit d22b5783be
15 changed files with 101 additions and 66 deletions

View File

@@ -197,7 +197,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
subscriptionData?.data?.status === 'active',
plan: subscriptionData?.data?.plan || 'free',
status: subscriptionData?.data?.status || 'inactive',
seats: subscriptionData?.data?.seats || 1,
seats: organizationBillingData?.totalSeats ?? 0,
}
const usage = {
@@ -373,7 +373,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
onBadgeClick={handleBadgeClick}
seatsText={
permissions.canManageTeam || subscription.isEnterprise
? `${organizationBillingData?.totalSeats || subscription.seats || 1} seats`
? `${subscription.seats} seats`
: undefined
}
current={

View File

@@ -3,23 +3,20 @@ import { Skeleton } from '@/components/ui/skeleton'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { cn } from '@/lib/core/utils/cn'
const PILL_COUNT = 8
type Subscription = {
id: string
plan: string
status: string
seats?: number
referenceId: string
cancelAtPeriodEnd?: boolean
periodEnd?: number | Date
trialEnd?: number | Date
metadata?: any
}
interface TeamSeatsOverviewProps {
subscriptionData: Subscription | null
isLoadingSubscription: boolean
totalSeats: number
usedSeats: number
isLoading: boolean
onConfirmTeamUpgrade: (seats: number) => Promise<void>
@@ -55,6 +52,7 @@ function TeamSeatsSkeleton() {
export function TeamSeatsOverview({
subscriptionData,
isLoadingSubscription,
totalSeats,
usedSeats,
isLoading,
onConfirmTeamUpgrade,
@@ -78,7 +76,7 @@ export function TeamSeatsOverview({
<Button
variant='primary'
onClick={() => {
onConfirmTeamUpgrade(2) // Start with 2 seats as default
onConfirmTeamUpgrade(2)
}}
disabled={isLoading}
>
@@ -89,7 +87,6 @@ export function TeamSeatsOverview({
)
}
const totalSeats = subscriptionData.seats || 0
const isEnterprise = checkEnterprisePlan(subscriptionData)
return (

View File

@@ -13,7 +13,6 @@ import {
Tooltip,
} from '@/components/emcn'
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
import { env } from '@/lib/core/config/env'
interface TeamSeatsProps {
open: boolean
@@ -52,7 +51,7 @@ export function TeamSeats({
}
}, [open, initialSeats])
const costPerSeat = env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT
const costPerSeat = DEFAULT_TEAM_TIER_COST_LIMIT
const totalMonthlyCost = selectedSeats * costPerSeat
const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0

View File

@@ -63,10 +63,10 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
return null
}
const currentUsage = billingData.totalCurrentUsage || 0
const currentCap = billingData.totalUsageLimit || billingData.minimumBillingAmount || 0
const minimumBilling = billingData.minimumBillingAmount || 0
const seatsCount = billingData.seatsCount || 1
const currentUsage = billingData.totalCurrentUsage ?? 0
const currentCap = billingData.totalUsageLimit ?? billingData.minimumBillingAmount ?? 0
const minimumBilling = billingData.minimumBillingAmount ?? 0
const seatsCount = billingData.seatsCount ?? 0
const percentUsed =
currentCap > 0 ? Math.round(Math.min((currentUsage / currentCap) * 100, 100)) : 0
const status: 'ok' | 'warning' | 'exceeded' =

View File

@@ -3,7 +3,6 @@ import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { env } from '@/lib/core/config/env'
import { createLogger } from '@/lib/logs/console/logger'
import {
generateSlug,
@@ -23,6 +22,7 @@ import {
useCreateOrganization,
useInviteMember,
useOrganization,
useOrganizationBilling,
useOrganizationSubscription,
useOrganizations,
useRemoveMember,
@@ -56,6 +56,8 @@ export function TeamManagement() {
error: subscriptionError,
} = useOrganizationSubscription(activeOrganization?.id || '')
const { data: organizationBillingData } = useOrganizationBilling(activeOrganization?.id || '')
const inviteMutation = useInviteMember()
const removeMemberMutation = useRemoveMember()
const updateSeatsMutation = useUpdateSeats()
@@ -89,6 +91,7 @@ export function TeamManagement() {
const userRole = getUserRole(organization, session?.user?.email)
const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)
const usedSeats = getUsedSeats(organization)
const totalSeats = organizationBillingData?.data?.totalSeats ?? 0
useEffect(() => {
if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) {
@@ -238,11 +241,11 @@ export function TeamManagement() {
}, [session?.user?.id, activeOrganization?.id, subscriptionData, usedSeats, updateSeatsMutation])
const handleAddSeatDialog = useCallback(() => {
if (subscriptionData) {
setNewSeatCount((subscriptionData.seats || 1) + 1)
if (subscriptionData && !checkEnterprisePlan(subscriptionData)) {
setNewSeatCount(totalSeats + 1)
setIsAddSeatDialogOpen(true)
}
}, [subscriptionData?.seats])
}, [subscriptionData, totalSeats])
const confirmAddSeats = useCallback(
async (selectedSeats?: number) => {
@@ -370,6 +373,7 @@ export function TeamManagement() {
<TeamSeatsOverview
subscriptionData={subscriptionData || null}
isLoadingSubscription={isLoadingSubscription}
totalSeats={totalSeats}
usedSeats={usedSeats.used}
isLoading={isLoading}
onConfirmTeamUpgrade={confirmTeamUpgrade}
@@ -394,8 +398,8 @@ export function TeamManagement() {
onLoadUserWorkspaces={async () => {}} // No-op: data is auto-loaded by React Query
onWorkspaceToggle={handleWorkspaceToggle}
inviteSuccess={inviteSuccess}
availableSeats={Math.max(0, (subscriptionData?.seats || 0) - usedSeats.used)}
maxSeats={subscriptionData?.seats || 0}
availableSeats={Math.max(0, totalSeats - usedSeats.used)}
maxSeats={totalSeats}
invitationError={inviteMutation.error}
isLoadingWorkspaces={isLoadingWorkspaces}
/>
@@ -481,9 +485,8 @@ export function TeamManagement() {
<ul className='ml-4 list-disc space-y-[8px] text-[var(--text-muted)] text-xs'>
<li>
Your team is billed a minimum of $
{(subscriptionData?.seats || 0) *
(env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT)}
/month for {subscriptionData?.seats || 0} licensed seats
{(subscriptionData?.seats ?? 0) * DEFAULT_TEAM_TIER_COST_LIMIT}
/month for {subscriptionData?.seats ?? 0} licensed seats
</li>
<li>All team member usage is pooled together from a shared limit</li>
<li>
@@ -528,23 +531,25 @@ export function TeamManagement() {
}
/>
<TeamSeats
open={isAddSeatDialogOpen}
onOpenChange={setIsAddSeatDialogOpen}
title='Add Team Seats'
description={`Each seat costs $${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month and provides $${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT} in monthly inference credits. Adjust the number of licensed seats for your team.`}
currentSeats={subscriptionData?.seats || 1}
initialSeats={newSeatCount}
isLoading={isUpdatingSeats}
error={updateSeatsMutation.error}
onConfirm={async (selectedSeats: number) => {
setNewSeatCount(selectedSeats)
await confirmAddSeats(selectedSeats)
}}
confirmButtonText='Update Seats'
showCostBreakdown={true}
isCancelledAtPeriodEnd={subscriptionData?.cancelAtPeriodEnd}
/>
{subscriptionData && !checkEnterprisePlan(subscriptionData) && (
<TeamSeats
open={isAddSeatDialogOpen}
onOpenChange={setIsAddSeatDialogOpen}
title='Add Team Seats'
description={`Each seat costs $${DEFAULT_TEAM_TIER_COST_LIMIT}/month and provides $${DEFAULT_TEAM_TIER_COST_LIMIT} in monthly inference credits. Adjust the number of licensed seats for your team.`}
currentSeats={totalSeats}
initialSeats={newSeatCount}
isLoading={isUpdatingSeats}
error={updateSeatsMutation.error}
onConfirm={async (selectedSeats: number) => {
setNewSeatCount(selectedSeats)
await confirmAddSeats(selectedSeats)
}}
confirmButtonText='Update Seats'
showCostBreakdown={true}
isCancelledAtPeriodEnd={subscriptionData?.cancelAtPeriodEnd}
/>
)}
</div>
)
}

View File

@@ -55,7 +55,7 @@ export function UserAvatar({
sizes={`${size}px`}
className='object-cover'
referrerPolicy='no-referrer'
unoptimized={avatarUrl.startsWith('http')}
unoptimized
onError={() => setImageError(true)}
/>
) : (

View File

@@ -115,7 +115,7 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
const orgSub = await getOrganizationSubscription(org.id)
if (orgSub?.seats) {
const { basePrice } = getPlanPricing(orgSub.plan)
orgCap = (orgSub.seats || 1) * basePrice
orgCap = (orgSub.seats ?? 0) * basePrice
} else {
// If no subscription, use team default
const { basePrice } = getPlanPricing('team')

View File

@@ -96,9 +96,6 @@ export function isAtLeastTeam(subscriptionData: SubscriptionData | null | undefi
return status.isTeam || status.isEnterprise
}
/**
* Check if user can upgrade
*/
export function canUpgrade(subscriptionData: SubscriptionData | null | undefined): boolean {
const status = getSubscriptionStatus(subscriptionData)
return status.plan === 'free' || status.plan === 'pro'

View File

@@ -144,7 +144,7 @@ export async function calculateSubscriptionOverage(sub: {
const totalUsageWithDeparted = totalTeamUsage + departedUsage
const { basePrice } = getPlanPricing(sub.plan)
const baseSubscriptionAmount = (sub.seats || 1) * basePrice
const baseSubscriptionAmount = (sub.seats ?? 0) * basePrice
totalOverage = Math.max(0, totalUsageWithDeparted - baseSubscriptionAmount)
logger.info('Calculated team overage', {
@@ -286,7 +286,7 @@ export async function getSimplifiedBillingSummary(
const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan)
// Use licensed seats from Stripe as source of truth
const licensedSeats = subscription.seats || 1
const licensedSeats = subscription.seats ?? 0
const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription
let totalCurrentUsage = 0

View File

@@ -2,7 +2,7 @@ import { db } from '@sim/db'
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { getFreeTierLimit } from '@/lib/billing/subscriptions/utils'
import { getEffectiveSeats, getFreeTierLimit } from '@/lib/billing/subscriptions/utils'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OrganizationBilling')
@@ -133,9 +133,12 @@ export async function getOrganizationBillingData(
// Get per-seat pricing for the plan
const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan)
// Use Stripe subscription seats as source of truth
// Ensure we always have at least 1 seat (protect against 0 or falsy values)
const licensedSeats = Math.max(subscription.seats || 1, 1)
const licensedSeats = subscription.seats ?? 0
// For seat count used in UI (invitations, team management):
// Team: seats column (Stripe quantity)
// Enterprise: metadata.seats (allocated seats, not Stripe quantity which is always 1)
const effectiveSeats = getEffectiveSeats(subscription)
// Calculate minimum billing amount
let minimumBillingAmount: number
@@ -174,9 +177,9 @@ export async function getOrganizationBillingData(
organizationName: organizationData.name || '',
subscriptionPlan: subscription.plan,
subscriptionStatus: subscription.status || 'inactive',
totalSeats: Math.max(subscription.seats || 1, 1),
totalSeats: effectiveSeats, // Uses metadata.seats for enterprise, seats column for team
usedSeats: members.length,
seatsCount: licensedSeats,
seatsCount: licensedSeats, // Used for billing calculations (Stripe quantity)
totalCurrentUsage: roundCurrency(totalCurrentUsage),
totalUsageLimit: roundCurrency(totalUsageLimit),
minimumBillingAmount: roundCurrency(minimumBillingAmount),
@@ -232,9 +235,8 @@ export async function updateOrganizationUsageLimit(
}
}
// Team plans have minimum based on seats
const { basePrice } = getPlanPricing(subscription.plan)
const minimumLimit = Math.max(subscription.seats || 1, 1) * basePrice
const minimumLimit = (subscription.seats ?? 0) * basePrice
// Validate new limit is not below minimum
if (newLimit < minimumLimit) {

View File

@@ -95,7 +95,7 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats || 1) * basePrice
const minimum = (subscription.seats ?? 0) * basePrice
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
@@ -168,7 +168,7 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats || 1) * basePrice
const minimum = (subscription.seats ?? 0) * basePrice
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
@@ -361,14 +361,14 @@ export async function getUserUsageLimit(userId: string): Promise<number> {
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats || 1) * basePrice
const minimum = (subscription.seats ?? 0) * basePrice
return Math.max(configured, minimum)
}
// If org hasn't set a custom limit, use minimum (seats × cost per seat)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan)
return (subscription.seats || 1) * basePrice
return (subscription.seats ?? 0) * basePrice
}
/**

View File

@@ -4,6 +4,7 @@ import {
DEFAULT_PRO_TIER_COST_LIMIT,
DEFAULT_TEAM_TIER_COST_LIMIT,
} from '@/lib/billing/constants'
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
import { env } from '@/lib/core/config/env'
/**
@@ -38,6 +39,38 @@ export function checkEnterprisePlan(subscription: any): boolean {
return subscription?.plan === 'enterprise' && subscription?.status === 'active'
}
/**
* Type guard to check if metadata is valid EnterpriseSubscriptionMetadata
*/
function isEnterpriseMetadata(metadata: unknown): metadata is EnterpriseSubscriptionMetadata {
return (
!!metadata &&
typeof metadata === 'object' &&
'seats' in metadata &&
typeof (metadata as EnterpriseSubscriptionMetadata).seats === 'string'
)
}
export function getEffectiveSeats(subscription: any): number {
if (!subscription) {
return 0
}
if (subscription.plan === 'enterprise') {
const metadata = subscription.metadata as EnterpriseSubscriptionMetadata | null
if (isEnterpriseMetadata(metadata)) {
return Number.parseInt(metadata.seats, 10)
}
return 0
}
if (subscription.plan === 'team') {
return subscription.seats ?? 0
}
return 0
}
export function checkProPlan(subscription: any): boolean {
return subscription?.plan === 'pro' && subscription?.status === 'active'
}

View File

@@ -330,7 +330,7 @@ export async function checkAndBillOrganizationOverageThreshold(
}
const { basePrice: basePricePerSeat } = getPlanPricing(orgSubscription.plan)
const basePrice = basePricePerSeat * (orgSubscription.seats || 1)
const basePrice = basePricePerSeat * (orgSubscription.seats ?? 0)
const currentOverage = Math.max(0, totalTeamUsage - basePrice)
const unbilledOverage = Math.max(0, currentOverage - totalBilledOverage)

View File

@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { invitation, member, organization, subscription, user, userStats } from '@sim/db/schema'
import { and, count, eq } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -66,9 +67,9 @@ export async function validateSeatAvailability(
const currentSeats = memberCount[0]?.count || 0
// Determine seat limits based on subscription
// Team: seats from Stripe subscription quantity
// Enterprise: seats from metadata (stored in subscription.seats)
const maxSeats = subscription.seats || 1
// Team: seats from Stripe subscription quantity (seats column)
// Enterprise: seats from metadata.seats (not from seats column which is always 1)
const maxSeats = getEffectiveSeats(subscription)
const availableSeats = Math.max(0, maxSeats - currentSeats)
const canInvite = availableSeats >= additionalSeats
@@ -140,7 +141,8 @@ export async function getOrganizationSeatInfo(
const currentSeats = memberCount[0]?.count || 0
const maxSeats = subscription.seats || 1
// Team: seats from column, Enterprise: seats from metadata
const maxSeats = getEffectiveSeats(subscription)
const canAddSeats = subscription.plan !== 'enterprise'

View File

@@ -115,7 +115,7 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
? new Date(referenceItem.current_period_end * 1000)
: null,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null,
seats,
seats: 1, // Enterprise uses metadata.seats for actual seat count, column is always 1
trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
: null,
@@ -140,7 +140,7 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
periodStart: subscriptionRow.periodStart,
periodEnd: subscriptionRow.periodEnd,
cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd,
seats: subscriptionRow.seats,
seats: 1, // Enterprise uses metadata.seats for actual seat count, column is always 1
trialStart: subscriptionRow.trialStart,
trialEnd: subscriptionRow.trialEnd,
metadata: subscriptionRow.metadata,