feat(mothership): billing (#3464)

* Billing update

* more billing improvements

* credits UI

* credit purchase safety

* progress

* ui improvements

* fix cancel sub

* fix types

* fix daily refresh for teams

* make max features differentiated

* address bugbot comments

* address greptile comments

* revert isHosted

* address more comments

* fix org refresh bar

* fix ui rounding

* fix minor rounding

* fix upgrade issue for legacy plans

* fix formatPlanName

* fix email dispay names

* fix legacy team reference bugs

* referral bonus in credits

* fix org upgrade bug

* improve logs

* respect toggle for paid users

* fix landing page pro features and usage limit checks

* fixed query and usage

* add unit test

* address more comments

* enterprise guard

* fix limits bug

* pass period start/end for overage
This commit is contained in:
Vikhyath Mondreti
2026-03-08 03:37:54 -07:00
committed by GitHub
parent 1def94392b
commit 1d955fc43a
70 changed files with 2377 additions and 1004 deletions

View File

@@ -20,7 +20,7 @@ const PRICING_TIERS: PricingTier[] = [
price: 'Free',
color: '#2ABBF8',
features: [
'$20 usage limit',
'3,000 credits/mo',
'5GB file storage',
'5 min execution limit',
'Limited log retention',
@@ -29,37 +29,34 @@ const PRICING_TIERS: PricingTier[] = [
cta: { label: 'Get started', href: '/signup' },
},
{
id: 'professional',
name: 'Professional',
id: 'pro',
name: 'Pro',
description: 'For professionals building production workflows',
price: '$20',
price: '$25',
billingPeriod: 'per month',
color: '#00F701',
features: [
'150 runs per minute (sync)',
'1,000 runs per minute (async)',
'6,000 credits/mo',
'+50 daily refresh credits',
'150 runs/min (sync)',
'50 min sync execution limit',
'50GB file storage',
'Unlimited invites',
'Unlimited log retention',
],
cta: { label: 'Get started', href: '/signup' },
},
{
id: 'team',
name: 'Team',
description: 'For teams collaborating on complex agents',
price: '$40',
id: 'max',
name: 'Max',
description: 'For power users and teams building at scale',
price: '$100',
billingPeriod: 'per month',
color: '#FA4EDF',
features: [
'300 runs per minute (sync)',
'2,500 runs per minute (async)',
'500GB file storage (pooled)',
'25,000 credits/mo',
'+200 daily refresh credits',
'300 runs/min (sync)',
'50 min sync execution limit',
'Unlimited invites',
'Unlimited log retention',
'Dedicated Slack channel',
'500GB file storage',
],
cta: { label: 'Get started', href: '/signup' },
},
@@ -69,7 +66,7 @@ const PRICING_TIERS: PricingTier[] = [
description: 'For organizations needing security and scale',
price: 'Custom',
color: '#FFCC02',
features: ['Custom rate limits', 'Custom file storage', 'SSO', 'SOC2', 'Dedicated support'],
features: ['Custom infra limits', 'SSO', 'SOC2', 'Self hosting', 'Dedicated support'],
cta: { label: 'Book a demo', href: '/contact' },
},
]
@@ -94,7 +91,7 @@ interface PricingCardProps {
function PricingCard({ tier }: PricingCardProps) {
const isEnterprise = tier.id === 'enterprise'
const isProfessional = tier.id === 'professional'
const isPro = tier.id === 'pro'
return (
<article className='flex flex-1 flex-col' aria-labelledby={`${tier.id}-heading`}>
@@ -123,7 +120,7 @@ function PricingCard({ tier }: PricingCardProps) {
>
{tier.cta.label}
</a>
) : isProfessional ? (
) : isPro ? (
<Link
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-white transition-[filter] hover:brightness-110'
@@ -172,18 +169,7 @@ function PricingCard({ tier }: PricingCardProps) {
}
/**
* Pricing section tiered pricing plans with feature comparison.
*
* SEO:
* - `<section id="pricing" aria-labelledby="pricing-heading">`.
* - `<h2 id="pricing-heading">` for the section title.
* - Each tier: `<h3>` plan name + semantic `<ul>` feature list.
* - Free tier CTA uses `<Link href="/signup">` (crawlable). Enterprise CTA uses `<a>`.
*
* GEO:
* - Each plan has consistent structure: name, price, billing period, feature list.
* - Lead with a summary: "Sim offers a free Community plan, $20/mo Pro, $40/mo Team, custom Enterprise."
* - Prices must match the `Offer` items in structured-data.tsx exactly.
* Pricing section -- tiered pricing plans with feature comparison.
*/
export default function Pricing() {
return (

View File

@@ -96,19 +96,19 @@ export default function StructuredData() {
offers: [
{
'@type': 'Offer',
name: 'Community Plan',
name: 'Community Plan — 3,000 credits included',
price: '0',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
{
'@type': 'Offer',
name: 'Pro Plan',
price: '20',
name: 'Pro Plan — 6,000 credits/month',
price: '25',
priceCurrency: 'USD',
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: '20',
price: '25',
priceCurrency: 'USD',
unitText: 'MONTH',
billingIncrement: 1,
@@ -117,12 +117,12 @@ export default function StructuredData() {
},
{
'@type': 'Offer',
name: 'Team Plan',
price: '40',
name: 'Max Plan — 25,000 credits/month',
price: '100',
priceCurrency: 'USD',
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: '40',
price: '100',
priceCurrency: 'USD',
unitText: 'MONTH',
billingIncrement: 1,
@@ -191,7 +191,7 @@ export default function StructuredData() {
name: 'How much does Sim cost?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Sim offers a free Community plan with $20 usage limit, a Pro plan at $20/month, a Team plan at $40/month, and custom Enterprise pricing. All plans include CLI/SDK access.',
text: 'Sim offers a free Community plan with 3,000 credits, a Pro plan at $25/month with 6,000 credits, a Max plan at $100/month with 25,000 credits, team plans available for both tiers, and custom Enterprise pricing. All plans include CLI/SDK access.',
},
},
{

View File

@@ -11,16 +11,14 @@ import {
Database,
DollarSign,
HardDrive,
RefreshCw,
Timer,
Zap,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import {
ENTERPRISE_PLAN_FEATURES,
PRO_PLAN_FEATURES,
TEAM_PLAN_FEATURES,
} from '@/app/workspace/[workspaceId]/settings/components/subscription/plan-configs'
import { ENTERPRISE_PLAN_FEATURES } from '@/app/workspace/[workspaceId]/settings/components/subscription/plan-configs'
const logger = createLogger('LandingPricing')
@@ -38,20 +36,30 @@ interface PricingTier {
featured?: boolean
}
/**
* Free plan features with consistent icons
*/
const FREE_PLAN_FEATURES: PricingFeature[] = [
{ icon: DollarSign, text: '$20 usage limit' },
{ icon: DollarSign, text: '3,000 credits/mo' },
{ icon: HardDrive, text: '5GB file storage' },
{ icon: Timer, text: '5 min execution limit' },
{ icon: Database, text: 'Limited log retention' },
{ icon: Code2, text: 'CLI/SDK Access' },
]
/**
* Available pricing tiers with their features and pricing
*/
const PRO_LANDING_FEATURES: PricingFeature[] = [
{ icon: DollarSign, text: '6,000 credits/mo' },
{ icon: RefreshCw, text: '+50 daily refresh credits' },
{ icon: Zap, text: '150 runs/min (sync)' },
{ icon: Timer, text: '50 min sync execution limit' },
{ icon: HardDrive, text: '50GB file storage' },
]
const MAX_LANDING_FEATURES: PricingFeature[] = [
{ icon: DollarSign, text: '25,000 credits/mo' },
{ icon: RefreshCw, text: '+200 daily refresh credits' },
{ icon: Zap, text: '300 runs/min (sync)' },
{ icon: Timer, text: '50 min sync execution limit' },
{ icon: HardDrive, text: '500GB file storage' },
]
const pricingTiers: PricingTier[] = [
{
name: 'COMMUNITY',
@@ -63,16 +71,16 @@ const pricingTiers: PricingTier[] = [
{
name: 'PRO',
tier: 'Pro',
price: '$20/mo',
features: PRO_PLAN_FEATURES,
price: '$25/mo',
features: PRO_LANDING_FEATURES,
ctaText: 'Get Started',
featured: true,
},
{
name: 'TEAM',
tier: 'Team',
price: '$40/mo',
features: TEAM_PLAN_FEATURES,
name: 'MAX',
tier: 'Max',
price: '$100/mo',
features: MAX_LANDING_FEATURES,
ctaText: 'Get Started',
},
{
@@ -84,12 +92,6 @@ const pricingTiers: PricingTier[] = [
},
]
/**
* Individual pricing card component
* @param tier - The pricing tier data
* @param index - The index of the card in the grid
* @param isBeforeFeatured - Whether this card is immediately before a featured card
*/
function PricingCard({
tier,
index,
@@ -106,10 +108,8 @@ function PricingCard({
logger.info(`Pricing CTA clicked: ${tier.name}`)
if (tier.ctaText === 'Contact Sales') {
// Open enterprise form in new tab
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
} else {
// Navigate to signup page for all "Get Started" buttons
router.push('/signup')
}
}

View File

@@ -6,6 +6,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getPlanTierCredits } from '@/lib/billing/plan-helpers'
/**
* Gets the effective billing blocked status for a user.
@@ -204,10 +206,17 @@ export async function GET(request: NextRequest) {
averageUsagePerMember: rawBillingData.averageUsagePerMember,
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
members: rawBillingData.members.map((member) => ({
...member,
joinedAt: member.joinedAt.toISOString(),
lastActive: member.lastActive?.toISOString() || null,
tierCredits: getPlanTierCredits(rawBillingData.subscriptionPlan),
totalCurrentUsageCredits: dollarsToCredits(rawBillingData.totalCurrentUsage),
totalUsageLimitCredits: dollarsToCredits(rawBillingData.totalUsageLimit),
minimumBillingAmountCredits: dollarsToCredits(rawBillingData.minimumBillingAmount),
averageUsagePerMemberCredits: dollarsToCredits(rawBillingData.averageUsagePerMember),
members: rawBillingData.members.map((m) => ({
...m,
joinedAt: m.joinedAt.toISOString(),
lastActive: m.lastActive?.toISOString() || null,
currentUsageCredits: dollarsToCredits(m.currentUsage),
usageLimitCredits: dollarsToCredits(m.usageLimit),
})),
}

View File

@@ -0,0 +1,174 @@
import { db } from '@sim/db'
import { subscription as subscriptionTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('SwitchPlan')
const switchPlanSchema = z.object({
targetPlanName: z.string(),
interval: z.enum(['month', 'year']).optional(),
})
/**
* POST /api/billing/switch-plan
*
* Switches a subscription's tier and/or billing interval via direct Stripe API.
* Covers: Pro <-> Max, monthly <-> annual, and team tier changes.
* Uses proration -- no Billing Portal redirect.
*
* Body:
* targetPlanName: string -- e.g. 'pro_6000', 'team_25000'
* interval?: 'month' | 'year' -- if omitted, keeps the current interval
*/
export async function POST(request: NextRequest) {
const session = await getSession()
try {
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!isBillingEnabled) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 })
}
const body = await request.json()
const parsed = switchPlanSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { targetPlanName, interval } = parsed.data
const userId = session.user.id
const sub = await getHighestPrioritySubscription(userId)
if (!sub || !sub.stripeSubscriptionId) {
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
}
if (isEnterprise(sub.plan) || isEnterprise(targetPlanName)) {
return NextResponse.json(
{ error: 'Enterprise plan changes must be handled via support' },
{ status: 400 }
)
}
const targetPlan = getPlanByName(targetPlanName)
if (!targetPlan) {
return NextResponse.json({ error: 'Target plan not found' }, { status: 400 })
}
const currentPlanType = getPlanType(sub.plan)
const targetPlanType = getPlanType(targetPlanName)
if (currentPlanType !== targetPlanType) {
return NextResponse.json(
{ error: 'Cannot switch between individual and team plans via this endpoint' },
{ status: 400 }
)
}
if (isOrgPlan(sub.plan)) {
const hasPermission = await isOrganizationOwnerOrAdmin(userId, sub.referenceId)
if (!hasPermission) {
return NextResponse.json({ error: 'Only team admins can change the plan' }, { status: 403 })
}
}
const stripe = requireStripeClient()
const stripeSubscription = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId)
if (stripeSubscription.status !== 'active') {
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
}
const subscriptionItem = stripeSubscription.items.data[0]
if (!subscriptionItem) {
return NextResponse.json({ error: 'No subscription item found in Stripe' }, { status: 500 })
}
const currentInterval = subscriptionItem.price?.recurring?.interval
const targetInterval = interval ?? currentInterval ?? 'month'
const targetPriceId =
targetInterval === 'year' ? targetPlan.annualDiscountPriceId : targetPlan.priceId
if (!targetPriceId) {
return NextResponse.json(
{ error: `No ${targetInterval} price configured for plan ${targetPlanName}` },
{ status: 400 }
)
}
const alreadyOnStripePrice = subscriptionItem.price?.id === targetPriceId
const alreadyInDb = sub.plan === targetPlanName
if (alreadyOnStripePrice && alreadyInDb) {
return NextResponse.json({ success: true, message: 'Already on this plan and interval' })
}
logger.info('Switching subscription', {
userId,
subscriptionId: sub.id,
stripeSubscriptionId: sub.stripeSubscriptionId,
fromPlan: sub.plan,
toPlan: targetPlanName,
fromInterval: currentInterval,
toInterval: targetInterval,
targetPriceId,
})
if (!alreadyOnStripePrice) {
const currentQuantity = subscriptionItem.quantity ?? 1
await stripe.subscriptions.update(sub.stripeSubscriptionId, {
items: [
{
id: subscriptionItem.id,
price: targetPriceId,
quantity: currentQuantity,
},
],
proration_behavior: 'create_prorations',
})
}
if (!alreadyInDb) {
await db
.update(subscriptionTable)
.set({ plan: targetPlanName })
.where(eq(subscriptionTable.id, sub.id))
}
logger.info('Subscription switched successfully', {
userId,
subscriptionId: sub.id,
fromPlan: sub.plan,
toPlan: targetPlanName,
interval: targetInterval,
})
return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval })
} catch (error) {
logger.error('Failed to switch subscription', {
userId: session?.user?.id,
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to switch plan' },
{ status: 500 }
)
}
}

View File

@@ -23,6 +23,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
@@ -325,7 +326,7 @@ export async function PUT(
.limit(1)
const orgSub = orgSubs[0]
const orgIsPaid = orgSub && (orgSub.plan === 'team' || orgSub.plan === 'enterprise')
const orgIsPaid = orgSub && isOrgPlan(orgSub.plan)
if (orgIsPaid) {
const userId = session.user.id

View File

@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { isTeam } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -75,7 +76,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const orgSubscription = subscriptionRecord[0]
// Only team plans support seat changes (not enterprise - those are handled manually)
if (orgSubscription.plan !== 'team') {
if (!isTeam(orgSubscription.plan)) {
return NextResponse.json(
{ error: 'Seat changes are only available for Team plans' },
{ status: 400 }
@@ -174,7 +175,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
.where(eq(subscription.id, orgSubscription.id))
// Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum)
const { basePrice } = getPlanPricing('team')
const { basePrice } = getPlanPricing(orgSubscription.plan)
const newMinimumLimit = newSeatCount * basePrice
const orgData = await db

View File

@@ -24,6 +24,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
const logger = createLogger('ReferralCodeRedemption')
@@ -43,15 +44,15 @@ export async function POST(request: Request) {
const subscription = await getHighestPrioritySubscription(session.user.id)
if (subscription?.plan === 'enterprise') {
if (isEnterprise(subscription?.plan)) {
return NextResponse.json({
redeemed: false,
error: 'Enterprise accounts cannot redeem referral codes',
})
}
const isTeam = subscription?.plan === 'team'
const orgId = isTeam ? subscription.referenceId : null
const isTeamSub = isTeam(subscription?.plan)
const orgId = isTeamSub ? subscription!.referenceId : null
const normalizedCode = code.trim().toUpperCase()

View File

@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
const logger = createLogger('UsageLogsAPI')
@@ -78,6 +79,16 @@ export async function GET(req: NextRequest) {
cursor,
})
const logsWithCredits = result.logs.map((log) => ({
...log,
creditCost: dollarsToCredits(log.cost),
}))
const bySourceCredits: Record<string, number> = {}
for (const [src, cost] of Object.entries(result.summary.bySource)) {
bySourceCredits[src] = dollarsToCredits(cost)
}
logger.debug('Retrieved usage logs', {
userId,
source,
@@ -88,7 +99,13 @@ export async function GET(req: NextRequest) {
return NextResponse.json({
success: true,
...result,
logs: logsWithCredits,
summary: {
...result.summary,
totalCostCredits: dollarsToCredits(result.summary.totalCost),
bySourceCredits,
},
pagination: result.pagination,
})
} catch (error) {
logger.error('Failed to get usage logs', {

View File

@@ -16,7 +16,7 @@ Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over
- **Product Name**: Sim
- **Category**: AI Agent Platform / Agentic Workflow Orchestration
- **Deployment**: Cloud (SaaS) and Self-hosted options
- **Pricing**: Free tier, Pro ($20/month), Team ($40/month), Enterprise (custom)
- **Pricing**: Free tier, Pro ($25/month, 6K credits), Max ($100/month, 25K credits), Team plans available, Enterprise (custom)
- **Compliance**: SOC2 Type II, HIPAA compliant
## Core Concepts

View File

@@ -80,7 +80,8 @@ export const metadata: Metadata = {
'AI agents, agentic workforce, agentic workflows, knowledge bases, tables, document creation, email automation, Slack bots, data analysis, customer support, content generation',
'llm:integrations':
'OpenAI, Anthropic, Google AI, Mistral, xAI, Perplexity, Slack, Gmail, Discord, Notion, Airtable, Supabase',
'llm:pricing': 'free tier available, pro $20/month, team $40/month, enterprise custom',
'llm:pricing':
'free tier available, pro $25/month, max $100/month, team plans available, enterprise custom',
'llm:region': 'global',
'llm:languages': 'en',
},

View File

@@ -5,6 +5,7 @@ import { ArrowUpRight, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, buttonVariants } from '@/components/emcn'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import {
@@ -122,7 +123,9 @@ const LogRow = memo(
<span
className={`${LOG_COLUMNS.cost.width} ${LOG_COLUMNS.cost.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
>
{typeof log.cost?.total === 'number' ? `$${log.cost.total.toFixed(4)}` : '—'}
{typeof log.cost?.total === 'number'
? `${dollarsToCredits(log.cost.total).toLocaleString()} credits`
: '—'}
</span>
<div className={`${LOG_COLUMNS.trigger.width} ${LOG_COLUMNS.trigger.minWidth}`}>

View File

@@ -23,6 +23,7 @@ import {
} from '@/components/emcn'
import { SlackIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import {
type NotificationSubscription,
@@ -71,7 +72,7 @@ const ALERT_RULES: { value: AlertRule; label: string; description: string }[] =
{
value: 'cost_threshold',
label: 'Cost Threshold',
description: 'When execution cost exceeds $',
description: 'When execution cost exceeds credits',
},
{ value: 'no_activity', label: 'No Activity', description: 'When no executions in time window' },
{ value: 'error_count', label: 'Error Count', description: 'When errors exceed count in window' },
@@ -106,7 +107,7 @@ function formatAlertConfigLabel(config: {
case 'latency_spike':
return `${config.latencySpikePercent}% above avg in ${config.windowHours}h`
case 'cost_threshold':
return `>$${config.costThresholdDollars} per execution`
return `>${dollarsToCredits(config.costThresholdDollars ?? 0).toLocaleString()} credits per execution`
case 'no_activity':
return `No activity in ${config.inactivityHours}h`
case 'error_count':

View File

@@ -1,291 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import {
Button,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { useSession, useSubscription } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('CancelSubscription')
interface SubscriptionCancelParams {
returnUrl: string
referenceId: string
subscriptionId?: string
}
interface SubscriptionRestoreParams {
referenceId: string
subscriptionId?: string
}
interface CancelSubscriptionProps {
subscription: {
plan: string
status: string | null
isPaid: boolean
}
subscriptionData?: {
periodEnd?: Date | null
cancelAtPeriodEnd?: boolean
}
}
/**
* Manages subscription cancellation and restoration.
*/
export function CancelSubscription({ subscription, subscriptionData }: CancelSubscriptionProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { data: session } = useSession()
const betterAuthSubscription = useSubscription()
const { data: orgsData } = useOrganizations()
const { data: subData } = useSubscriptionData()
const queryClient = useQueryClient()
const activeOrganization = orgsData?.activeOrganization
const currentSubscriptionStatus = getSubscriptionStatus(subData?.data)
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
setError(null)
}, 3000)
return () => clearTimeout(timer)
}
}, [error])
if (!subscription.isPaid) {
return null
}
const handleCancel = async () => {
if (!session?.user?.id) return
setIsLoading(true)
setError(null)
try {
const subscriptionStatus = currentSubscriptionStatus
const activeOrgId = activeOrganization?.id
let referenceId = session.user.id
let subscriptionId: string | undefined
if (subscriptionStatus.isTeam && activeOrgId) {
referenceId = activeOrgId
subscriptionId = subData?.data?.id
}
logger.info('Canceling subscription', {
referenceId,
subscriptionId,
isTeam: subscriptionStatus.isTeam,
activeOrgId,
})
if (!betterAuthSubscription.cancel) {
throw new Error('Subscription management not available')
}
const returnUrl = getBaseUrl() + window.location.pathname.split('/w/')[0]
const cancelParams: SubscriptionCancelParams = {
returnUrl,
referenceId,
...(subscriptionId && { subscriptionId }),
}
const result = await betterAuthSubscription.cancel(cancelParams)
if (result && 'error' in result && result.error) {
setError(result.error.message || 'Failed to cancel subscription')
logger.error('Failed to cancel subscription via Better Auth', { error: result.error })
} else {
logger.info('Redirecting to Stripe Billing Portal for cancellation')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to cancel subscription'
setError(errorMessage)
logger.error('Failed to cancel subscription', { error: err })
} finally {
setIsLoading(false)
}
}
const handleKeep = async () => {
if (!session?.user?.id) return
setIsLoading(true)
setError(null)
try {
const subscriptionStatus = currentSubscriptionStatus
const activeOrgId = activeOrganization?.id
if (isCancelAtPeriodEnd) {
if (!betterAuthSubscription.restore) {
throw new Error('Subscription restore not available')
}
let referenceId: string
let subscriptionId: string | undefined
if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) {
referenceId = activeOrgId
subscriptionId = subData?.data?.id
} else {
referenceId = session.user.id
subscriptionId = undefined
}
logger.info('Restoring subscription', { referenceId, subscriptionId })
const restoreParams: SubscriptionRestoreParams = {
referenceId,
...(subscriptionId && { subscriptionId }),
}
const result = await betterAuthSubscription.restore(restoreParams)
logger.info('Subscription restored successfully', result)
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }),
...(activeOrgId
? [
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) }),
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(activeOrgId) }),
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }),
]
: []),
])
setIsDialogOpen(false)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to restore subscription'
setError(errorMessage)
logger.error('Failed to restore subscription', { error: err })
} finally {
setIsLoading(false)
}
}
const getPeriodEndDate = () => {
return subscriptionData?.periodEnd || null
}
const formatDate = (date: Date | null) => {
if (!date) return 'end of current billing period'
try {
const dateObj = date instanceof Date ? date : new Date(date)
if (Number.isNaN(dateObj.getTime())) {
return 'end of current billing period'
}
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(dateObj)
} catch (err) {
logger.warn('Invalid date in cancel subscription', { date, error: err })
return 'end of current billing period'
}
}
const periodEndDate = getPeriodEndDate()
const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
return (
<>
<div className='flex items-center justify-between'>
<div className='flex flex-col gap-[2px]'>
<Label>{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}</Label>
{isCancelAtPeriodEnd && (
<span className='text-[13px] text-[var(--text-muted)]'>
You'll keep access until {formatDate(periodEndDate)}
</span>
)}
</div>
<Button
variant='active'
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
className={cn(
'h-[32px] rounded-[6px] text-[13px]',
error && 'border-[var(--text-error)] text-[var(--text-error)]'
)}
>
{error ? 'Error' : isCancelAtPeriodEnd ? 'Restore' : 'Manage'}
</Button>
</div>
<Modal open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<ModalContent size='sm'>
<ModalHeader>
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} Subscription
</ModalHeader>
<ModalBody>
<p className='text-[13px] text-[var(--text-secondary)]'>
{isCancelAtPeriodEnd
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
periodEndDate
)}, then downgrade to free plan. You can restore your subscription at any time.`}
</p>
{!isCancelAtPeriodEnd && (
<div className='mt-[12px]'>
<div className='rounded-[6px] bg-[var(--surface-4)] p-[12px]'>
<ul className='space-y-[4px] text-[13px] text-[var(--text-secondary)]'>
<li>- Keep all features until {formatDate(periodEndDate)}</li>
<li>- No more charges</li>
<li>- Data preserved</li>
<li>- Can reactivate anytime</li>
</ul>
</div>
</div>
)}
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={isCancelAtPeriodEnd ? () => setIsDialogOpen(false) : handleKeep}
disabled={isLoading}
>
{isCancelAtPeriodEnd ? 'Cancel' : 'Keep Subscription'}
</Button>
{currentSubscriptionStatus.isPaid && isCancelAtPeriodEnd ? (
<Button variant='tertiary' onClick={handleKeep} disabled={isLoading}>
{isLoading ? 'Restoring...' : 'Restore Subscription'}
</Button>
) : (
<Button variant='destructive' onClick={handleCancel} disabled={isLoading}>
{isLoading ? 'Redirecting...' : 'Continue'}
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1 +0,0 @@
export { CancelSubscription } from './cancel-subscription'

View File

@@ -13,6 +13,7 @@ import {
ModalHeader,
ModalTrigger,
} from '@/components/emcn'
import { dollarsToCredits, formatCredits } from '@/lib/billing/credits/conversion'
import { usePurchaseCredits } from '@/hooks/queries/subscription'
interface CreditBalanceProps {
@@ -39,6 +40,9 @@ export function CreditBalance({
const [requestId, setRequestId] = useState<string | null>(null)
const purchaseCredits = usePurchaseCredits()
const dollarAmount = Number.parseInt(amount, 10) || 0
const creditPreview = dollarsToCredits(dollarAmount)
const handleAmountChange = (value: string) => {
const numericValue = value.replace(/[^0-9]/g, '')
setAmount(numericValue)
@@ -90,9 +94,9 @@ export function CreditBalance({
return (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<Label>Credit Balance:</Label>
<Label>Additional Credits Balance:</Label>
<span className='text-[13px] text-[var(--text-secondary)]'>
{isLoading ? '...' : `$${balance.toFixed(2)}`}
{isLoading ? '...' : `${formatCredits(balance)} credits`}
</span>
</div>
@@ -129,16 +133,16 @@ export function CreditBalance({
disabled={purchaseCredits.isPending}
/>
</div>
{dollarAmount > 0 && !displayError && (
<span className='text-[12px] text-[var(--text-secondary)]'>
You'll receive {creditPreview.toLocaleString()} credits
</span>
)}
{displayError && (
<span className='text-[13px] text-[var(--text-error)]'>{displayError}</span>
)}
</div>
<div className='rounded-[6px] bg-[var(--surface-4)] p-[12px]'>
<p className='text-[13px] text-[var(--text-secondary)]'>
Credits are used before overage charges. Min: $10, Max: $1,000.
</p>
</div>
<div className='rounded-[6px] bg-[var(--surface-4)] p-[12px]'>
<p className='text-[13px] text-[var(--text-secondary)]'>
Credits are non-refundable and don't expire. They'll be applied automatically

View File

@@ -1,4 +1,3 @@
export { CancelSubscription } from './cancel-subscription'
export { CreditBalance } from './credit-balance'
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
export { ReferralCode } from './referral-code'

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'
import { Button, Input, Label } from '@/components/emcn'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { useRedeemReferralCode } from '@/hooks/queries/subscription'
interface ReferralCodeProps {
@@ -36,7 +37,7 @@ export function ReferralCode({ onRedeemComplete }: ReferralCodeProps) {
<div className='flex items-center justify-between'>
<Label>Referral Code</Label>
<span className='text-[13px] text-[var(--text-secondary)]'>
+${redeemCode.data.bonusAmount ?? 0} credits applied
+{dollarsToCredits(redeemCode.data.bonusAmount ?? 0).toLocaleString()} credits applied
</span>
</div>
)

View File

@@ -1,6 +1,5 @@
import {
Clock,
Database,
HardDrive,
HeadphonesIcon,
Server,
@@ -13,28 +12,31 @@ import { SlackMonoIcon } from '@/components/icons'
import type { PlanFeature } from '@/app/workspace/[workspaceId]/settings/components/subscription/components/plan-card'
export const PRO_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '150 runs per minute (sync)' },
{ icon: Clock, text: '1,000 runs per minute (async)' },
{ icon: Zap, text: '150 runs/min (sync)' },
{ icon: Clock, text: '1,000 runs/min (async)' },
{ icon: Timer, text: '50 min sync execution limit' },
{ icon: HardDrive, text: '50GB file storage' },
{ icon: Users, text: 'Unlimited invites' },
{ icon: Database, text: 'Unlimited log retention' },
]
export const TEAM_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '300 runs per minute (sync)' },
{ icon: Clock, text: '2,500 runs per minute (async)' },
export const MAX_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '300 runs/min (sync)' },
{ icon: Clock, text: '2,500 runs/min (async)' },
{ icon: Timer, text: '50 min sync execution limit' },
{ icon: HardDrive, text: '500GB file storage (pooled)' },
{ icon: Users, text: 'Unlimited invites' },
{ icon: Database, text: 'Unlimited log retention' },
{ icon: HardDrive, text: '500GB file storage' },
]
export const TEAM_INLINE_FEATURES: PlanFeature[] = [
{ icon: Users, text: 'Shared credit pool' },
{ icon: Zap, text: 'Max plan rate limits' },
{ icon: HardDrive, text: 'Max plan file storage' },
{ icon: ShieldCheck, text: 'Access controls' },
{ icon: SlackMonoIcon, text: 'Dedicated Slack channel' },
]
export const ENTERPRISE_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: 'Custom rate limits' },
{ icon: HardDrive, text: 'Custom file storage' },
{ icon: Zap, text: 'Custom infra limits' },
{ icon: Server, text: 'SSO' },
{ icon: ShieldCheck, text: 'SOC2' },
{ icon: HardDrive, text: 'Self hosting' },
{ icon: HeadphonesIcon, text: 'Dedicated support' },
]

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Avatar, AvatarFallback, AvatarImage, Badge, Button } from '@/components/emcn'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getUserColor } from '@/lib/workspaces/colors'
import type { Invitation, Member, Organization } from '@/lib/workspaces/organization'
import {
@@ -88,7 +89,7 @@ export function TeamMembers({
avatarInitial: name.charAt(0).toUpperCase(),
avatarUrl: member.user?.image,
userId: member.user?.id,
usage: `$${usageAmount.toFixed(2)}`,
usage: `${dollarsToCredits(usageAmount).toLocaleString()} credits`,
role: member.role,
member,
}

View File

@@ -11,7 +11,6 @@ import {
ModalHeader,
Tooltip,
} from '@/components/emcn'
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
interface TeamSeatsProps {
open: boolean
@@ -26,6 +25,8 @@ interface TeamSeatsProps {
confirmButtonText: string
showCostBreakdown?: boolean
isCancelledAtPeriodEnd?: boolean
costPerSeatDollars: number
creditsPerSeat: number
}
export function TeamSeats({
@@ -41,6 +42,8 @@ export function TeamSeats({
confirmButtonText,
showCostBreakdown = false,
isCancelledAtPeriodEnd = false,
costPerSeatDollars,
creditsPerSeat: creditsPerSeatProp,
}: TeamSeatsProps) {
const [selectedSeats, setSelectedSeats] = useState(initialSeats)
@@ -50,7 +53,8 @@ export function TeamSeats({
}
}, [open, initialSeats])
const costPerSeat = DEFAULT_TEAM_TIER_COST_LIMIT
const costPerSeat = costPerSeatDollars
const seatCredits = creditsPerSeatProp
const totalMonthlyCost = selectedSeats * costPerSeat
const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0
@@ -87,7 +91,7 @@ export function TeamSeats({
<p className='mt-[12px] text-[13px] text-[var(--text-muted)]'>
Your team will have {selectedSeats} {selectedSeats === 1 ? 'seat' : 'seats'} with a
total of ${totalMonthlyCost} inference credits per month.
total of {(selectedSeats * seatCredits).toLocaleString()} inference credits per month.
</p>
{showCostBreakdown && currentSeats !== undefined && (
@@ -101,9 +105,15 @@ export function TeamSeats({
<span className='text-[var(--text-primary)]'>{selectedSeats}</span>
</div>
<div className='mt-[12px] flex justify-between border-[var(--border-1)] border-t pt-[12px] text-[13px]'>
<span className='font-medium text-[var(--text-primary)]'>Monthly cost change:</span>
<span className='font-medium text-[var(--text-primary)]'>
{costChange > 0 ? '+' : ''}${costChange}
Monthly credit change:
</span>
<span className='font-medium text-[var(--text-primary)]'>
{costChange > 0 ? '+' : ''}
{(
(currentSeats ? selectedSeats - currentSeats : 0) * seatCredits
).toLocaleString()}{' '}
credits
</span>
</div>
</div>

View File

@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
import type { TagItem } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
import { getPlanTierCredits, getPlanTierDollars } from '@/lib/billing/plan-helpers'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import {
generateSlug,
@@ -68,6 +68,9 @@ export function TeamManagement() {
const updateSeatsMutation = useUpdateSeats()
const createOrgMutation = useCreateOrganization()
const costPerSeat = getPlanTierDollars(subscriptionData?.plan)
const creditsPerSeat = getPlanTierCredits(subscriptionData?.plan)
const [inviteSuccess, setInviteSuccess] = useState(false)
const [inviteEmails, setInviteEmails] = useState<TagItem[]>([])
@@ -493,8 +496,8 @@ export function TeamManagement() {
<ul className='ml-4 flex list-disc flex-col gap-[8px] text-[13px] text-[var(--text-muted)]'>
<li>
Your team is billed a minimum of $
{(subscriptionData?.seats ?? 0) * DEFAULT_TEAM_TIER_COST_LIMIT}
/month for {subscriptionData?.seats ?? 0} licensed seats
{((subscriptionData?.seats ?? 0) * costPerSeat).toLocaleString()}/month for{' '}
{subscriptionData?.seats ?? 0} licensed seats
</li>
<li>All team member usage is pooled together from a shared limit</li>
<li>
@@ -544,7 +547,7 @@ export function TeamManagement() {
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.`}
description={`Each seat costs $${costPerSeat}/month and provides ${creditsPerSeat.toLocaleString()} monthly inference credits. Adjust the number of licensed seats for your team.`}
currentSeats={totalSeats}
initialSeats={newSeatCount}
isLoading={isUpdatingSeats}
@@ -556,6 +559,8 @@ export function TeamManagement() {
confirmButtonText='Update Seats'
showCostBreakdown={true}
isCancelledAtPeriodEnd={subscriptionData?.cancelAtPeriodEnd}
costPerSeatDollars={costPerSeat}
creditsPerSeat={creditsPerSeat}
/>
)}
</div>

View File

@@ -3,6 +3,7 @@
import type { ReactNode } from 'react'
import { Badge } from '@/components/emcn'
import { getFilledPillColor, USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client'
import { formatCredits } from '@/lib/billing/credits/conversion'
const PILL_COUNT = 5
@@ -63,12 +64,12 @@ export function UsageHeader({
) : (
<>
<span className='font-medium text-[15px] text-[var(--text-primary)] tabular-nums'>
${current.toFixed(2)}
{formatCredits(current)}
</span>
<span className='font-medium text-[15px] text-[var(--text-primary)]'>/</span>
{rightContent ?? (
<span className='font-medium text-[15px] text-[var(--text-primary)] tabular-nums'>
${limit.toFixed(2)}
{formatCredits(limit)} credits
</span>
)}
</>

View File

@@ -4,6 +4,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 're
import { createLogger } from '@sim/logger'
import { Check, X } from 'lucide-react'
import { Badge, Button } from '@/components/emcn'
import { formatCredits } from '@/lib/billing/credits/conversion'
import { cn } from '@/lib/core/utils/cn'
import { useUpdateOrganizationUsageLimit } from '@/hooks/queries/organization'
import { useUpdateUsageLimit } from '@/hooks/queries/subscription'
@@ -96,7 +97,7 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
}, [hasError])
const handleSubmit = async () => {
const newLimit = Number.parseInt(inputValue, 10)
const newLimit = Number.parseFloat(inputValue)
if (Number.isNaN(newLimit) || newLimit < minimumLimit) {
setInputValue(currentLimit.toString())
@@ -169,15 +170,13 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
}
}
const inputWidthCh = Math.max(3, inputValue.length + 1)
const inputWidthCh = Math.max(3, inputValue.length + 2)
return (
<div className='flex items-center gap-[6px]'>
{isEditing ? (
<>
<span className='font-medium text-[15px] text-[var(--text-primary)] tabular-nums'>
$
</span>
<span className='font-medium text-[15px] text-[var(--text-muted)]'>$</span>
<input
ref={inputRef}
type='number'
@@ -198,7 +197,7 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
hasError && 'text-[var(--text-error)]'
)}
min={minimumLimit}
step='1'
step='0.01'
disabled={isUpdating}
autoComplete='off'
autoCorrect='off'
@@ -223,7 +222,7 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
) : (
<>
<span className='font-medium text-[15px] text-[var(--text-primary)] tabular-nums'>
${pendingLimit !== null ? pendingLimit : currentLimit}
{formatCredits(pendingLimit !== null ? pendingLimit : currentLimit)} credits
</span>
{canEdit && (
<Badge

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/emcn'
import { formatCredits } from '@/lib/billing/credits/conversion'
import { canEditUsageLimit } from '@/lib/billing/subscriptions/utils'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
@@ -28,7 +29,7 @@ export function UsageLimitActions() {
const [selectedAmount, setSelectedAmount] = useState<number | null>(null)
const [isHidden, setIsHidden] = useState(false)
const currentLimit = subscription?.usage_limit ?? 0
const currentLimit = subscription?.usageLimit ?? 0
const baseLimit = roundUpToNearest50(currentLimit) || 50
const limitOptions = LIMIT_INCREMENTS.map((increment) => baseLimit + increment)
@@ -94,7 +95,8 @@ export function UsageLimitActions() {
disabled={isDisabled}
variant='default'
>
{isLoading ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}${limit}
{isLoading ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}
{formatCredits(limit)}
</Button>
)
})}

View File

@@ -13,6 +13,7 @@ import {
getSubscriptionStatus,
getUsage,
} from '@/lib/billing/client/utils'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
@@ -153,7 +154,7 @@ function getStatusTextConfig(
}
}
return {
text: `$${usage.current.toFixed(2)} / $${usage.limit.toFixed(2)}`,
text: `${dollarsToCredits(usage.current).toLocaleString()} / ${dollarsToCredits(usage.limit).toLocaleString()} credits`,
isError: false,
}
}
@@ -504,11 +505,11 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
) : (
<>
<span className='font-base text-[12px] text-[var(--text-secondary)] tabular-nums'>
${usage.current.toFixed(2)}
{dollarsToCredits(usage.current).toLocaleString()}
</span>
<span className='font-base text-[12px] text-[var(--text-secondary)]'>/</span>
<span className='font-base text-[12px] text-[var(--text-secondary)] tabular-nums'>
${usage.limit.toFixed(2)}
{dollarsToCredits(usage.limit).toLocaleString()} credits
</span>
</>
)}

View File

@@ -17,6 +17,7 @@ import {
} from '@/components/emails'
import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
import { formatDuration } from '@/lib/core/utils/formatting'
@@ -238,7 +239,7 @@ async function deliverWebhook(
function formatCost(cost?: Record<string, unknown>): string {
if (!cost?.total) return 'N/A'
const total = cost.total as number
return `$${total.toFixed(4)}`
return `${dollarsToCredits(total).toLocaleString()} credits`
}
function buildLogUrl(workspaceId: string, executionId: string): string {
@@ -256,7 +257,7 @@ function formatAlertReason(alertConfig: AlertConfig): string {
case 'latency_spike':
return `Execution was ${alertConfig.latencySpikePercent}% slower than average`
case 'cost_threshold':
return `Execution cost exceeded $${alertConfig.costThresholdDollars} threshold`
return `Execution cost exceeded ${dollarsToCredits(alertConfig.costThresholdDollars || 0).toLocaleString()} credits threshold`
case 'no_activity':
return `No workflow activity detected in ${alertConfig.inactivityHours}h`
case 'error_count':

View File

@@ -44,7 +44,7 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) {
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
You're on the free plan with $20 in credits to get started.
You're on the free plan with 3,000 credits to get started.
</Text>
</EmailLayout>
)

View File

@@ -1,6 +1,7 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
@@ -20,7 +21,7 @@ export function CreditPurchaseEmail({
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
const previewText = `${brand.name}: ${dollarsToCredits(amount).toLocaleString()} credits added to your account`
return (
<EmailLayout preview={previewText} showUnsubscribe={false}>
@@ -28,7 +29,8 @@ export function CreditPurchaseEmail({
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Your credit purchase of <strong>${amount.toFixed(2)}</strong> has been confirmed.
Your credit purchase of <strong>{dollarsToCredits(amount).toLocaleString()} credits</strong>{' '}
has been confirmed.
</Text>
<Section style={baseStyles.infoBox}>
@@ -51,7 +53,7 @@ export function CreditPurchaseEmail({
fontFamily: baseStyles.fontFamily,
}}
>
${amount.toFixed(2)}
{dollarsToCredits(amount).toLocaleString()} credits
</Text>
<Text
style={{
@@ -72,7 +74,7 @@ export function CreditPurchaseEmail({
fontFamily: baseStyles.fontFamily,
}}
>
${newBalance.toFixed(2)}
{dollarsToCredits(newBalance).toLocaleString()} credits
</Text>
</Section>

View File

@@ -1,6 +1,7 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors, typography } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBrandConfig } from '@/ee/whitelabeling'
interface FreeTierUpgradeEmailProps {
@@ -12,11 +13,10 @@ interface FreeTierUpgradeEmailProps {
}
const proFeatures = [
{ label: '$20/month', desc: 'in credits included' },
{ label: '6,000 credits/month', desc: 'included' },
{ label: '+50 daily refresh', desc: 'credits per day' },
{ label: '150 runs/min', desc: 'sync executions' },
{ label: '1,000 runs/min', desc: 'async executions' },
{ label: '50GB storage', desc: 'for files & assets' },
{ label: 'Unlimited', desc: 'workspaces & invites' },
]
export function FreeTierUpgradeEmail({
@@ -37,9 +37,9 @@ export function FreeTierUpgradeEmail({
</Text>
<Text style={baseStyles.paragraph}>
You've used <strong>${currentUsage.toFixed(2)}</strong> of your{' '}
<strong>${limit.toFixed(2)}</strong> free credits ({percentUsed}%). Upgrade to Pro to keep
building without interruption.
You've used <strong>{dollarsToCredits(currentUsage).toLocaleString()}</strong> of your{' '}
<strong>{dollarsToCredits(limit).toLocaleString()}</strong> free credits ({percentUsed}%).
Upgrade to Pro to keep building without interruption.
</Text>
{/* Pro Features */}

View File

@@ -5,7 +5,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface PlanWelcomeEmailProps {
planName: 'Pro' | 'Team'
planName: string
userName?: string
loginLink?: string
}

View File

@@ -1,6 +1,7 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBrandConfig } from '@/ee/whitelabeling'
interface UsageThresholdEmailProps {
@@ -37,7 +38,8 @@ export function UsageThresholdEmail({
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
<Text style={baseStyles.infoBoxList}>
${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
{dollarsToCredits(currentUsage).toLocaleString()} of{' '}
{dollarsToCredits(limit).toLocaleString()} credits used ({percentUsed}%)
</Text>
</Section>

View File

@@ -1,6 +1,7 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBrandConfig } from '@/ee/whitelabeling'
/**
@@ -130,7 +131,8 @@ export function WorkflowNotificationEmail({
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
<Text style={baseStyles.infoBoxList}>
${usageData.currentPeriodCost.toFixed(2)} of ${usageData.limit.toFixed(2)} used (
{dollarsToCredits(usageData.currentPeriodCost).toLocaleString()} of{' '}
{dollarsToCredits(usageData.limit).toLocaleString()} credits used (
{usageData.percentUsed.toFixed(1)}%)
</Text>
</Section>

View File

@@ -142,7 +142,7 @@ export async function renderFreeTierUpgradeEmail(params: {
}
export async function renderPlanWelcomeEmail(params: {
planName: 'Pro' | 'Team'
planName: string
userName?: string
loginLink?: string
}): Promise<string> {

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client } from '@/lib/auth/auth-client'
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
const logger = createLogger('OrganizationQueries')
@@ -86,10 +87,10 @@ async function fetchOrganizationSubscription(orgId: string, _signal?: AbortSigna
}
const teamSubscription = response.data?.find(
(sub: any) => sub.status === 'active' && sub.plan === 'team'
(sub: any) => sub.status === 'active' && isTeam(sub.plan)
)
const enterpriseSubscription = response.data?.find(
(sub: any) => sub.plan === 'enterprise' || sub.plan === 'enterprise-plus'
(sub: any) => isEnterprise(sub.plan) || sub.plan === 'enterprise-plus'
)
const activeSubscription = enterpriseSubscription || teamSubscription

View File

@@ -1,6 +1,86 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { organizationKeys } from '@/hooks/queries/organization'
/**
* Shape of the usage object returned from the billing API (user context)
*/
export interface BillingUsageData {
current: number
limit: number
percentUsed: number
isWarning: boolean
isExceeded: boolean
billingPeriodStart: string | null
billingPeriodEnd: string | null
lastPeriodCost: number
lastPeriodCopilotCost: number
daysRemaining: number
copilotCost: number
currentCredits: number
limitCredits: number
lastPeriodCostCredits: number
lastPeriodCopilotCostCredits: number
copilotCostCredits: number
}
/**
* Shape of the billing data returned for the user context
*/
export interface SubscriptionBillingData {
type: 'individual' | 'organization'
plan: string
basePrice: number
currentUsage: number
overageAmount: number
totalProjected: number
usageLimit: number
percentUsed: number
isWarning: boolean
isExceeded: boolean
daysRemaining: number
creditBalance: number
billingInterval: 'month' | 'year'
tierCredits: number
basePriceCredits: number
currentUsageCredits: number
overageAmountCredits: number
totalProjectedCredits: number
usageLimitCredits: number
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
status: string | null
seats: number | null
stripeSubscriptionId: string | null
periodEnd: string | null
cancelAtPeriodEnd?: boolean
usage: BillingUsageData
billingBlocked?: boolean
billingBlockedReason?: 'payment_failed' | 'dispute' | null
blockedByOrgOwner?: boolean
organization?: { id: string; role: 'owner' | 'admin' | 'member' }
organizationData?: {
seatCount: number
memberCount: number
totalBasePrice: number
totalCurrentUsage: number
totalOverage: number
totalBasePriceCredits: number
totalCurrentUsageCredits: number
totalOverageCredits: number
}
}
/**
* Shape of the full API response from GET /api/billing?context=user
*/
export interface SubscriptionApiResponse {
success: boolean
context: string
data: SubscriptionBillingData
}
/**
* Query key factories for subscription-related queries
*/
@@ -15,7 +95,10 @@ export const subscriptionKeys = {
* Fetch user subscription data
* @param includeOrg - Whether to include organization role data
*/
async function fetchSubscriptionData(includeOrg = false, signal?: AbortSignal) {
async function fetchSubscriptionData(
includeOrg = false,
signal?: AbortSignal
): Promise<SubscriptionApiResponse> {
const params = new URLSearchParams({ context: 'user' })
if (includeOrg) params.set('includeOrg', 'true')

View File

@@ -38,6 +38,7 @@ import {
ensureOrganizationForTeamSubscription,
syncSubscriptionUsageLimits,
} from '@/lib/billing/organization'
import { isOrgPlan, isTeam } from '@/lib/billing/plan-helpers'
import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans'
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
@@ -2717,11 +2718,11 @@ export const auth = betterAuth({
subscription: {
enabled: true,
plans: getPlans(),
authorizeReference: async ({ user, referenceId }) => {
return await authorizeSubscriptionReference(user.id, referenceId)
authorizeReference: async ({ user, referenceId, action }) => {
return await authorizeSubscriptionReference(user.id, referenceId, action)
},
getCheckoutSessionParams: async ({ plan, subscription }) => {
if (plan.name === 'team') {
if (isTeam(plan.name)) {
return {
params: {
allow_promotion_codes: true,
@@ -2754,7 +2755,7 @@ export const auth = betterAuth({
stripeSubscription: Stripe.Subscription
subscription: any
}) => {
const { priceId, planFromStripe, isTeamPlan } =
const { priceId, planFromStripe, isTeamPlan, isAnnual } =
resolvePlanFromStripeSubscription(stripeSubscription)
logger.info('[onSubscriptionComplete] Subscription created', {
@@ -2763,18 +2764,25 @@ export const auth = betterAuth({
dbPlan: subscription.plan,
planFromStripe,
priceId,
isAnnual,
status: subscription.status,
})
const subscriptionForOrgCreation = isTeamPlan
? { ...subscription, plan: 'team' }
: subscription
if (!planFromStripe) {
logger.error(
'[onSubscriptionComplete] Could not resolve plan from Stripe price — check env var configuration',
{ subscriptionId: subscription.id, dbPlan: subscription.plan, priceId }
)
}
const subscriptionForOrg = {
...subscription,
plan: planFromStripe ?? subscription.plan,
}
let resolvedSubscription = subscription
try {
resolvedSubscription = await ensureOrganizationForTeamSubscription(
subscriptionForOrgCreation
)
resolvedSubscription =
await ensureOrganizationForTeamSubscription(subscriptionForOrg)
} catch (orgError) {
logger.error(
'[onSubscriptionComplete] Failed to ensure organization for team subscription',
@@ -2804,7 +2812,7 @@ export const auth = betterAuth({
subscription: any
}) => {
const stripeSubscription = event.data.object as Stripe.Subscription
const { priceId, planFromStripe, isTeamPlan } =
const { priceId, planFromStripe, isTeamPlan, isAnnual } =
resolvePlanFromStripeSubscription(stripeSubscription)
if (priceId && !planFromStripe) {
@@ -2820,7 +2828,7 @@ export const auth = betterAuth({
const isUpgradeToTeam =
isTeamPlan &&
subscription.plan !== 'team' &&
!isTeam(subscription.plan) &&
!subscription.referenceId.startsWith('org_')
const effectivePlanForTeamFeatures = planFromStripe ?? subscription.plan
@@ -2831,18 +2839,25 @@ export const auth = betterAuth({
dbPlan: subscription.plan,
planFromStripe,
isUpgradeToTeam,
isAnnual,
referenceId: subscription.referenceId,
})
const subscriptionForOrgCreation = isUpgradeToTeam
? { ...subscription, plan: 'team' }
: subscription
if (!planFromStripe) {
logger.error(
'[onSubscriptionUpdate] Could not resolve plan from Stripe price — org creation may be skipped for team upgrades',
{ subscriptionId: subscription.id, dbPlan: subscription.plan }
)
}
const subscriptionForOrg = {
...subscription,
plan: planFromStripe ?? subscription.plan,
}
let resolvedSubscription = subscription
try {
resolvedSubscription = await ensureOrganizationForTeamSubscription(
subscriptionForOrgCreation
)
resolvedSubscription =
await ensureOrganizationForTeamSubscription(subscriptionForOrg)
if (isUpgradeToTeam) {
logger.info(
@@ -2881,7 +2896,7 @@ export const auth = betterAuth({
})
}
if (effectivePlanForTeamFeatures === 'team') {
if (isTeam(effectivePlanForTeamFeatures)) {
try {
const quantity = stripeSubscription.items?.data?.[0]?.quantity || 1
@@ -3000,8 +3015,7 @@ export const auth = betterAuth({
.where(eq(schema.subscription.referenceId, user.id))
const hasTeamPlan = dbSubscriptions.some(
(sub) =>
sub.status === 'active' && (sub.plan === 'team' || sub.plan === 'enterprise')
(sub) => sub.status === 'active' && isOrgPlan(sub.plan)
)
return hasTeamPlan

View File

@@ -16,15 +16,15 @@ const logger = createLogger('BillingAuthorization')
*/
export async function authorizeSubscriptionReference(
userId: string,
referenceId: string
referenceId: string,
action?: string
): Promise<boolean> {
// User can always manage their own subscriptions (Pro upgrades, etc.)
if (referenceId === userId) {
return true
}
// For organizations: check for existing active subscriptions to prevent duplicates
if (await hasActiveSubscription(referenceId)) {
// Only block duplicate subscriptions during upgrade/checkout, not cancel/restore/list
if (action === 'upgrade-subscription' && (await hasActiveSubscription(referenceId))) {
logger.warn('Blocking checkout - active subscription already exists for organization', {
userId,
referenceId,
@@ -32,7 +32,6 @@ export async function authorizeSubscriptionReference(
return false
}
// Check if referenceId is an organizationId the user has admin rights to
const members = await db
.select()
.from(schema.member)
@@ -40,6 +39,5 @@ export async function authorizeSubscriptionReference(
const member = members[0]
// Allow if the user is an owner or admin of the organization
return member?.role === 'owner' || member?.role === 'admin'
}

View File

@@ -4,6 +4,8 @@ import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getUserUsageLimit } from '@/lib/billing/core/usage'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('UsageMonitor')
@@ -65,15 +67,34 @@ export async function checkUsageStatus(
}
}
// Get the current period cost from the user stats (use currentPeriodCost if available, fallback to totalCost)
const currentUsage = Number.parseFloat(
const rawUsage = Number.parseFloat(
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
)
// Calculate percentage used
// Deduct daily refresh credits for individual paid plans only.
// Org plans apply refresh at the pooled level in the org usage check below.
let dailyRefreshDeduction = 0
if (
preloadedSubscription &&
isPaid(preloadedSubscription.plan) &&
!isOrgPlan(preloadedSubscription.plan) &&
preloadedSubscription.periodStart
) {
const planDollars = getPlanTierDollars(preloadedSubscription.plan)
if (planDollars > 0) {
dailyRefreshDeduction = await computeDailyRefreshConsumed({
userIds: [userId],
periodStart: preloadedSubscription.periodStart,
periodEnd: preloadedSubscription.periodEnd ?? null,
planDollars,
})
}
}
const currentUsage = Math.max(0, rawUsage - dailyRefreshDeduction)
const percentUsed = Math.min((currentUsage / limit) * 100, 100)
// Check org-level cap for team/enterprise pooled usage
let isExceeded = currentUsage >= limit
let isWarning = percentUsed >= WARNING_THRESHOLD && percentUsed < 100
try {
@@ -90,13 +111,11 @@ export async function checkUsageStatus(
.limit(1)
if (orgRows.length) {
const org = orgRows[0]
// Sum pooled usage
const teamMembers = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, org.id))
// Get all team member usage in a single query to avoid N+1
let pooledUsage = 0
if (teamMembers.length > 0) {
const memberIds = teamMembers.map((tm) => tm.userId)
@@ -111,7 +130,25 @@ export async function checkUsageStatus(
)
}
}
// Determine org cap from orgUsageLimit (should always be set for team/enterprise)
if (
preloadedSubscription &&
isPaid(preloadedSubscription.plan) &&
preloadedSubscription.periodStart
) {
const planDollars = getPlanTierDollars(preloadedSubscription.plan)
if (planDollars > 0) {
const memberIds = teamMembers.map((tm) => tm.userId)
const orgRefreshDeduction = await computeDailyRefreshConsumed({
userIds: memberIds,
periodStart: preloadedSubscription.periodStart,
periodEnd: preloadedSubscription.periodEnd ?? null,
planDollars,
seats: preloadedSubscription.seats ?? 1,
})
pooledUsage = Math.max(0, pooledUsage - orgRefreshDeduction)
}
}
const orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0
if (!orgCap || Number.isNaN(orgCap)) {
logger.warn('Organization missing usage limit', { orgId: org.id })

View File

@@ -2,6 +2,7 @@ import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { client, useSession, useSubscription } from '@/lib/auth/auth-client'
import { buildPlanName, isOrgPlan } from '@/lib/billing/plan-helpers'
import { organizationKeys } from '@/hooks/queries/organization'
const logger = createLogger('SubscriptionUpgrade')
@@ -10,15 +11,25 @@ type TargetPlan = 'pro' | 'team'
const CONSTANTS = {
INITIAL_TEAM_SEATS: 1,
DEFAULT_CREDIT_TIER: 6000,
} as const
interface UpgradeOptions {
creditTier?: number
annual?: boolean
seats?: number
}
export function useSubscriptionUpgrade() {
const { data: session } = useSession()
const betterAuthSubscription = useSubscription()
const queryClient = useQueryClient()
const handleUpgrade = useCallback(
async (targetPlan: TargetPlan) => {
async (targetPlan: TargetPlan, options?: UpgradeOptions) => {
const creditTier = options?.creditTier ?? CONSTANTS.DEFAULT_CREDIT_TIER
const annual = options?.annual ?? false
const planName = buildPlanName(targetPlan, creditTier)
const userId = session?.user?.id
if (!userId) {
throw new Error('User not authenticated')
@@ -56,9 +67,7 @@ export function useSubscriptionUpgrade() {
// Check if this org already has an active team subscription
const existingTeamSub = allSubscriptions.find(
(sub: any) =>
sub.status === 'active' &&
sub.referenceId === existingOrg.id &&
(sub.plan === 'team' || sub.plan === 'enterprise')
sub.status === 'active' && sub.referenceId === existingOrg.id && isOrgPlan(sub.plan)
)
if (existingTeamSub) {
@@ -109,11 +118,12 @@ export function useSubscriptionUpgrade() {
try {
const upgradeParams = {
plan: targetPlan,
plan: planName,
referenceId,
successUrl,
cancelUrl: currentUrl,
...(targetPlan === 'team' && { seats: CONSTANTS.INITIAL_TEAM_SEATS }),
...(targetPlan === 'team' && { seats: options?.seats ?? CONSTANTS.INITIAL_TEAM_SEATS }),
...(annual && { annual: true }),
} as const
const finalParams = currentSubscriptionId
@@ -122,7 +132,7 @@ export function useSubscriptionUpgrade() {
logger.info(
currentSubscriptionId ? 'Upgrading existing subscription' : 'Creating new subscription',
{ targetPlan, currentSubscriptionId, referenceId }
{ targetPlan, planName, annual, currentSubscriptionId, referenceId }
)
await betterAuthSubscription.upgrade(finalParams)

View File

@@ -4,6 +4,7 @@
*/
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
import { isFree, isPro } from '@/lib/billing/plan-helpers'
import { USAGE_PILL_COLORS } from './consts'
import type { BillingStatus, SubscriptionData, UsageData } from './types'
@@ -125,7 +126,7 @@ export function isAtLeastTeam(subscriptionData: SubscriptionData | null | undefi
export function canUpgrade(subscriptionData: SubscriptionData | null | undefined): boolean {
const status = getSubscriptionStatus(subscriptionData)
return status.plan === 'free' || status.plan === 'pro'
return isFree(status.plan) || isPro(status.plan)
}
/**

View File

@@ -5,10 +5,13 @@
/**
* Fallback free credits (in dollars) when env var is not set
*/
export const DEFAULT_FREE_CREDITS = 20
export const DEFAULT_FREE_CREDITS = 15
/**
* Default per-user minimum limits (in dollars) for paid plans when env vars are absent
* Default per-user minimum limits (in dollars) for paid plans when env vars are absent.
* These are intentionally kept at legacy pricing ($20 Pro, $40 Team) for backward
* compatibility with existing subscribers on the old plan names ('pro', 'team').
* New tiered plans (pro_6000, team_25000, etc.) derive their limits from CREDIT_TIERS.
*/
export const DEFAULT_PRO_TIER_COST_LIMIT = 20
export const DEFAULT_TEAM_TIER_COST_LIMIT = 40
@@ -30,3 +33,27 @@ export const SEARCH_TOOL_COST = 0.01
* When unbilled overage reaches this amount, an invoice item is created
*/
export const DEFAULT_OVERAGE_THRESHOLD = 50
/**
* Available credit tiers. Each tier maps a credit amount to the underlying dollar cost.
* 1 credit = $0.005, so credits = dollars * 200.
*/
export const CREDIT_TIERS = [
{ credits: 6000, dollars: 25, name: 'Pro' },
{ credits: 25000, dollars: 100, name: 'Max' },
] as const
export type CreditTier = (typeof CREDIT_TIERS)[number]
/**
* Daily refresh rate: 1% of plan cost per day.
* E.g. $25 plan => $0.25/day => 50 credits/day included usage.
*/
export const DAILY_REFRESH_RATE = 0.01
/**
* Annual subscribers pay 15% less than the equivalent monthly plan
* but receive the same included credits. The Stripe annual price is
* `monthlyDollars * 12 * (1 - ANNUAL_DISCOUNT_RATE)`.
*/
export const ANNUAL_DISCOUNT_RATE = 0.15

View File

@@ -4,6 +4,17 @@ import { and, eq } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { getCreditBalance } from '@/lib/billing/credits/balance'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
import {
getPlanTierCredits,
getPlanTierDollars,
isEnterprise,
isOrgPlan,
isPaid,
isPro,
isTeam,
} from '@/lib/billing/plan-helpers'
import { getFreeTierLimit, getPlanPricing } from '@/lib/billing/subscriptions/utils'
import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal'
@@ -13,6 +24,20 @@ import { createLogger } from '@sim/logger'
const logger = createLogger('Billing')
/**
* Derive billing interval from subscription period dates.
* If the period spans more than 180 days, assume annual; otherwise monthly.
*/
function deriveBillingInterval(
periodStart?: Date | null,
periodEnd?: Date | null
): 'month' | 'year' {
if (!periodStart || !periodEnd) return 'month'
const diffMs = periodEnd.getTime() - periodStart.getTime()
const diffDays = diffMs / (1000 * 60 * 60 * 24)
return diffDays > 180 ? 'year' : 'month'
}
/**
* Get organization subscription directly by organization ID
*/
@@ -90,9 +115,11 @@ export async function calculateSubscriptionOverage(sub: {
plan: string | null
referenceId: string
seats?: number | null
periodStart?: Date | null
periodEnd?: Date | null
}): Promise<number> {
// Enterprise plans have no overages
if (sub.plan === 'enterprise') {
if (isEnterprise(sub.plan)) {
logger.info('Enterprise plan has no overages', {
subscriptionId: sub.id,
plan: sub.plan,
@@ -102,7 +129,7 @@ export async function calculateSubscriptionOverage(sub: {
let totalOverageDecimal = new Decimal(0)
if (sub.plan === 'team') {
if (isTeam(sub.plan)) {
const members = await db
.select({ userId: member.userId })
.from(member)
@@ -124,12 +151,27 @@ export async function calculateSubscriptionOverage(sub: {
orgData.length > 0 ? toDecimal(orgData[0].departedMemberUsage) : new Decimal(0)
const totalUsageWithDepartedDecimal = totalTeamUsageDecimal.plus(departedUsageDecimal)
const { basePrice } = getPlanPricing(sub.plan)
const baseSubscriptionAmount = (sub.seats ?? 0) * basePrice
totalOverageDecimal = Decimal.max(
let dailyRefreshDeduction = 0
const planDollars = getPlanTierDollars(sub.plan)
if (planDollars > 0 && sub.periodStart) {
const memberIds = members.map((m) => m.userId)
dailyRefreshDeduction = await computeDailyRefreshConsumed({
userIds: memberIds,
periodStart: sub.periodStart,
periodEnd: sub.periodEnd ?? null,
planDollars,
seats: sub.seats ?? 1,
})
}
const effectiveUsageDecimal = Decimal.max(
0,
totalUsageWithDepartedDecimal.minus(baseSubscriptionAmount)
totalUsageWithDepartedDecimal.minus(toDecimal(dailyRefreshDeduction))
)
const { basePrice } = getPlanPricing(sub.plan ?? '')
const baseSubscriptionAmount = (sub.seats ?? 0) * basePrice
totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(baseSubscriptionAmount))
logger.info('Calculated team overage', {
subscriptionId: sub.id,
@@ -139,7 +181,7 @@ export async function calculateSubscriptionOverage(sub: {
baseSubscriptionAmount,
totalOverage: toNumber(totalOverageDecimal),
})
} else if (sub.plan === 'pro') {
} else if (isPro(sub.plan)) {
// Pro plan: include snapshot if user joined a team
const usage = await getUserUsageData(sub.referenceId)
let totalProUsageDecimal = toDecimal(usage.currentUsage)
@@ -162,7 +204,7 @@ export async function calculateSubscriptionOverage(sub: {
})
}
const { basePrice } = getPlanPricing(sub.plan)
const { basePrice } = getPlanPricing(sub.plan ?? '')
totalOverageDecimal = Decimal.max(0, totalProUsageDecimal.minus(basePrice))
logger.info('Calculated pro overage', {
@@ -208,6 +250,13 @@ export async function getSimplifiedBillingSummary(
isExceeded: boolean
daysRemaining: number
creditBalance: number
billingInterval: 'month' | 'year'
tierCredits: number
basePriceCredits: number
currentUsageCredits: number
overageAmountCredits: number
totalProjectedCredits: number
usageLimitCredits: number
// Subscription details
isPaid: boolean
isPro: boolean
@@ -232,6 +281,11 @@ export async function getSimplifiedBillingSummary(
lastPeriodCopilotCost: number
daysRemaining: number
copilotCost: number
currentCredits: number
limitCredits: number
lastPeriodCostCredits: number
lastPeriodCopilotCostCredits: number
copilotCostCredits: number
}
organizationData?: {
seatCount: number
@@ -239,6 +293,9 @@ export async function getSimplifiedBillingSummary(
totalBasePrice: number
totalCurrentUsage: number
totalOverage: number
totalBasePriceCredits: number
totalCurrentUsageCredits: number
totalOverageCredits: number
}
}> {
try {
@@ -252,10 +309,10 @@ export async function getSimplifiedBillingSummary(
// Determine subscription type flags
const plan = subscription?.plan || 'free'
const isPaid = plan !== 'free'
const isPro = plan === 'pro'
const isTeam = plan === 'team'
const isEnterprise = plan === 'enterprise'
const planIsPaid = isPaid(plan)
const planIsPro = isPro(plan)
const planIsTeam = isTeam(plan)
const planIsEnterprise = isEnterprise(plan)
if (organizationId) {
// Organization billing summary
@@ -325,6 +382,11 @@ export async function getSimplifiedBillingSummary(
: 0
const orgCredits = await getCreditBalance(userId)
const orgTotalProjected = totalBasePrice + totalOverage
const orgBillingInterval = deriveBillingInterval(
subscription.periodStart,
subscription.periodEnd
)
return {
type: 'organization',
@@ -332,18 +394,25 @@ export async function getSimplifiedBillingSummary(
basePrice: totalBasePrice,
currentUsage: totalCurrentUsage,
overageAmount: totalOverage,
totalProjected: totalBasePrice + totalOverage,
totalProjected: orgTotalProjected,
usageLimit: usageData.limit,
percentUsed,
isWarning: percentUsed >= 80 && percentUsed < 100,
isExceeded: usageData.currentUsage >= usageData.limit,
daysRemaining,
creditBalance: orgCredits.balance,
billingInterval: orgBillingInterval,
tierCredits: getPlanTierCredits(subscription.plan),
basePriceCredits: dollarsToCredits(totalBasePrice),
currentUsageCredits: dollarsToCredits(totalCurrentUsage),
overageAmountCredits: dollarsToCredits(totalOverage),
totalProjectedCredits: dollarsToCredits(orgTotalProjected),
usageLimitCredits: dollarsToCredits(usageData.limit),
// Subscription details
isPaid,
isPro,
isTeam,
isEnterprise,
isPaid: planIsPaid,
isPro: planIsPro,
isTeam: planIsTeam,
isEnterprise: planIsEnterprise,
status: subscription.status || null,
seats: subscription.seats || null,
metadata: subscription.metadata || null,
@@ -363,6 +432,11 @@ export async function getSimplifiedBillingSummary(
lastPeriodCopilotCost: totalLastPeriodCopilotCost,
daysRemaining,
copilotCost: totalCopilotCost,
currentCredits: dollarsToCredits(usageData.currentUsage),
limitCredits: dollarsToCredits(usageData.limit),
lastPeriodCostCredits: dollarsToCredits(usageData.lastPeriodCost),
lastPeriodCopilotCostCredits: dollarsToCredits(totalLastPeriodCopilotCost),
copilotCostCredits: dollarsToCredits(totalCopilotCost),
},
organizationData: {
seatCount: licensedSeats,
@@ -370,6 +444,9 @@ export async function getSimplifiedBillingSummary(
totalBasePrice,
totalCurrentUsage,
totalOverage,
totalBasePriceCredits: dollarsToCredits(totalBasePrice),
totalCurrentUsageCredits: dollarsToCredits(totalCurrentUsage),
totalOverageCredits: dollarsToCredits(totalOverage),
},
}
}
@@ -397,7 +474,7 @@ export async function getSimplifiedBillingSummary(
let currentUsage = usageData.currentUsage
let totalCopilotCost = copilotCost
let totalLastPeriodCopilotCost = lastPeriodCopilotCost
if ((isTeam || isEnterprise) && subscription?.referenceId) {
if (isOrgPlan(plan) && subscription?.referenceId) {
// Get all team members and sum their usage
const teamMembers = await db
.select({ userId: member.userId })
@@ -447,6 +524,11 @@ export async function getSimplifiedBillingSummary(
: 0
const userCredits = await getCreditBalance(userId)
const individualTotalProjected = basePrice + overageAmount
const individualBillingInterval = deriveBillingInterval(
subscription?.periodStart,
subscription?.periodEnd
)
return {
type: 'individual',
@@ -454,18 +536,25 @@ export async function getSimplifiedBillingSummary(
basePrice,
currentUsage: currentUsage,
overageAmount,
totalProjected: basePrice + overageAmount,
totalProjected: individualTotalProjected,
usageLimit: usageData.limit,
percentUsed,
isWarning: percentUsed >= 80 && percentUsed < 100,
isExceeded: currentUsage >= usageData.limit,
daysRemaining,
creditBalance: userCredits.balance,
billingInterval: individualBillingInterval,
tierCredits: getPlanTierCredits(plan),
basePriceCredits: dollarsToCredits(basePrice),
currentUsageCredits: dollarsToCredits(currentUsage),
overageAmountCredits: dollarsToCredits(overageAmount),
totalProjectedCredits: dollarsToCredits(individualTotalProjected),
usageLimitCredits: dollarsToCredits(usageData.limit),
// Subscription details
isPaid,
isPro,
isTeam,
isEnterprise,
isPaid: planIsPaid,
isPro: planIsPro,
isTeam: planIsTeam,
isEnterprise: planIsEnterprise,
status: subscription?.status || null,
seats: subscription?.seats || null,
metadata: subscription?.metadata || null,
@@ -485,6 +574,11 @@ export async function getSimplifiedBillingSummary(
lastPeriodCopilotCost: totalLastPeriodCopilotCost,
daysRemaining,
copilotCost: totalCopilotCost,
currentCredits: dollarsToCredits(currentUsage),
limitCredits: dollarsToCredits(usageData.limit),
lastPeriodCostCredits: dollarsToCredits(usageData.lastPeriodCost),
lastPeriodCopilotCostCredits: dollarsToCredits(totalLastPeriodCopilotCost),
copilotCostCredits: dollarsToCredits(totalCopilotCost),
},
}
} catch (error) {
@@ -497,6 +591,7 @@ export async function getSimplifiedBillingSummary(
* Get default billing summary for error cases
*/
function getDefaultBillingSummary(type: 'individual' | 'organization') {
const freeTierLimit = getFreeTierLimit()
return {
type,
plan: 'free',
@@ -504,12 +599,19 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
currentUsage: 0,
overageAmount: 0,
totalProjected: 0,
usageLimit: getFreeTierLimit(),
usageLimit: freeTierLimit,
percentUsed: 0,
isWarning: false,
isExceeded: false,
daysRemaining: 0,
creditBalance: 0,
billingInterval: 'month' as const,
tierCredits: 0,
basePriceCredits: 0,
currentUsageCredits: 0,
overageAmountCredits: 0,
totalProjectedCredits: 0,
usageLimitCredits: dollarsToCredits(freeTierLimit),
// Subscription details
isPaid: false,
isPro: false,
@@ -523,7 +625,7 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
// Usage details
usage: {
current: 0,
limit: getFreeTierLimit(),
limit: freeTierLimit,
percentUsed: 0,
isWarning: false,
isExceeded: false,
@@ -533,6 +635,11 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
lastPeriodCopilotCost: 0,
daysRemaining: 0,
copilotCost: 0,
currentCredits: 0,
limitCredits: dollarsToCredits(freeTierLimit),
lastPeriodCostCredits: 0,
lastPeriodCopilotCostCredits: 0,
copilotCostCredits: 0,
},
...(type === 'organization' && {
organizationData: {
@@ -541,6 +648,9 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
totalBasePrice: 0,
totalCurrentUsage: 0,
totalOverage: 0,
totalBasePriceCredits: 0,
totalCurrentUsageCredits: 0,
totalOverageCredits: 0,
},
}),
}

View File

@@ -3,6 +3,8 @@ import { member, organization, subscription, user, userStats } from '@sim/db/sch
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers'
import { getEffectiveSeats, getFreeTierLimit } from '@/lib/billing/subscriptions/utils'
const logger = createLogger('OrganizationBilling')
@@ -128,7 +130,23 @@ export async function getOrganizationBillingData(
})
// Calculate aggregated statistics
const totalCurrentUsage = members.reduce((sum, member) => sum + member.currentUsage, 0)
let totalCurrentUsage = members.reduce((sum, m) => sum + m.currentUsage, 0)
// Deduct daily refresh from pooled usage
if (isPaid(subscription.plan) && subscription.periodStart) {
const planDollars = getPlanTierDollars(subscription.plan)
if (planDollars > 0) {
const memberIds = members.map((m) => m.userId)
const refreshConsumed = await computeDailyRefreshConsumed({
userIds: memberIds,
periodStart: subscription.periodStart,
periodEnd: subscription.periodEnd ?? null,
planDollars,
seats: subscription.seats ?? 1,
})
totalCurrentUsage = Math.max(0, totalCurrentUsage - refreshConsumed)
}
}
// Get per-seat pricing for the plan
const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan)
@@ -144,7 +162,7 @@ export async function getOrganizationBillingData(
let minimumBillingAmount: number
let totalUsageLimit: number
if (subscription.plan === 'enterprise') {
if (isEnterprise(subscription.plan)) {
// Enterprise has fixed pricing set through custom Stripe product
// Their usage limit is configured to match their monthly cost
const configuredLimit = organizationData.orgUsageLimit
@@ -220,7 +238,7 @@ export async function updateOrganizationUsageLimit(
}
// Enterprise plans have fixed usage limits that cannot be changed
if (subscription.plan === 'enterprise') {
if (isEnterprise(subscription.plan)) {
return {
success: false,
error: 'Enterprise plans have fixed usage limits that cannot be changed',
@@ -228,7 +246,7 @@ export async function updateOrganizationUsageLimit(
}
// Only team plans can update their usage limits
if (subscription.plan !== 'team') {
if (!isTeam(subscription.plan)) {
return {
success: false,
error: 'Only team organizations can update usage limits',

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getUserUsageLimit } from '@/lib/billing/core/usage'
import { isOrgPlan, isPro as isPlanPro, isTeam as isPlanTeam } from '@/lib/billing/plan-helpers'
import {
checkEnterprisePlan,
checkProPlan,
@@ -374,7 +375,7 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
if (subscription) {
// Team/Enterprise: Use organization limit
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
if (isOrgPlan(subscription.plan)) {
limit = await getUserUsageLimit(userId)
logger.info('Using organization limit', {
userId,
@@ -459,7 +460,7 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
let limit = getFreeTierLimit() // Default free tier limit
if (subscription) {
// Team/Enterprise: Use organization limit
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
if (isOrgPlan(subscription.plan)) {
limit = await getUserUsageLimit(userId)
} else {
// Pro/Free: Use individual limit
@@ -504,7 +505,7 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
export async function sendPlanWelcomeEmail(subscription: any): Promise<void> {
try {
const subPlan = subscription.plan
if (subPlan === 'pro' || subPlan === 'team') {
if (isPlanPro(subPlan) || isPlanTeam(subPlan)) {
const userId = subscription.referenceId
const users = await db
.select({ email: user.email, name: user.name })
@@ -517,15 +518,17 @@ export async function sendPlanWelcomeEmail(subscription: any): Promise<void> {
const { sendEmail } = await import('@/lib/messaging/email/mailer')
const baseUrl = getBaseUrl()
const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers')
const html = await renderPlanWelcomeEmail({
planName: subPlan === 'pro' ? 'Pro' : 'Team',
planName: getDisplayPlanName(subPlan),
userName: users[0].name || undefined,
loginLink: `${baseUrl}/login`,
})
const displayName = getDisplayPlanName(subPlan)
await sendEmail({
to: users[0].email,
subject: getEmailSubject(subPlan === 'pro' ? 'plan-welcome-pro' : 'plan-welcome-team'),
subject: `Your ${displayName} plan is now active on ${(await import('@/ee/whitelabeling')).getBrandConfig().name}`,
html,
emailType: 'updates',
})

View File

@@ -11,6 +11,15 @@ import {
getHighestPrioritySubscription,
type HighestPrioritySubscription,
} from '@/lib/billing/core/plan'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
import {
getPlanTierDollars,
isEnterprise,
isFree,
isOrgPlan,
isPaid,
isPro,
} from '@/lib/billing/plan-helpers'
import {
canEditUsageLimit,
getFreeTierLimit,
@@ -52,7 +61,7 @@ export async function getOrgUsageLimit(
? toNumber(toDecimal(orgData[0].orgUsageLimit))
: null
if (plan === 'enterprise') {
if (isEnterprise(plan)) {
// Enterprise: Use configured limit directly (no per-seat minimum)
if (configured !== null) {
return { limit: configured, minimum: configured }
@@ -140,7 +149,7 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
// For Pro users, include any snapshotted usage (from when they joined a team)
// This ensures they see their total Pro usage in the UI
if (subscription && subscription.plan === 'pro' && subscription.referenceId === userId) {
if (subscription && isPro(subscription.plan) && subscription.referenceId === userId) {
const snapshotUsageDecimal = toDecimal(stats.proPeriodCostSnapshot)
if (snapshotUsageDecimal.greaterThan(0)) {
currentUsageDecimal = currentUsageDecimal.plus(snapshotUsageDecimal)
@@ -157,7 +166,7 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
// Determine usage limit based on plan type
let limit: number
if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') {
if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) {
// Free/Pro: Use individual user limit from userStats
limit = stats.currentUsageLimit
? toNumber(toDecimal(stats.currentUsageLimit))
@@ -172,17 +181,37 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
limit = orgLimit.limit
}
const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 0
const isWarning = percentUsed >= 80
const isExceeded = currentUsage >= limit
// Derive billing period dates from subscription (source of truth).
// For free users or missing dates, expose nulls.
const billingPeriodStart = subscription?.periodStart ?? null
const billingPeriodEnd = subscription?.periodEnd ?? null
// Compute daily refresh deduction for individual (non-org) paid plans.
// Org plans apply refresh at the pooled level in getEffectiveCurrentPeriodCost.
let dailyRefreshConsumed = 0
if (
subscription &&
isPaid(subscription.plan) &&
!isOrgPlan(subscription.plan) &&
billingPeriodStart
) {
const planDollars = getPlanTierDollars(subscription.plan)
if (planDollars > 0) {
dailyRefreshConsumed = await computeDailyRefreshConsumed({
userIds: [userId],
periodStart: billingPeriodStart,
periodEnd: billingPeriodEnd,
planDollars,
})
}
}
const effectiveUsage = Math.max(0, currentUsage - dailyRefreshConsumed)
const percentUsed = limit > 0 ? Math.min((effectiveUsage / limit) * 100, 100) : 0
const isWarning = percentUsed >= 80
const isExceeded = effectiveUsage >= limit
return {
currentUsage,
currentUsage: effectiveUsage,
limit,
percentUsed,
isWarning,
@@ -218,7 +247,7 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
let minimumLimit: number
let canEdit: boolean
if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') {
if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) {
// Free/Pro: Use individual limits
currentLimit = stats.currentUsageLimit
? toNumber(toDecimal(stats.currentUsageLimit))
@@ -267,8 +296,7 @@ export async function initializeUserUsageLimit(userId: string): Promise<void> {
// Check user's subscription to determine initial limit
const subscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterprise =
subscription && (subscription.plan === 'team' || subscription.plan === 'enterprise')
const isTeamOrEnterprise = subscription && isOrgPlan(subscription.plan)
// Create initial usage stats
await db.insert(userStats).values({
@@ -298,7 +326,7 @@ export async function updateUserUsageLimit(
const subscription = await getHighestPrioritySubscription(userId)
// Team/enterprise users don't have individual limits
if (subscription && (subscription.plan === 'team' || subscription.plan === 'enterprise')) {
if (subscription && isOrgPlan(subscription.plan)) {
return {
success: false,
error: 'Team and enterprise members use organization limits',
@@ -306,7 +334,7 @@ export async function updateUserUsageLimit(
}
// Only pro users can edit limits (free users cannot)
if (!subscription || subscription.plan === 'free') {
if (!subscription || isFree(subscription.plan)) {
return { success: false, error: 'Free plan users cannot edit usage limits' }
}
@@ -364,7 +392,7 @@ export async function getUserUsageLimit(
? preloadedSubscription
: await getHighestPrioritySubscription(userId)
if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') {
if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) {
// Free/Pro: Use individual limit from userStats
const userStatsQuery = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
@@ -449,7 +477,7 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise<v
const currentStats = currentUserStats[0]
// Team/enterprise: Should have null individual limits
if (subscription && (subscription.plan === 'team' || subscription.plan === 'enterprise')) {
if (subscription && isOrgPlan(subscription.plan)) {
if (currentStats.currentUsageLimit !== null) {
await db
.update(userStats)
@@ -547,15 +575,18 @@ export async function getTeamUsageLimits(organizationId: string): Promise<
}
/**
* Returns the effective current period usage cost for a user.
* - Free/Pro: user's own currentPeriodCost (fallback to totalCost)
* - Team/Enterprise: pooled sum of all members' currentPeriodCost within the organization
* Returns the effective current period usage cost for a user,
* with weekly refresh credits deducted.
* - Free/Pro: user's own currentPeriodCost minus refresh consumed
* - Team/Enterprise: pooled sum of all members' currentPeriodCost minus refresh consumed
*/
export async function getEffectiveCurrentPeriodCost(userId: string): Promise<number> {
const subscription = await getHighestPrioritySubscription(userId)
// If no team/org subscription, return the user's own usage
if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') {
let rawCost: number
let refreshUserIds: string[] = [userId]
if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) {
const rows = await db
.select({ current: userStats.currentPeriodCost })
.from(userStats)
@@ -563,28 +594,45 @@ export async function getEffectiveCurrentPeriodCost(userId: string): Promise<num
.limit(1)
if (rows.length === 0) return 0
return toNumber(toDecimal(rows[0].current))
rawCost = toNumber(toDecimal(rows[0].current))
} else {
const teamMembers = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, subscription.referenceId))
if (teamMembers.length === 0) return 0
const memberIds = teamMembers.map((m) => m.userId)
refreshUserIds = memberIds
const rows = await db
.select({ current: userStats.currentPeriodCost })
.from(userStats)
.where(inArray(userStats.userId, memberIds))
let pooled = new Decimal(0)
for (const r of rows) {
pooled = pooled.plus(toDecimal(r.current))
}
rawCost = toNumber(pooled)
}
// Team/Enterprise: pooled usage across org members
const teamMembers = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, subscription.referenceId))
if (teamMembers.length === 0) return 0
const memberIds = teamMembers.map((m) => m.userId)
const rows = await db
.select({ current: userStats.currentPeriodCost })
.from(userStats)
.where(inArray(userStats.userId, memberIds))
let pooled = new Decimal(0)
for (const r of rows) {
pooled = pooled.plus(toDecimal(r.current))
if (!subscription || !isPaid(subscription.plan) || !subscription.periodStart) {
return rawCost
}
return toNumber(pooled)
const planDollars = getPlanTierDollars(subscription.plan)
if (planDollars <= 0) return rawCost
const refreshConsumed = await computeDailyRefreshConsumed({
userIds: refreshUserIds,
periodStart: subscription.periodStart,
periodEnd: subscription.periodEnd ?? null,
planDollars,
seats: subscription.seats ?? 1,
})
return Math.max(0, rawCost - refreshConsumed)
}
/**

View File

@@ -3,6 +3,7 @@ import { member, organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isOrgPlan, isPro, isTeam } from '@/lib/billing/plan-helpers'
import { Decimal, toDecimal, toFixedString, toNumber } from '@/lib/billing/utils/decimal'
const logger = createLogger('CreditBalance')
@@ -16,7 +17,7 @@ export interface CreditBalanceInfo {
export async function getCreditBalance(userId: string): Promise<CreditBalanceInfo> {
const subscription = await getHighestPrioritySubscription(userId)
if (subscription?.plan === 'team' || subscription?.plan === 'enterprise') {
if (isOrgPlan(subscription?.plan)) {
const orgRows = await db
.select({ creditBalance: organization.creditBalance })
.from(organization)
@@ -152,7 +153,7 @@ export async function deductFromCredits(userId: string, cost: number): Promise<D
}
const subscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterprise = subscription?.plan === 'team' || subscription?.plan === 'enterprise'
const isTeamOrEnterprise = isOrgPlan(subscription?.plan)
let creditsUsed: number
@@ -182,7 +183,7 @@ export async function canPurchaseCredits(userId: string): Promise<boolean> {
return false
}
// Enterprise users must contact support to purchase credits
return subscription.plan === 'pro' || subscription.plan === 'team'
return isPro(subscription.plan) || isTeam(subscription.plan)
}
export async function isOrgAdmin(userId: string, organizationId: string): Promise<boolean> {

View File

@@ -3,6 +3,7 @@ import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import type { DbOrTx } from '@/lib/db/types'
const logger = createLogger('BonusCredits')
@@ -27,7 +28,7 @@ export async function applyBonusCredits(
): Promise<void> {
const dbCtx = tx ?? db
const subscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterprise = subscription?.plan === 'team' || subscription?.plan === 'enterprise'
const isTeamOrEnterprise = isOrgPlan(subscription?.plan)
if (isTeamOrEnterprise && subscription?.referenceId) {
const orgId = subscription.referenceId

View File

@@ -0,0 +1,19 @@
/**
* Credit conversion utilities.
* All DB values remain in dollars; these helpers convert at API/UI boundaries only.
* 1 credit = $0.005 (i.e. $1 = 200 credits)
*/
export const CREDIT_MULTIPLIER = 200
export function dollarsToCredits(dollars: number): number {
return Math.round(dollars * CREDIT_MULTIPLIER)
}
/**
* Format a dollar amount as a comma-separated credit string.
* @example formatCredits(20) => "2,000"
*/
export function formatCredits(dollars: number): string {
return dollarsToCredits(dollars).toLocaleString()
}

View File

@@ -0,0 +1,164 @@
/**
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockDbSelect = vi.fn()
vi.mock('@sim/db', () => ({
db: {
select: () => ({
from: () => ({
where: () => ({
groupBy: mockDbSelect,
}),
}),
}),
},
}))
vi.mock('@sim/db/schema', () => ({
usageLog: {
userId: 'user_id',
createdAt: 'created_at',
cost: 'cost',
},
}))
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/billing/constants', () => ({
DAILY_REFRESH_RATE: 0.01,
}))
import { computeDailyRefreshConsumed, getDailyRefreshDollars } from './daily-refresh'
describe('computeDailyRefreshConsumed', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 0 when planDollars is 0', async () => {
const result = await computeDailyRefreshConsumed({
userIds: ['user-1'],
periodStart: new Date('2026-03-01'),
planDollars: 0,
})
expect(result).toBe(0)
expect(mockDbSelect).not.toHaveBeenCalled()
})
it('returns 0 when userIds is empty', async () => {
const result = await computeDailyRefreshConsumed({
userIds: [],
periodStart: new Date('2026-03-01'),
planDollars: 25,
})
expect(result).toBe(0)
expect(mockDbSelect).not.toHaveBeenCalled()
})
it('returns 0 when periodEnd is before periodStart', async () => {
const result = await computeDailyRefreshConsumed({
userIds: ['user-1'],
periodStart: new Date('2026-03-10'),
periodEnd: new Date('2026-03-01'),
planDollars: 25,
})
expect(result).toBe(0)
})
it('caps each day at the daily refresh allowance', async () => {
mockDbSelect.mockResolvedValue([
{ dayIndex: 0, dayTotal: '0.50' },
{ dayIndex: 1, dayTotal: '0.10' },
{ dayIndex: 2, dayTotal: '1.00' },
])
const result = await computeDailyRefreshConsumed({
userIds: ['user-1'],
periodStart: new Date('2026-03-01'),
periodEnd: new Date('2026-03-04'),
planDollars: 25,
})
// Daily refresh = $25 * 0.01 = $0.25/day
// Day 0: MIN(0.50, 0.25) = 0.25
// Day 1: MIN(0.10, 0.25) = 0.10
// Day 2: MIN(1.00, 0.25) = 0.25
// Total = 0.60
expect(result).toBe(0.6)
})
it('returns 0 when no usage rows exist', async () => {
mockDbSelect.mockResolvedValue([])
const result = await computeDailyRefreshConsumed({
userIds: ['user-1'],
periodStart: new Date('2026-03-01'),
periodEnd: new Date('2026-03-04'),
planDollars: 25,
})
expect(result).toBe(0)
})
it('multiplies daily refresh by seats', async () => {
mockDbSelect.mockResolvedValue([{ dayIndex: 0, dayTotal: '2.00' }])
const result = await computeDailyRefreshConsumed({
userIds: ['user-1', 'user-2', 'user-3'],
periodStart: new Date('2026-03-01'),
periodEnd: new Date('2026-03-02'),
planDollars: 100,
seats: 3,
})
// Daily refresh = $100 * 0.01 * 3 seats = $3.00/day
// Day 0: MIN(2.00, 3.00) = 2.00
expect(result).toBe(2.0)
})
it('caps at refresh even with high usage and multiple seats', async () => {
mockDbSelect.mockResolvedValue([{ dayIndex: 0, dayTotal: '50.00' }])
const result = await computeDailyRefreshConsumed({
userIds: ['user-1', 'user-2'],
periodStart: new Date('2026-03-01'),
periodEnd: new Date('2026-03-02'),
planDollars: 100,
seats: 2,
})
// Daily refresh = $100 * 0.01 * 2 seats = $2.00/day
// Day 0: MIN(50.00, 2.00) = 2.00
expect(result).toBe(2.0)
})
it('handles null dayTotal gracefully', async () => {
mockDbSelect.mockResolvedValue([{ dayIndex: 0, dayTotal: null }])
const result = await computeDailyRefreshConsumed({
userIds: ['user-1'],
periodStart: new Date('2026-03-01'),
periodEnd: new Date('2026-03-02'),
planDollars: 25,
})
expect(result).toBe(0)
})
})
describe('getDailyRefreshDollars', () => {
it('computes correct daily refresh for Pro ($25)', () => {
expect(getDailyRefreshDollars(25)).toBe(0.25)
})
it('computes correct daily refresh for Max ($100)', () => {
expect(getDailyRefreshDollars(100)).toBe(1.0)
})
it('returns 0 for $0 plan', () => {
expect(getDailyRefreshDollars(0)).toBe(0)
})
})

View File

@@ -0,0 +1,94 @@
/**
* Daily Refresh Credits
*
* Each billing period is divided into 1-day windows starting from `periodStart`.
* Users receive `planDollars * DAILY_REFRESH_RATE` in "included" usage per day.
* Usage within that allowance does not count toward the plan limit (use-it-or-lose-it).
*
* The total refresh consumed in a period is:
* SUM( MIN(day_usage, daily_refresh_amount) ) for each day
*
* This is subtracted from `currentPeriodCost` to derive "effective billable usage".
*/
import { db } from '@sim/db'
import { usageLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, gte, inArray, lt, sql, sum } from 'drizzle-orm'
import { DAILY_REFRESH_RATE } from '@/lib/billing/constants'
const logger = createLogger('DailyRefresh')
const MS_PER_DAY = 86_400_000
/**
* Compute the total daily refresh credits consumed in the current billing period
* using a single aggregating SQL query grouped by day offset.
*
* For each day from `periodStart`:
* consumed_today = MIN(actual_usage_today, daily_refresh_dollars)
*
* @returns Total dollars of refresh consumed across all days (to subtract from usage)
*/
export async function computeDailyRefreshConsumed(params: {
userIds: string[]
periodStart: Date
periodEnd?: Date | null
planDollars: number
seats?: number
}): Promise<number> {
const { userIds, periodStart, periodEnd, planDollars, seats = 1 } = params
if (planDollars <= 0 || userIds.length === 0) return 0
const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats
const now = new Date()
const cap = periodEnd && periodEnd < now ? periodEnd : now
if (cap <= periodStart) return 0
const dayCount = Math.ceil((cap.getTime() - periodStart.getTime()) / MS_PER_DAY)
if (dayCount <= 0) return 0
const rows = await db
.select({
dayIndex:
sql<number>`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(periodStart.getTime() / 1000)}) / 86400)`.as(
'day_index'
),
dayTotal: sum(usageLog.cost).as('day_total'),
})
.from(usageLog)
.where(
and(
inArray(usageLog.userId, userIds),
gte(usageLog.createdAt, periodStart),
lt(usageLog.createdAt, cap)
)
)
.groupBy(sql`day_index`)
let totalConsumed = 0
for (const row of rows) {
const dayUsage = Number.parseFloat(row.dayTotal ?? '0')
totalConsumed += Math.min(dayUsage, dailyRefreshDollars)
}
logger.debug('Daily refresh computed', {
userCount: userIds.length,
periodStart: periodStart.toISOString(),
days: dayCount,
dailyRefreshDollars,
totalConsumed,
})
return totalConsumed
}
/**
* Get the daily refresh allowance in dollars for a plan.
*/
export function getDailyRefreshDollars(planDollars: number): number {
return planDollars * DAILY_REFRESH_RATE
}

View File

@@ -6,6 +6,7 @@ import type Stripe from 'stripe'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { canPurchaseCredits, isOrgAdmin } from '@/lib/billing/credits/balance'
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
const logger = createLogger('CreditPurchase')
@@ -120,14 +121,14 @@ export async function purchaseCredits(params: PurchaseCreditsParams): Promise<Pu
}
// Enterprise users must contact support
if (subscription.plan === 'enterprise') {
if (isEnterprise(subscription.plan)) {
return { success: false, error: 'Enterprise users must contact support to purchase credits' }
}
let entityType: 'user' | 'organization' = 'user'
let entityId = userId
if (subscription.plan === 'team') {
if (isTeam(subscription.plan)) {
const isAdmin = await isOrgAdmin(userId, subscription.referenceId)
if (!isAdmin) {
return { success: false, error: 'Only organization owners and admins can purchase credits' }

View File

@@ -11,6 +11,7 @@ import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { isTeam } from '@/lib/billing/plan-helpers'
const logger = createLogger('BillingOrganization')
@@ -132,7 +133,7 @@ export async function createOrganizationForTeamPlan(
export async function ensureOrganizationForTeamSubscription(
subscription: SubscriptionData
): Promise<SubscriptionData> {
if (subscription.plan !== 'team') {
if (!isTeam(subscription.plan)) {
return subscription
}
@@ -257,7 +258,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
const organizationId = subscription.referenceId
// Set orgUsageLimit for team plans (enterprise is set via webhook with custom pricing)
if (subscription.plan === 'team') {
if (isTeam(subscription.plan)) {
const { basePrice } = getPlanPricing(subscription.plan)
const seats = subscription.seats ?? 1
const orgLimit = seats * basePrice

View File

@@ -17,6 +17,7 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
@@ -412,7 +413,7 @@ export async function addUserToOrganization(params: AddMemberParams): Promise<Ad
)
.limit(1)
const orgIsPaid = orgSub && (orgSub.plan === 'team' || orgSub.plan === 'enterprise')
const orgIsPaid = orgSub && isOrgPlan(orgSub.plan)
let memberId = ''
@@ -621,7 +622,7 @@ export async function removeUserFromOrganization(
.where(eq(subscriptionTable.status, 'active'))
hasAnyPaidTeam = orgPaidSubs.some(
(s) => orgIds.includes(s.referenceId) && ['team', 'enterprise'].includes(s.plan ?? '')
(s) => orgIds.includes(s.referenceId) && isOrgPlan(s.plan)
)
}

View File

@@ -0,0 +1,126 @@
/**
* Plan type helpers for the credit-tier billing system.
*
* Plan names follow the convention `{type}_{credits}`:
* - `pro_6000` (Pro), `pro_25000` (Max)
* - `team_6000` (Team Pro), `team_25000` (Team Max)
* - `free`, `enterprise` (unchanged)
*
* Legacy plan names (`pro`, `team`) are also recognized for backward compat
* and map to their original dollar amounts ($20 / $40).
*/
import {
CREDIT_TIERS,
DEFAULT_PRO_TIER_COST_LIMIT,
DEFAULT_TEAM_TIER_COST_LIMIT,
} from '@/lib/billing/constants'
export type PlanCategory = 'free' | 'pro' | 'team' | 'enterprise'
export function isPro(plan: string | null | undefined): boolean {
if (!plan) return false
return plan === 'pro' || plan.startsWith('pro_')
}
export function isTeam(plan: string | null | undefined): boolean {
if (!plan) return false
return plan === 'team' || plan.startsWith('team_')
}
export function isFree(plan: string | null | undefined): boolean {
return !plan || plan === 'free'
}
export function isEnterprise(plan: string | null | undefined): boolean {
return plan === 'enterprise'
}
export function isPaid(plan: string | null | undefined): boolean {
return isPro(plan) || isTeam(plan) || isEnterprise(plan)
}
export function isOrgPlan(plan: string | null | undefined): boolean {
return isTeam(plan) || isEnterprise(plan)
}
/**
* Extract the credit count from a plan name (e.g. `'pro_6000'` => `6000`).
* Legacy names map to their original dollar values:
* `'pro'` => 4000 credits ($20 at 1:200), `'team'` => 8000 credits ($40 at 1:200).
*/
export function getPlanTierCredits(plan: string | null | undefined): number {
if (!plan) return 0
const match = plan.match(/_(\d+)$/)
if (match) return Number.parseInt(match[1], 10)
if (plan === 'pro') return 4000
if (plan === 'team') return 8000
return 0
}
/**
* Get the dollar value of a plan's credit tier.
* Looks up from CREDIT_TIERS for exact mapping, with legacy plan fallbacks.
*/
export function getPlanTierDollars(plan: string | null | undefined): number {
if (!plan) return 0
const credits = getPlanTierCredits(plan)
const tier = CREDIT_TIERS.find((t) => t.credits === credits)
if (tier) return tier.dollars
if (plan === 'pro') return DEFAULT_PRO_TIER_COST_LIMIT
if (plan === 'team') return DEFAULT_TEAM_TIER_COST_LIMIT
return 0
}
/**
* Return the broad plan category regardless of tier suffix.
*/
export function getPlanType(plan: string | null | undefined): PlanCategory {
if (isPro(plan)) return 'pro'
if (isTeam(plan)) return 'team'
if (isEnterprise(plan)) return 'enterprise'
return 'free'
}
/**
* Return the plan category used for rate limits, storage, and execution timeouts.
* Max plans (>= 25K credits) are promoted to team-level limits.
*/
export function getPlanTypeForLimits(plan: string | null | undefined): PlanCategory {
const credits = getPlanTierCredits(plan)
if (credits >= 25000 && isPro(plan)) return 'team'
return getPlanType(plan)
}
/**
* Build the canonical plan name for a given type and credit tier.
* @example buildPlanName('pro', 6000) => 'pro_6000'
*/
export function buildPlanName(type: 'pro' | 'team', credits: number): string {
return `${type}_${credits}`
}
/**
* Get the list of valid plan names for a given category.
*/
export function getValidPlanNames(type: 'pro' | 'team'): string[] {
return CREDIT_TIERS.map((t) => buildPlanName(type, t.credits))
}
/**
* Get the user-facing display name for a plan.
* @example getDisplayPlanName('pro_25000') => 'Max'
* @example getDisplayPlanName('team_6000') => 'Pro (Team)'
* @example getDisplayPlanName('pro') => 'Legacy Pro'
*/
export function getDisplayPlanName(plan: string | null | undefined): string {
if (!plan || isFree(plan)) return 'Free'
if (isEnterprise(plan)) return 'Enterprise'
const credits = getPlanTierCredits(plan)
const tier = CREDIT_TIERS.find((t) => t.credits === credits)
const isLegacy = plan === 'pro' || plan === 'team'
const tierName = tier?.name ?? (plan === 'team' ? 'Max' : 'Pro')
const prefix = isLegacy ? 'Legacy ' : ''
const suffix = isTeam(plan) ? ' (Team)' : ''
return `${prefix}${tierName}${suffix}`
}

View File

@@ -1,53 +1,94 @@
import type Stripe from 'stripe'
import {
getFreeTierLimit,
getProTierLimit,
getTeamTierLimitPerSeat,
} from '@/lib/billing/subscriptions/utils'
import { CREDIT_TIERS } from '@/lib/billing/constants'
import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion'
import { isTeam } from '@/lib/billing/plan-helpers'
import { getFreeTierLimit } from '@/lib/billing/subscriptions/utils'
import { env } from '@/lib/core/config/env'
export interface BillingPlan {
name: string
priceId: string
annualDiscountPriceId?: string
limits: {
cost: number
}
}
/**
* Get the billing plans configuration for Better Auth Stripe plugin
* Build the billing plans for the Better Auth Stripe plugin.
*
* Plans:
* - free
* - pro_6000 (Pro, $25/mo) + team_6000
* - pro_25000 (Max, $100/mo) + team_25000
* - enterprise (dynamic pricing)
*
* Legacy subscriptions with plan='pro' or plan='team' are handled by
* plan-helpers.ts which maps them to their original dollar amounts.
*/
export function getPlans(): BillingPlan[] {
return [
const plans: BillingPlan[] = [
{
name: 'free',
priceId: env.STRIPE_FREE_PRICE_ID || '',
limits: {
cost: getFreeTierLimit(),
},
},
{
name: 'pro',
priceId: env.STRIPE_PRO_PRICE_ID || '',
limits: {
cost: getProTierLimit(),
},
},
{
name: 'team',
priceId: env.STRIPE_TEAM_PRICE_ID || '',
limits: {
cost: getTeamTierLimitPerSeat(),
},
},
{
name: 'enterprise',
priceId: 'price_dynamic',
limits: {
cost: getTeamTierLimitPerSeat(),
},
limits: { cost: getFreeTierLimit() },
},
]
const proPriceMap: Record<number, { monthly: string; annual: string }> = {
25: {
monthly: env.STRIPE_PRICE_TIER_25_MO || '',
annual: env.STRIPE_PRICE_TIER_25_YR || '',
},
100: {
monthly: env.STRIPE_PRICE_TIER_100_MO || '',
annual: env.STRIPE_PRICE_TIER_100_YR || '',
},
}
const teamPriceMap: Record<number, { monthly: string; annual: string }> = {
25: {
monthly: env.STRIPE_PRICE_TEAM_25_MO || '',
annual: env.STRIPE_PRICE_TEAM_25_YR || '',
},
100: {
monthly: env.STRIPE_PRICE_TEAM_100_MO || '',
annual: env.STRIPE_PRICE_TEAM_100_YR || '',
},
}
for (const tier of CREDIT_TIERS) {
const proPrices = proPriceMap[tier.dollars]
const teamPrices = teamPriceMap[tier.dollars]
const creditValueDollars = tier.credits / CREDIT_MULTIPLIER
if (proPrices?.monthly) {
plans.push({
name: `pro_${tier.credits}`,
priceId: proPrices.monthly,
annualDiscountPriceId: proPrices.annual || undefined,
limits: { cost: creditValueDollars },
})
}
if (teamPrices?.monthly) {
plans.push({
name: `team_${tier.credits}`,
priceId: teamPrices.monthly,
annualDiscountPriceId: teamPrices.annual || undefined,
limits: { cost: creditValueDollars },
})
}
}
plans.push({
name: 'enterprise',
priceId: 'price_dynamic',
limits: { cost: 200 },
})
return plans
}
/**
@@ -58,10 +99,14 @@ export function getPlanByName(planName: string): BillingPlan | undefined {
}
/**
* Get a specific plan by Stripe price ID
* Get a specific plan by Stripe price ID.
* Matches against both monthly (`priceId`) and annual (`annualDiscountPriceId`) prices.
*/
export function getPlanByPriceId(priceId: string): BillingPlan | undefined {
return getPlans().find((plan) => plan.priceId === priceId)
if (!priceId) return undefined
return getPlans().find(
(plan) => plan.priceId === priceId || plan.annualDiscountPriceId === priceId
)
}
/**
@@ -76,21 +121,23 @@ export interface StripePlanResolution {
priceId: string | undefined
planFromStripe: string | null
isTeamPlan: boolean
isAnnual: boolean
}
/**
* Resolve plan information from a Stripe subscription object.
* Used to get the authoritative plan from Stripe rather than relying on DB state.
*/
export function resolvePlanFromStripeSubscription(
stripeSubscription: Stripe.Subscription
): StripePlanResolution {
const priceId = stripeSubscription?.items?.data?.[0]?.price?.id
const interval = stripeSubscription?.items?.data?.[0]?.price?.recurring?.interval
const plan = priceId ? getPlanByPriceId(priceId) : undefined
return {
priceId,
planFromStripe: plan?.name ?? null,
isTeamPlan: plan?.name === 'team',
isTeamPlan: plan ? isTeam(plan.name) : false,
isAnnual: interval === 'year',
}
}

View File

@@ -13,6 +13,7 @@ import {
import { organization, subscription, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getPlanTypeForLimits, isEnterprise, isFree, isOrgPlan } from '@/lib/billing/plan-helpers'
import { getEnv } from '@/lib/core/config/env'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -55,22 +56,15 @@ export function getStorageLimits() {
export function getStorageLimitForPlan(plan: string, metadata?: any): number {
const limits = getStorageLimits()
switch (plan) {
case 'free':
return limits.free
case 'pro':
return limits.pro
case 'team':
return limits.team
case 'enterprise':
// Check for custom limit in metadata (stored in GB)
if (metadata?.storageLimitGB) {
return gbToBytes(Number.parseInt(metadata.storageLimitGB))
}
return limits.enterpriseDefault
default:
return limits.free
if (isEnterprise(plan)) {
if (metadata?.storageLimitGB) {
return gbToBytes(Number.parseInt(metadata.storageLimitGB))
}
return limits.enterpriseDefault
}
const effectivePlan = getPlanTypeForLimits(plan)
return limits[effectivePlan] ?? limits.free
}
/**
@@ -85,16 +79,16 @@ export async function getUserStorageLimit(userId: string): Promise<number> {
const limits = getStorageLimits()
if (!sub || sub.plan === 'free') {
if (!sub || isFree(sub.plan)) {
return limits.free
}
if (sub.plan === 'pro') {
return limits.pro
if (!isOrgPlan(sub.plan)) {
const effectivePlan = getPlanTypeForLimits(sub.plan)
return limits[effectivePlan] ?? limits.free
}
// Team/Enterprise: Use organization limit
if (sub.plan === 'team' || sub.plan === 'enterprise') {
if (isOrgPlan(sub.plan)) {
// Get organization storage limit
const orgRecord = await db
.select({ metadata: subscription.metadata })
@@ -110,7 +104,7 @@ export async function getUserStorageLimit(userId: string): Promise<number> {
}
// Default for team/enterprise
return sub.plan === 'enterprise' ? limits.enterpriseDefault : limits.team
return isEnterprise(sub.plan) ? limits.enterpriseDefault : limits.team
}
return limits.free
@@ -130,8 +124,7 @@ export async function getUserStorageUsage(userId: string): Promise<number> {
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
const sub = await getHighestPrioritySubscription(userId)
if (sub && (sub.plan === 'team' || sub.plan === 'enterprise')) {
// Use organization storage
if (sub && isOrgPlan(sub.plan)) {
const orgRecord = await db
.select({ storageUsedBytes: organization.storageUsedBytes })
.from(organization)

View File

@@ -8,6 +8,7 @@ import { db } from '@sim/db'
import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('StorageTracking')
@@ -27,8 +28,7 @@ export async function incrementStorageUsage(userId: string, bytes: number): Prom
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
const sub = await getHighestPrioritySubscription(userId)
if (sub && (sub.plan === 'team' || sub.plan === 'enterprise')) {
// Update organization storage
if (sub && isOrgPlan(sub.plan)) {
await db
.update(organization)
.set({
@@ -69,8 +69,7 @@ export async function decrementStorageUsage(userId: string, bytes: number): Prom
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
const sub = await getHighestPrioritySubscription(userId)
if (sub && (sub.plan === 'team' || sub.plan === 'enterprise')) {
// Update organization storage
if (sub && isOrgPlan(sub.plan)) {
await db
.update(organization)
.set({

View File

@@ -4,6 +4,15 @@ import {
DEFAULT_PRO_TIER_COST_LIMIT,
DEFAULT_TEAM_TIER_COST_LIMIT,
} from '@/lib/billing/constants'
import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion'
import {
getPlanTierCredits,
isEnterprise,
isFree,
isOrgPlan,
isPro,
isTeam,
} from '@/lib/billing/plan-helpers'
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
import { env } from '@/lib/core/config/env'
@@ -36,7 +45,7 @@ export function getEnterpriseTierLimitPerSeat(): number {
}
export function checkEnterprisePlan(subscription: any): boolean {
return subscription?.plan === 'enterprise' && subscription?.status === 'active'
return isEnterprise(subscription?.plan) && subscription?.status === 'active'
}
/**
@@ -56,7 +65,7 @@ export function getEffectiveSeats(subscription: any): number {
return 0
}
if (subscription.plan === 'enterprise') {
if (isEnterprise(subscription.plan)) {
const metadata = subscription.metadata as EnterpriseSubscriptionMetadata | null
if (isEnterpriseMetadata(metadata)) {
return Number.parseInt(metadata.seats, 10)
@@ -64,7 +73,7 @@ export function getEffectiveSeats(subscription: any): number {
return 0
}
if (subscription.plan === 'team') {
if (isTeam(subscription.plan)) {
return subscription.seats ?? 0
}
@@ -72,11 +81,11 @@ export function getEffectiveSeats(subscription: any): number {
}
export function checkProPlan(subscription: any): boolean {
return subscription?.plan === 'pro' && subscription?.status === 'active'
return isPro(subscription?.plan) && subscription?.status === 'active'
}
export function checkTeamPlan(subscription: any): boolean {
return subscription?.plan === 'team' && subscription?.status === 'active'
return isTeam(subscription?.plan) && subscription?.status === 'active'
}
/**
@@ -91,11 +100,13 @@ export function getPerUserMinimumLimit(subscription: any): number {
return getFreeTierLimit()
}
if (subscription.plan === 'pro') {
if (isPro(subscription.plan)) {
const tierCredits = getPlanTierCredits(subscription.plan)
if (tierCredits > 0) return tierCredits / CREDIT_MULTIPLIER
return getProTierLimit()
}
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
if (isOrgPlan(subscription.plan)) {
// Team and Enterprise don't have individual limits - they use organization limits
// This function should not be called for these plans
// Returning 0 to indicate no individual minimum
@@ -119,23 +130,22 @@ export function canEditUsageLimit(subscription: any): boolean {
// Only Pro and Team plans can edit limits
// Enterprise has fixed limits that match their monthly cost
return subscription.plan === 'pro' || subscription.plan === 'team'
return isPro(subscription.plan) || isTeam(subscription.plan)
}
/**
* Get pricing info for a plan
* Get pricing info for a plan. Supports both legacy names (`'pro'`, `'team'`)
* and new credit-tier names (`'pro_4000'`, `'team_8000'`).
*/
export function getPlanPricing(plan: string): { basePrice: number } {
switch (plan) {
case 'free':
return { basePrice: 0 }
case 'pro':
return { basePrice: getProTierLimit() }
case 'team':
return { basePrice: getTeamTierLimitPerSeat() }
case 'enterprise':
return { basePrice: getEnterpriseTierLimitPerSeat() }
default:
return { basePrice: 0 }
if (isFree(plan)) return { basePrice: 0 }
if (isEnterprise(plan)) return { basePrice: getEnterpriseTierLimitPerSeat() }
if (isPro(plan) || isTeam(plan)) {
const tierCredits = getPlanTierCredits(plan)
if (tierCredits > 0) return { basePrice: tierCredits / CREDIT_MULTIPLIER }
return { basePrice: isPro(plan) ? getProTierLimit() : getTeamTierLimitPerSeat() }
}
return { basePrice: 0 }
}

View File

@@ -6,6 +6,14 @@ import type Stripe from 'stripe'
import { DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants'
import { calculateSubscriptionOverage, getPlanPricing } from '@/lib/billing/core/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
import {
getPlanTierDollars,
isEnterprise,
isFree,
isPaid,
isTeam,
} from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { env } from '@/lib/core/config/env'
@@ -107,15 +115,11 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
return
}
if (
!userSubscription.plan ||
userSubscription.plan === 'free' ||
userSubscription.plan === 'enterprise'
) {
if (isFree(userSubscription.plan) || isEnterprise(userSubscription.plan)) {
return
}
if (userSubscription.plan === 'team') {
if (isTeam(userSubscription.plan)) {
logger.debug('Team plan detected - triggering org-level threshold billing', {
userId,
organizationId: userSubscription.referenceId,
@@ -144,6 +148,8 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
plan: userSubscription.plan,
referenceId: userSubscription.referenceId,
seats: userSubscription.seats,
periodStart: userSubscription.periodStart,
periodEnd: userSubscription.periodEnd,
})
const billedOverageThisPeriod = parseDecimal(stats.billedOverageThisPeriod)
const unbilledOverage = Math.max(0, currentOverage - billedOverageThisPeriod)
@@ -303,7 +309,7 @@ export async function checkAndBillOrganizationOverageThreshold(
stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
})
if (orgSubscription.plan !== 'team') {
if (!isTeam(orgSubscription.plan)) {
logger.debug('Organization plan is not team, skipping', {
organizationId,
plan: orgSubscription.plan,
@@ -384,9 +390,25 @@ export async function checkAndBillOrganizationOverageThreshold(
}
}
let dailyRefreshDeduction = 0
if (isPaid(orgSubscription.plan) && orgSubscription.periodStart) {
const planDollars = getPlanTierDollars(orgSubscription.plan)
if (planDollars > 0) {
const allMemberIds = members.map((m) => m.userId)
dailyRefreshDeduction = await computeDailyRefreshConsumed({
userIds: allMemberIds,
periodStart: orgSubscription.periodStart,
periodEnd: orgSubscription.periodEnd ?? null,
planDollars,
seats: orgSubscription.seats ?? 1,
})
}
}
const effectiveTeamUsage = Math.max(0, totalTeamUsage - dailyRefreshDeduction)
const { basePrice: basePricePerSeat } = getPlanPricing(orgSubscription.plan)
const basePrice = basePricePerSeat * (orgSubscription.seats ?? 0)
const currentOverage = Math.max(0, totalTeamUsage - basePrice)
const currentOverage = Math.max(0, effectiveTeamUsage - basePrice)
const unbilledOverage = Math.max(0, currentOverage - totalBilledOverage)
logger.debug('Organization threshold billing check', {

View File

@@ -3,6 +3,7 @@ import { invitation, member, organization, subscription, user, userStats } from
import { createLogger } from '@sim/logger'
import { and, count, eq } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { isEnterprise, isFree, isPro } from '@/lib/billing/plan-helpers'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -62,7 +63,7 @@ export async function validateSeatAvailability(
}
// Free and Pro plans don't support organizations
if (['free', 'pro'].includes(subscription.plan)) {
if (isFree(subscription.plan) || isPro(subscription.plan)) {
return {
canInvite: false,
reason: 'Organization features require Team or Enterprise plan',
@@ -158,7 +159,7 @@ export async function getOrganizationSeatInfo(
// Team: seats from column, Enterprise: seats from metadata
const maxSeats = getEffectiveSeats(subscription)
const canAddSeats = subscription.plan !== 'enterprise'
const canAddSeats = !isEnterprise(subscription.plan)
const availableSeats = Math.max(0, maxSeats - currentSeats)

View File

@@ -15,6 +15,7 @@ import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership'
import { isEnterprise, isOrgPlan, isTeam } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -155,7 +156,7 @@ async function sendPaymentFailureEmails(
// Get users to notify
let usersToNotify: Array<{ email: string; name: string | null }> = []
if (sub.plan === 'team' || sub.plan === 'enterprise') {
if (isOrgPlan(sub.plan)) {
// For team/enterprise, notify all owners and admins
const members = await db
.select({
@@ -240,7 +241,7 @@ export async function getBilledOverageForSubscription(sub: {
plan: string | null
referenceId: string
}): Promise<number> {
if (sub.plan === 'team') {
if (isTeam(sub.plan)) {
const ownerRows = await db
.select({ userId: member.userId })
.from(member)
@@ -275,7 +276,7 @@ export async function getBilledOverageForSubscription(sub: {
}
export async function resetUsageForSubscription(sub: { plan: string | null; referenceId: string }) {
if (sub.plan === 'team' || sub.plan === 'enterprise') {
if (isOrgPlan(sub.plan)) {
const membersRows = await db
.select({ userId: member.userId })
.from(member)
@@ -479,7 +480,7 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
// Only reset usage here if the tenant was previously blocked; otherwise invoice.created already reset it
let wasBlocked = false
if (sub.plan === 'team' || sub.plan === 'enterprise') {
if (isOrgPlan(sub.plan)) {
const membersRows = await db
.select({ userId: member.userId })
.from(member)
@@ -502,7 +503,7 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
wasBlocked = row.length > 0 ? !!row[0].blocked : false
}
if (sub.plan === 'team' || sub.plan === 'enterprise') {
if (isOrgPlan(sub.plan)) {
await unblockOrgMembers(sub.referenceId, 'payment_failed')
} else {
// Only unblock users blocked for payment_failed, not disputes
@@ -599,7 +600,7 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
if (records.length > 0) {
const sub = records[0]
if (sub.plan === 'team' || sub.plan === 'enterprise') {
if (isOrgPlan(sub.plan)) {
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
logger.info('Blocked team/enterprise members due to payment failure', {
organizationId: sub.referenceId,
@@ -686,7 +687,7 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
const sub = records[0]
// Enterprise plans have no overages - reset usage and exit
if (sub.plan === 'enterprise') {
if (isEnterprise(sub.plan)) {
await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })
return
}
@@ -708,7 +709,7 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
// Apply credits to reduce overage at end of cycle
let creditsApplied = 0
if (remainingOverage > 0) {
const entityType = sub.plan === 'team' || sub.plan === 'enterprise' ? 'organization' : 'user'
const entityType = isOrgPlan(sub.plan) ? 'organization' : 'user'
const entityId = sub.referenceId
const { balance: creditBalance } = await getCreditBalance(entityId)

View File

@@ -6,6 +6,7 @@ import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { hasActiveSubscription } from '@/lib/billing/core/subscription'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import {
getBilledOverageForSubscription,
@@ -124,10 +125,7 @@ export async function handleSubscriptionCreated(subscriptionData: {
)
const wasFreePreviously = otherActiveSubscriptions.length === 0
const isPaidPlan =
subscriptionData.plan === 'pro' ||
subscriptionData.plan === 'team' ||
subscriptionData.plan === 'enterprise'
const isPaidPlan = isPaid(subscriptionData.plan)
if (wasFreePreviously && isPaidPlan) {
logger.info('Detected free -> paid transition, resetting usage', {
@@ -190,7 +188,7 @@ export async function handleSubscriptionDeleted(subscription: {
const stripe = requireStripeClient()
// Enterprise plans have no overages - reset usage and cleanup org
if (subscription.plan === 'enterprise') {
if (isEnterprise(subscription.plan)) {
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
@@ -316,12 +314,12 @@ export async function handleSubscriptionDeleted(subscription: {
let organizationDeleted = false
let membersSynced = 0
if (subscription.plan === 'team') {
if (isTeam(subscription.plan)) {
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
restoredProCount = cleanup.restoredProCount
membersSynced = cleanup.membersSynced
organizationDeleted = cleanup.organizationDeleted
} else if (subscription.plan === 'pro') {
} else if (isPro(subscription.plan)) {
await syncUsageLimitsFromSubscription(subscription.referenceId)
membersSynced = 1
}

View File

@@ -58,6 +58,20 @@ export const env = createEnv({
ENTERPRISE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for enterprise tier users
ENTERPRISE_STORAGE_LIMIT_GB: z.number().optional().default(500), // Default storage limit in GB for enterprise tier (can be overridden per org)
BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking
// Credit-tier Stripe prices (monthly)
STRIPE_PRICE_TIER_25_MO: z.string().min(1).optional(), // Pro: $25/mo (6,000 credits)
STRIPE_PRICE_TIER_100_MO: z.string().min(1).optional(), // Max: $100/mo (25,000 credits)
// Credit-tier Stripe prices (annual, 15% discount)
STRIPE_PRICE_TIER_25_YR: z.string().min(1).optional(), // Pro: $255/yr (15% off $300)
STRIPE_PRICE_TIER_100_YR: z.string().min(1).optional(), // Max: $1,020/yr (15% off $1,200)
// Team-specific Stripe prices (separate products for Billing Portal compat)
STRIPE_PRICE_TEAM_25_MO: z.string().min(1).optional(), // Team Pro: $25/seat/mo
STRIPE_PRICE_TEAM_25_YR: z.string().min(1).optional(), // Team Pro: $255/seat/yr
STRIPE_PRICE_TEAM_100_MO: z.string().min(1).optional(), // Team Max: $100/seat/mo
STRIPE_PRICE_TEAM_100_YR: z.string().min(1).optional(), // Team Max: $1,020/seat/yr
OVERAGE_THRESHOLD_DOLLARS: z.number().optional().default(50), // Dollar threshold for incremental overage billing (default: $50)
// Email & Communication

View File

@@ -1,3 +1,4 @@
import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers'
import { env } from '@/lib/core/config/env'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
@@ -61,13 +62,13 @@ const EXECUTION_TIMEOUTS: Record<SubscriptionPlan, ExecutionTimeoutConfig> = {
}
export function getExecutionTimeout(
plan: SubscriptionPlan | undefined,
plan: SubscriptionPlan | string | undefined,
type: 'sync' | 'async' = 'sync'
): number {
if (!isBillingEnabled) {
return EXECUTION_TIMEOUTS.free[type]
}
return EXECUTION_TIMEOUTS[plan || 'free'][type]
return EXECUTION_TIMEOUTS[getPlanTypeForLimits(plan)][type]
}
export function getMaxExecutionTimeout(): number {

View File

@@ -1,3 +1,4 @@
import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers'
import { env } from '@/lib/core/config/env'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import type { CoreTriggerType } from '@/stores/logs/filters/types'
@@ -88,14 +89,14 @@ export const RATE_LIMITS: Record<SubscriptionPlan, RateLimitConfig> = {
}
export function getRateLimit(
plan: SubscriptionPlan | undefined,
plan: SubscriptionPlan | string | undefined,
type: RateLimitCounterType
): TokenBucketConfig {
const key = toConfigKey(type)
if (!isBillingEnabled) {
return RATE_LIMITS.free[key]
}
return RATE_LIMITS[plan || 'free'][key]
return RATE_LIMITS[getPlanTypeForLimits(plan)][key]
}
export class RateLimitError extends Error {

View File

@@ -17,6 +17,7 @@ import {
maybeSendUsageThresholdEmail,
} from '@/lib/billing/core/usage'
import { logWorkflowUsageBatch } from '@/lib/billing/core/usage-log'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { redactApiKeys } from '@/lib/core/security/redaction'
@@ -317,9 +318,10 @@ export class ExecutionLogger implements IExecutionLoggerService {
const costDelta = costSummary.totalCost
const planName = sub?.plan || 'Free'
const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers')
const planName = getDisplayPlanName(sub?.plan)
const scope: 'user' | 'organization' =
sub && (sub.plan === 'team' || sub.plan === 'enterprise') ? 'organization' : 'user'
sub && isOrgPlan(sub.plan) ? 'organization' : 'user'
if (scope === 'user') {
const before = await checkUsageStatus(usr.id)

View File

@@ -59,13 +59,21 @@ export const FILTER_DEFINITIONS: FilterDefinition[] = [
label: 'Cost',
description: 'Filter by execution cost',
options: [
{ value: '>0.01', label: 'Over $0.01', description: 'Executions costing more than $0.01' },
{
value: '>0.01',
label: 'Over 2 credits',
description: 'Executions costing more than 2 credits',
},
{
value: '<0.005',
label: 'Under $0.005',
description: 'Executions costing less than $0.005',
label: 'Under 1 credit',
description: 'Executions costing less than 1 credit',
},
{
value: '>0.05',
label: 'Over 10 credits',
description: 'Executions costing more than 10 credits',
},
{ value: '>0.05', label: 'Over $0.05', description: 'Executions costing more than $0.05' },
{ value: '=0', label: 'Free', description: 'Free executions' },
{ value: '>0', label: 'Paid', description: 'Executions with cost' },
],

View File

@@ -2,6 +2,7 @@ import { createLogger, type Logger } from '@sim/logger'
import type OpenAI from 'openai'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import {
@@ -660,28 +661,18 @@ export function getModelPricing(modelId: string): any {
}
/**
* Format cost as a currency string
* Format cost as a credit string for display.
* Internally cost is in USD; this converts to credits (1 USD = 200 credits).
*
* @param cost Cost in USD
* @returns Formatted cost string
* @returns Formatted credit string (e.g. "200 credits", "<1 credit", "0 credits")
*/
export function formatCost(cost: number): string {
if (cost === undefined || cost === null) return '—'
if (cost >= 1) {
return `$${cost.toFixed(2)}`
}
if (cost >= 0.01) {
return `$${cost.toFixed(3)}`
}
if (cost >= 0.001) {
return `$${cost.toFixed(4)}`
}
if (cost > 0) {
const places = Math.max(4, Math.abs(Math.floor(Math.log10(cost))) + 3)
return `$${cost.toFixed(places)}`
}
return '$0'
const credits = dollarsToCredits(cost)
if (credits <= 0 && cost > 0) return '<1 credit'
if (credits <= 0) return '0 credits'
return `${credits.toLocaleString()} credits`
}
/**