mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
committed by
GitHub
parent
1def94392b
commit
1d955fc43a
@@ -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 (
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
}
|
||||
|
||||
|
||||
174
apps/sim/app/api/billing/switch-plan/route.ts
Normal file
174
apps/sim/app/api/billing/switch-plan/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { CancelSubscription } from './cancel-subscription'
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
19
apps/sim/lib/billing/credits/conversion.ts
Normal file
19
apps/sim/lib/billing/credits/conversion.ts
Normal 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()
|
||||
}
|
||||
164
apps/sim/lib/billing/credits/daily-refresh.test.ts
Normal file
164
apps/sim/lib/billing/credits/daily-refresh.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
94
apps/sim/lib/billing/credits/daily-refresh.ts
Normal file
94
apps/sim/lib/billing/credits/daily-refresh.ts
Normal 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
|
||||
}
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
126
apps/sim/lib/billing/plan-helpers.ts
Normal file
126
apps/sim/lib/billing/plan-helpers.ts
Normal 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}`
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user