From 7780d9b32b436e0e5f4f4f6f825496b2d744dec8 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 30 Aug 2025 17:26:17 -0700 Subject: [PATCH] fix(enterprise-billing): simplification to be fixed-cost (#1196) * fix(enterprise-billing): simplify * conceptual improvement * add seats to enterprise sub meta * correct type * fix UI * send emails to new enterprise users * fix fallback * fix merge conflict issue --------- Co-authored-by: waleedlatif1 --- .../components/subscription/subscription.tsx | 45 ++-- .../w/components/sidebar/sidebar.tsx | 1 + .../emails/enterprise-subscription-email.tsx | 122 +++++++++ apps/sim/components/emails/index.ts | 1 + apps/sim/components/emails/render-email.ts | 21 ++ apps/sim/lib/auth.ts | 117 +------- .../lib/billing/calculations/usage-monitor.ts | 6 +- apps/sim/lib/billing/core/billing.ts | 35 +-- .../lib/billing/core/organization-billing.ts | 67 +++-- apps/sim/lib/billing/core/subscription.ts | 35 ++- apps/sim/lib/billing/core/usage.ts | 8 +- apps/sim/lib/billing/index.ts | 6 +- .../lib/billing/subscriptions/utils.test.ts | 77 ------ apps/sim/lib/billing/subscriptions/utils.ts | 91 +------ apps/sim/lib/billing/types/index.ts | 16 +- .../lib/billing/validation/seat-management.ts | 45 +--- apps/sim/lib/billing/webhooks/enterprise.ts | 251 ++++++++++++++++++ apps/sim/lib/billing/webhooks/invoices.ts | 20 +- apps/sim/package.json | 2 +- bun.lock | 4 +- 20 files changed, 554 insertions(+), 416 deletions(-) create mode 100644 apps/sim/components/emails/enterprise-subscription-email.tsx delete mode 100644 apps/sim/lib/billing/subscriptions/utils.test.ts create mode 100644 apps/sim/lib/billing/webhooks/enterprise.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index e88bd9fe3..ab02c6402 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -197,10 +197,10 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { const activeOrgId = activeOrganization?.id useEffect(() => { - if (subscription.isTeam && activeOrgId) { + if ((subscription.isTeam || subscription.isEnterprise) && activeOrgId) { loadOrganizationBillingData(activeOrgId) } - }, [activeOrgId, subscription.isTeam, loadOrganizationBillingData]) + }, [activeOrgId, subscription.isTeam, subscription.isEnterprise, loadOrganizationBillingData]) // Auto-clear upgrade error useEffect(() => { @@ -349,22 +349,39 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { badgeText={badgeText} onBadgeClick={handleBadgeClick} seatsText={ - permissions.canManageTeam + permissions.canManageTeam || subscription.isEnterprise ? `${organizationBillingData?.totalSeats || subscription.seats || 1} seats` : undefined } - current={usage.current} + current={ + subscription.isEnterprise || subscription.isTeam + ? organizationBillingData?.totalCurrentUsage || 0 + : usage.current + } limit={ - !subscription.isFree && - (permissions.canEditUsageLimit || - permissions.showTeamMemberView || - subscription.isEnterprise) - ? usage.current // placeholder; rightContent will render UsageLimit - : usage.limit + subscription.isEnterprise || subscription.isTeam + ? organizationBillingData?.totalUsageLimit || + organizationBillingData?.minimumBillingAmount || + 0 + : !subscription.isFree && + (permissions.canEditUsageLimit || permissions.showTeamMemberView) + ? usage.current // placeholder; rightContent will render UsageLimit + : usage.limit } isBlocked={Boolean(subscriptionData?.billingBlocked)} status={billingStatus === 'unknown' ? 'ok' : billingStatus} - percentUsed={Math.round(usage.percentUsed)} + percentUsed={ + subscription.isEnterprise || subscription.isTeam + ? organizationBillingData?.totalUsageLimit && + organizationBillingData.totalUsageLimit > 0 + ? Math.round( + (organizationBillingData.totalCurrentUsage / + organizationBillingData.totalUsageLimit) * + 100 + ) + : 0 + : Math.round(usage.percentUsed) + } onResolvePayment={async () => { try { const res = await fetch('/api/billing/portal', { @@ -387,9 +404,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { }} rightContent={ !subscription.isFree && - (permissions.canEditUsageLimit || - permissions.showTeamMemberView || - subscription.isEnterprise) ? ( + (permissions.canEditUsageLimit || permissions.showTeamMemberView) ? ( + { + const brand = getBrandConfig() + + return ( + + + + Your Enterprise Plan is now active on Sim + +
+ + + {brand.name} + + +
+ +
+ + + + + +
+ +
+ Hello {userName}, + + Great news! Your Enterprise Plan has been activated on Sim. You now + have access to advanced features and increased capacity for your workflows. + + + + Your account has been set up with full access to your organization. Click below to log + in and start exploring your new Enterprise features: + + + + Access Your Enterprise Account + + + + What's next? + + + • Invite team members to your organization +
• Begin building your workflows +
+ + + If you have any questions or need assistance getting started, our support team is here + to help. + + + + Welcome to Sim Enterprise! +
+ The Sim Team +
+ + + This email was sent on {format(createdDate, 'MMMM do, yyyy')} to {userEmail} + regarding your Enterprise plan activation on Sim. + +
+
+ + + + + ) +} + +export default EnterpriseSubscriptionEmail diff --git a/apps/sim/components/emails/index.ts b/apps/sim/components/emails/index.ts index 60aeb1f09..d87d9c9a6 100644 --- a/apps/sim/components/emails/index.ts +++ b/apps/sim/components/emails/index.ts @@ -1,5 +1,6 @@ export * from './base-styles' export { BatchInvitationEmail } from './batch-invitation-email' +export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email' export { default as EmailFooter } from './footer' export { HelpConfirmationEmail } from './help-confirmation-email' export { InvitationEmail } from './invitation-email' diff --git a/apps/sim/components/emails/render-email.ts b/apps/sim/components/emails/render-email.ts index 45386aa92..dc7b4073f 100644 --- a/apps/sim/components/emails/render-email.ts +++ b/apps/sim/components/emails/render-email.ts @@ -1,6 +1,7 @@ import { render } from '@react-email/components' import { BatchInvitationEmail, + EnterpriseSubscriptionEmail, HelpConfirmationEmail, InvitationEmail, OTPVerificationEmail, @@ -82,6 +83,23 @@ export async function renderHelpConfirmationEmail( ) } +export async function renderEnterpriseSubscriptionEmail( + userName: string, + userEmail: string +): Promise { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' + const loginLink = `${baseUrl}/login` + + return await render( + EnterpriseSubscriptionEmail({ + userName, + userEmail, + loginLink, + createdDate: new Date(), + }) + ) +} + export function getEmailSubject( type: | 'sign-in' @@ -91,6 +109,7 @@ export function getEmailSubject( | 'invitation' | 'batch-invitation' | 'help-confirmation' + | 'enterprise-subscription' ): string { const brandName = getBrandConfig().name @@ -109,6 +128,8 @@ export function getEmailSubject( return `You've been invited to join a team and workspaces on ${brandName}` case 'help-confirmation': return 'Your request has been received' + case 'enterprise-subscription': + return `Your Enterprise Plan is now active on ${brandName}` default: return brandName } diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index f1f2a6c3f..2ff31bfbb 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -24,7 +24,7 @@ import { authorizeSubscriptionReference } from '@/lib/billing/authorization' import { handleNewUser } from '@/lib/billing/core/usage' import { syncSubscriptionUsageLimits } from '@/lib/billing/organization' import { getPlans } from '@/lib/billing/plans' -import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types' +import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise' import { handleInvoiceFinalized, handleInvoicePaymentFailed, @@ -52,121 +52,6 @@ if (validStripeKey) { }) } -function isEnterpriseMetadata(value: unknown): value is EnterpriseSubscriptionMetadata { - return ( - !!value && - typeof (value as any).plan === 'string' && - (value as any).plan.toLowerCase() === 'enterprise' - ) -} - -async function handleManualEnterpriseSubscription(event: Stripe.Event) { - const stripeSubscription = event.data.object as Stripe.Subscription - - const metaPlan = (stripeSubscription.metadata?.plan as string | undefined)?.toLowerCase() || '' - - if (metaPlan !== 'enterprise') { - logger.info('[subscription.created] Skipping non-enterprise subscription', { - subscriptionId: stripeSubscription.id, - plan: metaPlan || 'unknown', - }) - return - } - - const stripeCustomerId = stripeSubscription.customer as string - - if (!stripeCustomerId) { - logger.error('[subscription.created] Missing Stripe customer ID', { - subscriptionId: stripeSubscription.id, - }) - throw new Error('Missing Stripe customer ID on subscription') - } - - const metadata = stripeSubscription.metadata || {} - - const referenceId = - typeof metadata.referenceId === 'string' && metadata.referenceId.length > 0 - ? metadata.referenceId - : null - - if (!referenceId) { - logger.error('[subscription.created] Unable to resolve referenceId', { - subscriptionId: stripeSubscription.id, - stripeCustomerId, - }) - throw new Error('Unable to resolve referenceId for subscription') - } - - const firstItem = stripeSubscription.items?.data?.[0] - const seats = typeof firstItem?.quantity === 'number' ? firstItem.quantity : null - - if (!isEnterpriseMetadata(metadata)) { - logger.error('[subscription.created] Invalid enterprise metadata shape', { - subscriptionId: stripeSubscription.id, - metadata, - }) - throw new Error('Invalid enterprise metadata for subscription') - } - const enterpriseMetadata = metadata - const metadataJson: Record = { ...enterpriseMetadata } - - const subscriptionRow = { - id: crypto.randomUUID(), - plan: 'enterprise', - referenceId, - stripeCustomerId, - stripeSubscriptionId: stripeSubscription.id, - status: stripeSubscription.status || null, - periodStart: stripeSubscription.current_period_start - ? new Date(stripeSubscription.current_period_start * 1000) - : null, - periodEnd: stripeSubscription.current_period_end - ? new Date(stripeSubscription.current_period_end * 1000) - : null, - cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null, - seats, - trialStart: stripeSubscription.trial_start - ? new Date(stripeSubscription.trial_start * 1000) - : null, - trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null, - metadata: metadataJson, - } - - const existing = await db - .select({ id: schema.subscription.id }) - .from(schema.subscription) - .where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id)) - .limit(1) - - if (existing.length > 0) { - await db - .update(schema.subscription) - .set({ - plan: subscriptionRow.plan, - referenceId: subscriptionRow.referenceId, - stripeCustomerId: subscriptionRow.stripeCustomerId, - status: subscriptionRow.status, - periodStart: subscriptionRow.periodStart, - periodEnd: subscriptionRow.periodEnd, - cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd, - seats: subscriptionRow.seats, - trialStart: subscriptionRow.trialStart, - trialEnd: subscriptionRow.trialEnd, - metadata: subscriptionRow.metadata, - }) - .where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id)) - } else { - await db.insert(schema.subscription).values(subscriptionRow) - } - - logger.info('[subscription.created] Upserted subscription', { - subscriptionId: subscriptionRow.id, - referenceId: subscriptionRow.referenceId, - plan: subscriptionRow.plan, - status: subscriptionRow.status, - }) -} - export const auth = betterAuth({ baseURL: getBaseURL(), trustedOrigins: [ diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index c9c6c4e8f..0c8155611 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -31,9 +31,7 @@ export async function checkUsageStatus(userId: string): Promise { const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId)) const currentUsage = statsRecords.length > 0 - ? Number.parseFloat( - statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString() - ) + ? Number.parseFloat(statsRecords[0].currentPeriodCost?.toString()) : 0 return { @@ -117,7 +115,7 @@ export async function checkUsageStatus(userId: string): Promise { // Fall back to minimum billing amount from Stripe subscription const orgSub = await getOrganizationSubscription(org.id) if (orgSub?.seats) { - const { basePrice } = getPlanPricing(orgSub.plan, orgSub) + const { basePrice } = getPlanPricing(orgSub.plan) orgCap = (orgSub.seats || 1) * basePrice } else { // If no subscription, use team default diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 0ff242211..c1e650b7f 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -2,12 +2,10 @@ import { and, eq } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getUserUsageData } from '@/lib/billing/core/usage' import { - getEnterpriseTierLimitPerSeat, getFreeTierLimit, getProTierLimit, getTeamTierLimitPerSeat, } from '@/lib/billing/subscriptions/utils' -import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' import { member, subscription, user } from '@/db/schema' @@ -43,11 +41,8 @@ export async function getOrganizationSubscription(organizationId: string) { /** * Get plan pricing information */ -export function getPlanPricing( - plan: string, - subscription?: any -): { - basePrice: number // What they pay upfront via Stripe subscription (per seat for team/enterprise) +export function getPlanPricing(plan: string): { + basePrice: number // What they pay upfront via Stripe subscription } { switch (plan) { case 'free': @@ -55,25 +50,7 @@ export function getPlanPricing( case 'pro': return { basePrice: getProTierLimit() } case 'team': - return { basePrice: getTeamTierLimitPerSeat() } - case 'enterprise': - // Enterprise uses per-seat pricing like Team plans - // Custom per-seat price can be set in metadata - if (subscription?.metadata) { - const metadata: EnterpriseSubscriptionMetadata = - typeof subscription.metadata === 'string' - ? JSON.parse(subscription.metadata) - : subscription.metadata - - const perSeatPrice = metadata.perSeatPrice - ? Number.parseFloat(String(metadata.perSeatPrice)) - : undefined - if (perSeatPrice && perSeatPrice > 0 && !Number.isNaN(perSeatPrice)) { - return { basePrice: perSeatPrice } - } - } - // Default enterprise per-seat pricing - return { basePrice: getEnterpriseTierLimitPerSeat() } + return { basePrice: getTeamTierLimitPerSeat() } // Per-seat pricing default: return { basePrice: 0 } } @@ -103,7 +80,7 @@ export async function calculateUserOverage(userId: string): Promise<{ } const plan = subscription?.plan || 'free' - const { basePrice } = getPlanPricing(plan, subscription) + const { basePrice } = getPlanPricing(plan) const actualUsage = usageData.currentUsage // Calculate overage: any usage beyond what they already paid for @@ -197,7 +174,7 @@ export async function getSimplifiedBillingSummary( .from(member) .where(eq(member.organizationId, organizationId)) - const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan, subscription) + const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan) // Use licensed seats from Stripe as source of truth const licensedSeats = subscription.seats || 1 const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription @@ -270,7 +247,7 @@ export async function getSimplifiedBillingSummary( } // Individual billing summary - const { basePrice } = getPlanPricing(plan, subscription) + const { basePrice } = getPlanPricing(plan) // For team and enterprise plans, calculate total team usage instead of individual usage let currentUsage = usageData.currentUsage diff --git a/apps/sim/lib/billing/core/organization-billing.ts b/apps/sim/lib/billing/core/organization-billing.ts index fcb981128..634138ef0 100644 --- a/apps/sim/lib/billing/core/organization-billing.ts +++ b/apps/sim/lib/billing/core/organization-billing.ts @@ -131,35 +131,38 @@ export async function getOrganizationBillingData( const totalCurrentUsage = members.reduce((sum, member) => sum + member.currentUsage, 0) // Get per-seat pricing for the plan - const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan, subscription) + const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan) // Use Stripe subscription seats as source of truth // Ensure we always have at least 1 seat (protect against 0 or falsy values) const licensedSeats = Math.max(subscription.seats || 1, 1) - // Validate seat capacity - warn if members exceed licensed seats - if (members.length > licensedSeats) { - logger.warn('Organization has more members than licensed seats', { - organizationId, - licensedSeats, - actualMembers: members.length, - plan: subscription.plan, - }) + // Calculate minimum billing amount + let minimumBillingAmount: number + let totalUsageLimit: number + + if (subscription.plan === 'enterprise') { + // Enterprise has fixed pricing set through custom Stripe product + // Their usage limit is configured to match their monthly cost + const configuredLimit = organizationData.orgUsageLimit + ? Number.parseFloat(organizationData.orgUsageLimit) + : 0 + minimumBillingAmount = configuredLimit // For enterprise, this equals their fixed monthly cost + totalUsageLimit = configuredLimit // Same as their monthly cost + } else { + // Team plan: Billing is based on licensed seats from Stripe + minimumBillingAmount = licensedSeats * pricePerSeat + + // Total usage limit: never below the minimum based on licensed seats + const configuredLimit = organizationData.orgUsageLimit + ? Number.parseFloat(organizationData.orgUsageLimit) + : null + totalUsageLimit = + configuredLimit !== null + ? Math.max(configuredLimit, minimumBillingAmount) + : minimumBillingAmount } - // Billing is based on licensed seats from Stripe, not actual member count - // This ensures organizations pay for their seat capacity regardless of utilization - const minimumBillingAmount = licensedSeats * pricePerSeat - - // Total usage limit: never below the minimum based on licensed seats - const configuredLimit = organizationData.orgUsageLimit - ? Number.parseFloat(organizationData.orgUsageLimit) - : null - const totalUsageLimit = - configuredLimit !== null - ? Math.max(configuredLimit, minimumBillingAmount) - : minimumBillingAmount - const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0 // Billing period comes from the organization's subscription @@ -213,8 +216,24 @@ export async function updateOrganizationUsageLimit( return { success: false, error: 'No active subscription found' } } - // Calculate minimum based on seats - const { basePrice } = getPlanPricing(subscription.plan, subscription) + // Enterprise plans have fixed usage limits that cannot be changed + if (subscription.plan === 'enterprise') { + return { + success: false, + error: 'Enterprise plans have fixed usage limits that cannot be changed', + } + } + + // Only team plans can update their usage limits + if (subscription.plan !== 'team') { + return { + success: false, + error: 'Only team organizations can update usage limits', + } + } + + // Team plans have minimum based on seats + const { basePrice } = getPlanPricing(subscription.plan) const minimumLimit = Math.max(subscription.seats || 1, 1) * basePrice // Validate new limit is not below minimum diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 221d892d8..fb46fd56b 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -157,14 +157,26 @@ export async function hasExceededCostLimit(userId: string): Promise { // Calculate usage limit let limit = getFreeTierLimit() // Default free tier limit + if (subscription) { - limit = getPerUserMinimumLimit(subscription) - logger.info('Using subscription-based limit', { - userId, - plan: subscription.plan, - seats: subscription.seats || 1, - limit, - }) + // Team/Enterprise: Use organization limit + if (subscription.plan === 'team' || subscription.plan === 'enterprise') { + const { getUserUsageLimit } = await import('@/lib/billing/core/usage') + limit = await getUserUsageLimit(userId) + logger.info('Using organization limit', { + userId, + plan: subscription.plan, + limit, + }) + } else { + // Pro/Free: Use individual limit + limit = getPerUserMinimumLimit(subscription) + logger.info('Using subscription-based limit', { + userId, + plan: subscription.plan, + limit, + }) + } } else { logger.info('Using free tier limit', { userId, limit }) } @@ -231,7 +243,14 @@ export async function getUserSubscriptionState(userId: string): Promise 0) { let limit = getFreeTierLimit() // Default free tier limit if (subscription) { - limit = getPerUserMinimumLimit(subscription) + // Team/Enterprise: Use organization limit + if (subscription.plan === 'team' || subscription.plan === 'enterprise') { + const { getUserUsageLimit } = await import('@/lib/billing/core/usage') + limit = await getUserUsageLimit(userId) + } else { + // Pro/Free: Use individual limit + limit = getPerUserMinimumLimit(subscription) + } } const currentCost = Number.parseFloat( diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index e3eebd19f..c4d93257b 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -71,7 +71,7 @@ export async function getUserUsageData(userId: string): Promise { .limit(1) const { getPlanPricing } = await import('@/lib/billing/core/billing') - const { basePrice } = getPlanPricing(subscription.plan, subscription) + const { basePrice } = getPlanPricing(subscription.plan) const minimum = (subscription.seats || 1) * basePrice if (orgData.length > 0 && orgData[0].orgUsageLimit) { @@ -144,7 +144,7 @@ export async function getUserUsageLimitInfo(userId: string): Promise 0 && orgData[0].orgUsageLimit) { @@ -335,14 +335,14 @@ export async function getUserUsageLimit(userId: string): Promise { if (orgData[0].orgUsageLimit) { const configured = Number.parseFloat(orgData[0].orgUsageLimit) const { getPlanPricing } = await import('@/lib/billing/core/billing') - const { basePrice } = getPlanPricing(subscription.plan, subscription) + const { basePrice } = getPlanPricing(subscription.plan) const minimum = (subscription.seats || 1) * basePrice return Math.max(configured, minimum) } // If org hasn't set a custom limit, use minimum (seats × cost per seat) const { getPlanPricing } = await import('@/lib/billing/core/billing') - const { basePrice } = getPlanPricing(subscription.plan, subscription) + const { basePrice } = getPlanPricing(subscription.plan) return (subscription.seats || 1) * basePrice } diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts index a239fd1b8..082ee4f34 100644 --- a/apps/sim/lib/billing/index.ts +++ b/apps/sim/lib/billing/index.ts @@ -23,10 +23,6 @@ export { updateUserUsageLimit as updateUsageLimit, } from '@/lib/billing/core/usage' export * from '@/lib/billing/subscriptions/utils' -export { - canEditUsageLimit as canEditLimit, - getMinimumUsageLimit as getMinimumLimit, - getSubscriptionAllowance as getDefaultLimit, -} from '@/lib/billing/subscriptions/utils' +export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils' export * from '@/lib/billing/types' export * from '@/lib/billing/validation/seat-management' diff --git a/apps/sim/lib/billing/subscriptions/utils.test.ts b/apps/sim/lib/billing/subscriptions/utils.test.ts deleted file mode 100644 index db45010ce..000000000 --- a/apps/sim/lib/billing/subscriptions/utils.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { checkEnterprisePlan, getSubscriptionAllowance } from '@/lib/billing/subscriptions/utils' - -vi.mock('@/lib/env', () => ({ - env: { - FREE_TIER_COST_LIMIT: 10, - PRO_TIER_COST_LIMIT: 20, - TEAM_TIER_COST_LIMIT: 40, - ENTERPRISE_TIER_COST_LIMIT: 200, - }, - isTruthy: (value: string | boolean | number | undefined) => - typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value), - getEnv: (variable: string) => process.env[variable], -})) - -describe('Subscription Utilities', () => { - describe('checkEnterprisePlan', () => { - it.concurrent('returns true for active enterprise subscription', () => { - expect(checkEnterprisePlan({ plan: 'enterprise', status: 'active' })).toBeTruthy() - }) - - it.concurrent('returns false for inactive enterprise subscription', () => { - expect(checkEnterprisePlan({ plan: 'enterprise', status: 'canceled' })).toBeFalsy() - }) - - it.concurrent('returns false when plan is not enterprise', () => { - expect(checkEnterprisePlan({ plan: 'pro', status: 'active' })).toBeFalsy() - }) - }) - - describe('getSubscriptionAllowance', () => { - it.concurrent('returns free-tier limit when subscription is null', () => { - expect(getSubscriptionAllowance(null)).toBe(10) - }) - - it.concurrent('returns free-tier limit when subscription is undefined', () => { - expect(getSubscriptionAllowance(undefined)).toBe(10) - }) - - it.concurrent('returns free-tier limit when subscription is not active', () => { - expect(getSubscriptionAllowance({ plan: 'pro', status: 'canceled', seats: 1 })).toBe(10) - }) - - it.concurrent('returns pro limit for active pro plan', () => { - expect(getSubscriptionAllowance({ plan: 'pro', status: 'active', seats: 1 })).toBe(20) - }) - - it.concurrent('returns team limit multiplied by seats', () => { - expect(getSubscriptionAllowance({ plan: 'team', status: 'active', seats: 3 })).toBe(3 * 40) - }) - - it.concurrent('returns enterprise limit using perSeatPrice metadata', () => { - const sub = { - plan: 'enterprise', - status: 'active', - seats: 10, - metadata: { perSeatPrice: 150 }, - } - expect(getSubscriptionAllowance(sub)).toBe(10 * 150) - }) - - it.concurrent('returns enterprise limit using perSeatPrice as string', () => { - const sub = { - plan: 'enterprise', - status: 'active', - seats: 8, - metadata: { perSeatPrice: '250' }, - } - expect(getSubscriptionAllowance(sub)).toBe(8 * 250) - }) - - it.concurrent('falls back to default enterprise tier when metadata missing', () => { - const sub = { plan: 'enterprise', status: 'active', seats: 2, metadata: {} } - expect(getSubscriptionAllowance(sub)).toBe(2 * 200) - }) - }) -}) diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts index a2cc6c8cd..baabb5e42 100644 --- a/apps/sim/lib/billing/subscriptions/utils.ts +++ b/apps/sim/lib/billing/subscriptions/utils.ts @@ -4,7 +4,6 @@ import { DEFAULT_PRO_TIER_COST_LIMIT, DEFAULT_TEAM_TIER_COST_LIMIT, } from '@/lib/billing/constants' -import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types' import { env } from '@/lib/env' /** @@ -47,51 +46,10 @@ export function checkTeamPlan(subscription: any): boolean { return subscription?.plan === 'team' && subscription?.status === 'active' } -/** - * Calculate the total subscription-level allowance (what the org/user gets for their base payment) - * - Pro: Fixed amount per user - * - Team: Seats * base price (pooled for the org) - * - Enterprise: Seats * per-seat price (pooled, with optional custom pricing in metadata) - * @param subscription The subscription object - * @returns The total subscription allowance in dollars - */ -export function getSubscriptionAllowance(subscription: any): number { - if (!subscription || subscription.status !== 'active') { - return getFreeTierLimit() - } - - const seats = subscription.seats || 1 - - if (subscription.plan === 'pro') { - return getProTierLimit() - } - if (subscription.plan === 'team') { - return seats * getTeamTierLimitPerSeat() - } - if (subscription.plan === 'enterprise') { - const metadata = subscription.metadata as EnterpriseSubscriptionMetadata | undefined - - // Enterprise uses per-seat pricing (pooled like Team) - // Custom per-seat price can be set in metadata - let perSeatPrice = getEnterpriseTierLimitPerSeat() - if (metadata?.perSeatPrice) { - const parsed = Number.parseFloat(String(metadata.perSeatPrice)) - if (parsed > 0 && !Number.isNaN(parsed)) { - perSeatPrice = parsed - } - } - - return seats * perSeatPrice - } - - return getFreeTierLimit() -} - /** * Get the minimum usage limit for an individual user (used for validation) - * - Pro: User's plan minimum - * - Team: 0 (pooled model, no individual minimums) - * - Enterprise: 0 (pooled model, no individual minimums) + * Only applicable for plans with individual limits (Free/Pro) + * Team and Enterprise plans use organization-level limits instead * @param subscription The subscription object * @returns The per-user minimum limit in dollars */ @@ -100,27 +58,15 @@ export function getPerUserMinimumLimit(subscription: any): number { return getFreeTierLimit() } - const seats = subscription.seats || 1 - if (subscription.plan === 'pro') { return getProTierLimit() } - if (subscription.plan === 'team') { - // For team plans, return the total pooled limit (seats * cost per seat) - // This becomes the user's individual limit representing their share of the team pool - return seats * getTeamTierLimitPerSeat() - } - if (subscription.plan === 'enterprise') { - // For enterprise plans, return the total pooled limit (seats * cost per seat) - // This becomes the user's individual limit representing their share of the enterprise pool - let perSeatPrice = getEnterpriseTierLimitPerSeat() - if (subscription.metadata?.perSeatPrice) { - const parsed = Number.parseFloat(String(subscription.metadata.perSeatPrice)) - if (parsed > 0 && !Number.isNaN(parsed)) { - perSeatPrice = parsed - } - } - return seats * perSeatPrice + + if (subscription.plan === 'team' || subscription.plan === 'enterprise') { + // 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 + return 0 } return getFreeTierLimit() @@ -128,7 +74,8 @@ export function getPerUserMinimumLimit(subscription: any): number { /** * Check if a user can edit their usage limits based on their subscription - * Free plan users cannot edit limits, paid plan users can + * Free and Enterprise plans cannot edit limits + * Pro and Team plans can increase their limits * @param subscription The subscription object * @returns Whether the user can edit their usage limits */ @@ -137,19 +84,7 @@ export function canEditUsageLimit(subscription: any): boolean { return false // Free plan users cannot edit limits } - return ( - subscription.plan === 'pro' || - subscription.plan === 'team' || - subscription.plan === 'enterprise' - ) -} - -/** - * Get the minimum allowed usage limit for a subscription - * This prevents users from setting limits below their plan's base amount - * @param subscription The subscription object - * @returns The minimum allowed usage limit in dollars - */ -export function getMinimumUsageLimit(subscription: any): number { - return getPerUserMinimumLimit(subscription) + // Only Pro and Team plans can edit limits + // Enterprise has fixed limits that match their monthly cost + return subscription.plan === 'pro' || subscription.plan === 'team' } diff --git a/apps/sim/lib/billing/types/index.ts b/apps/sim/lib/billing/types/index.ts index 4f43cef2e..e3c3f2de5 100644 --- a/apps/sim/lib/billing/types/index.ts +++ b/apps/sim/lib/billing/types/index.ts @@ -5,15 +5,15 @@ export interface EnterpriseSubscriptionMetadata { plan: 'enterprise' - // Custom per-seat pricing (defaults to DEFAULT_ENTERPRISE_TIER_COST_LIMIT) + // The referenceId must be provided in Stripe metadata to link to the organization + // This gets stored in the subscription.referenceId column referenceId: string - perSeatPrice?: number - - // Maximum allowed seats (defaults to subscription.seats) - maxSeats?: number - - // Whether seats are fixed and cannot be changed - fixedSeats?: boolean + // The fixed monthly price for this enterprise customer (as string from Stripe metadata) + // This will be used to set the organization's usage limit + monthlyPrice: string + // Number of seats for invitation limits (not for billing) (as string from Stripe metadata) + // We set Stripe quantity to 1 and use this for actual seat count + seats: string } export interface UsageData { diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts index 5c567a77d..d2c43755a 100644 --- a/apps/sim/lib/billing/validation/seat-management.ts +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -1,6 +1,5 @@ import { and, count, eq } from 'drizzle-orm' import { getOrganizationSubscription } from '@/lib/billing/core/billing' -import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types' import { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' @@ -67,26 +66,9 @@ export async function validateSeatAvailability( const currentSeats = memberCount[0]?.count || 0 // Determine seat limits based on subscription - let maxSeats = subscription.seats || 1 - - // For enterprise plans, check metadata for custom seat allowances - if (subscription.plan === 'enterprise' && subscription.metadata) { - try { - const metadata: EnterpriseSubscriptionMetadata = - typeof subscription.metadata === 'string' - ? JSON.parse(subscription.metadata) - : subscription.metadata - if (metadata.maxSeats && typeof metadata.maxSeats === 'number') { - maxSeats = metadata.maxSeats - } - } catch (error) { - logger.warn('Failed to parse enterprise subscription metadata', { - organizationId, - metadata: subscription.metadata, - error, - }) - } - } + // Team: seats from Stripe subscription quantity + // Enterprise: seats from metadata (stored in subscription.seats) + const maxSeats = subscription.seats || 1 const availableSeats = Math.max(0, maxSeats - currentSeats) const canInvite = availableSeats >= additionalSeats @@ -162,24 +144,11 @@ export async function getOrganizationSeatInfo( const currentSeats = memberCount[0]?.count || 0 // Determine seat limits - let maxSeats = subscription.seats || 1 - let canAddSeats = true + const maxSeats = subscription.seats || 1 - if (subscription.plan === 'enterprise' && subscription.metadata) { - try { - const metadata: EnterpriseSubscriptionMetadata = - typeof subscription.metadata === 'string' - ? JSON.parse(subscription.metadata) - : subscription.metadata - if (metadata.maxSeats && typeof metadata.maxSeats === 'number') { - maxSeats = metadata.maxSeats - } - // Enterprise plans might have fixed seat counts - canAddSeats = !metadata.fixedSeats - } catch (error) { - logger.warn('Failed to parse enterprise subscription metadata', { organizationId, error }) - } - } + // Enterprise plans have fixed seats (can't self-serve changes) + // Team plans can add seats through Stripe + const canAddSeats = subscription.plan !== 'enterprise' const availableSeats = Math.max(0, maxSeats - currentSeats) diff --git a/apps/sim/lib/billing/webhooks/enterprise.ts b/apps/sim/lib/billing/webhooks/enterprise.ts new file mode 100644 index 000000000..18191e785 --- /dev/null +++ b/apps/sim/lib/billing/webhooks/enterprise.ts @@ -0,0 +1,251 @@ +import { eq } from 'drizzle-orm' +import type Stripe from 'stripe' +import { + getEmailSubject, + renderEnterpriseSubscriptionEmail, +} from '@/components/emails/render-email' +import { sendEmail } from '@/lib/email/mailer' +import { getFromEmailAddress } from '@/lib/email/utils' +import { createLogger } from '@/lib/logs/console/logger' +import { db } from '@/db' +import { organization, subscription, user } from '@/db/schema' +import type { EnterpriseSubscriptionMetadata } from '../types' + +const logger = createLogger('BillingEnterprise') + +function isEnterpriseMetadata(value: unknown): value is EnterpriseSubscriptionMetadata { + return ( + !!value && + typeof value === 'object' && + 'plan' in value && + 'referenceId' in value && + 'monthlyPrice' in value && + 'seats' in value && + typeof value.plan === 'string' && + value.plan.toLowerCase() === 'enterprise' && + typeof value.referenceId === 'string' && + typeof value.monthlyPrice === 'string' && + typeof value.seats === 'string' + ) +} + +export async function handleManualEnterpriseSubscription(event: Stripe.Event) { + const stripeSubscription = event.data.object as Stripe.Subscription + + const metaPlan = (stripeSubscription.metadata?.plan as string | undefined)?.toLowerCase() || '' + + if (metaPlan !== 'enterprise') { + logger.info('[subscription.created] Skipping non-enterprise subscription', { + subscriptionId: stripeSubscription.id, + plan: metaPlan || 'unknown', + }) + return + } + + const stripeCustomerId = stripeSubscription.customer as string + + if (!stripeCustomerId) { + logger.error('[subscription.created] Missing Stripe customer ID', { + subscriptionId: stripeSubscription.id, + }) + throw new Error('Missing Stripe customer ID on subscription') + } + + const metadata = stripeSubscription.metadata || {} + + const referenceId = + typeof metadata.referenceId === 'string' && metadata.referenceId.length > 0 + ? metadata.referenceId + : null + + if (!referenceId) { + logger.error('[subscription.created] Unable to resolve referenceId', { + subscriptionId: stripeSubscription.id, + stripeCustomerId, + }) + throw new Error('Unable to resolve referenceId for subscription') + } + + if (!isEnterpriseMetadata(metadata)) { + logger.error('[subscription.created] Invalid enterprise metadata shape', { + subscriptionId: stripeSubscription.id, + metadata, + }) + throw new Error('Invalid enterprise metadata for subscription') + } + const enterpriseMetadata = metadata + const metadataJson: Record = { ...enterpriseMetadata } + + // Extract and parse seats and monthly price from metadata (they come as strings from Stripe) + const seats = Number.parseInt(enterpriseMetadata.seats, 10) + const monthlyPrice = Number.parseFloat(enterpriseMetadata.monthlyPrice) + + if (!seats || seats <= 0 || Number.isNaN(seats)) { + logger.error('[subscription.created] Invalid or missing seats in enterprise metadata', { + subscriptionId: stripeSubscription.id, + seatsRaw: enterpriseMetadata.seats, + seatsParsed: seats, + }) + throw new Error('Enterprise subscription must include valid seats in metadata') + } + + if (!monthlyPrice || monthlyPrice <= 0 || Number.isNaN(monthlyPrice)) { + logger.error('[subscription.created] Invalid or missing monthlyPrice in enterprise metadata', { + subscriptionId: stripeSubscription.id, + monthlyPriceRaw: enterpriseMetadata.monthlyPrice, + monthlyPriceParsed: monthlyPrice, + }) + throw new Error('Enterprise subscription must include valid monthlyPrice in metadata') + } + + const subscriptionRow = { + id: crypto.randomUUID(), + plan: 'enterprise', + referenceId, + stripeCustomerId, + stripeSubscriptionId: stripeSubscription.id, + status: stripeSubscription.status || null, + periodStart: stripeSubscription.current_period_start + ? new Date(stripeSubscription.current_period_start * 1000) + : null, + periodEnd: stripeSubscription.current_period_end + ? new Date(stripeSubscription.current_period_end * 1000) + : null, + cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null, + seats, + trialStart: stripeSubscription.trial_start + ? new Date(stripeSubscription.trial_start * 1000) + : null, + trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null, + metadata: metadataJson, + } + + const existing = await db + .select({ id: subscription.id }) + .from(subscription) + .where(eq(subscription.stripeSubscriptionId, stripeSubscription.id)) + .limit(1) + + if (existing.length > 0) { + await db + .update(subscription) + .set({ + plan: subscriptionRow.plan, + referenceId: subscriptionRow.referenceId, + stripeCustomerId: subscriptionRow.stripeCustomerId, + status: subscriptionRow.status, + periodStart: subscriptionRow.periodStart, + periodEnd: subscriptionRow.periodEnd, + cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd, + seats: subscriptionRow.seats, + trialStart: subscriptionRow.trialStart, + trialEnd: subscriptionRow.trialEnd, + metadata: subscriptionRow.metadata, + }) + .where(eq(subscription.stripeSubscriptionId, stripeSubscription.id)) + } else { + await db.insert(subscription).values(subscriptionRow) + } + + // Update the organization's usage limit to match the monthly price + // The referenceId for enterprise plans is the organization ID + try { + await db + .update(organization) + .set({ + orgUsageLimit: monthlyPrice.toFixed(2), + updatedAt: new Date(), + }) + .where(eq(organization.id, referenceId)) + + logger.info('[subscription.created] Updated organization usage limit', { + organizationId: referenceId, + usageLimit: monthlyPrice, + }) + } catch (error) { + logger.error('[subscription.created] Failed to update organization usage limit', { + organizationId: referenceId, + usageLimit: monthlyPrice, + error, + }) + // Don't throw - the subscription was created successfully, just log the error + } + + logger.info('[subscription.created] Upserted enterprise subscription', { + subscriptionId: subscriptionRow.id, + referenceId: subscriptionRow.referenceId, + plan: subscriptionRow.plan, + status: subscriptionRow.status, + monthlyPrice, + seats, + note: 'Seats from metadata, Stripe quantity set to 1', + }) + + try { + const userDetails = await db + .select({ + id: user.id, + name: user.name, + email: user.email, + }) + .from(user) + .where(eq(user.stripeCustomerId, stripeCustomerId)) + .limit(1) + + const orgDetails = await db + .select({ + id: organization.id, + name: organization.name, + }) + .from(organization) + .where(eq(organization.id, referenceId)) + .limit(1) + + if (userDetails.length > 0 && orgDetails.length > 0) { + const user = userDetails[0] + const org = orgDetails[0] + + const html = await renderEnterpriseSubscriptionEmail(user.name || user.email, user.email) + + const emailResult = await sendEmail({ + to: user.email, + subject: getEmailSubject('enterprise-subscription'), + html, + from: getFromEmailAddress(), + emailType: 'transactional', + }) + + if (emailResult.success) { + logger.info('[subscription.created] Enterprise subscription email sent successfully', { + userId: user.id, + email: user.email, + organizationId: org.id, + subscriptionId: subscriptionRow.id, + }) + } else { + logger.warn('[subscription.created] Failed to send enterprise subscription email', { + userId: user.id, + email: user.email, + error: emailResult.message, + }) + } + } else { + logger.warn( + '[subscription.created] Could not find user or organization for email notification', + { + userFound: userDetails.length > 0, + orgFound: orgDetails.length > 0, + stripeCustomerId, + referenceId, + } + ) + } + } catch (emailError) { + logger.error('[subscription.created] Error sending enterprise subscription email', { + error: emailError, + stripeCustomerId, + referenceId, + subscriptionId: subscriptionRow.id, + }) + } +} diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index 0b79a9bb9..2557e2d10 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -197,6 +197,7 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { /** * Handle base invoice finalized → create a separate overage-only invoice + * Note: Enterprise plans no longer have overages */ export async function handleInvoiceFinalized(event: Stripe.Event) { try { @@ -215,14 +216,22 @@ export async function handleInvoiceFinalized(event: Stripe.Event) { if (records.length === 0) return const sub = records[0] + // Always reset usage at cycle end for all plans + await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) + + // Enterprise plans have no overages - skip overage invoice creation + if (sub.plan === 'enterprise') { + return + } + const stripe = requireStripeClient() const periodEnd = invoice.lines?.data?.[0]?.period?.end || invoice.period_end || Math.floor(Date.now() / 1000) const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7) - // Compute overage + // Compute overage (only for team and pro plans) let totalOverage = 0 - if (sub.plan === 'team' || sub.plan === 'enterprise') { + if (sub.plan === 'team') { const members = await db .select({ userId: member.userId }) .from(member) @@ -235,19 +244,16 @@ export async function handleInvoiceFinalized(event: Stripe.Event) { } const { getPlanPricing } = await import('@/lib/billing/core/billing') - const { basePrice } = getPlanPricing(sub.plan, sub) + const { basePrice } = getPlanPricing(sub.plan) const baseSubscriptionAmount = (sub.seats || 1) * basePrice totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount) } else { const usage = await getUserUsageData(sub.referenceId) const { getPlanPricing } = await import('@/lib/billing/core/billing') - const { basePrice } = getPlanPricing(sub.plan, sub) + const { basePrice } = getPlanPricing(sub.plan) totalOverage = Math.max(0, usage.currentUsage - basePrice) } - // Always reset usage at cycle end, regardless of whether overage > 0 - await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) - if (totalOverage <= 0) return const customerId = String(invoice.customer) diff --git a/apps/sim/package.json b/apps/sim/package.json index 55f8210cc..01deba4b4 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -132,7 +132,7 @@ "zod": "^3.24.2" }, "devDependencies": { - "@react-email/preview-server": "4.2.4", + "@react-email/preview-server": "4.2.8", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/bun.lock b/bun.lock index d8bf6b644..b035c8d23 100644 --- a/bun.lock +++ b/bun.lock @@ -160,7 +160,7 @@ "zod": "^3.24.2", }, "devDependencies": { - "@react-email/preview-server": "4.2.4", + "@react-email/preview-server": "4.2.8", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -1002,7 +1002,7 @@ "@react-email/preview": ["@react-email/preview@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-g/H5fa9PQPDK6WUEG7iTlC19sAktI23qyoiJtMLqQiXFCfWeQMhqjLGKeLSKkfzszqmfJCjZtpSiKtBoOdxp3Q=="], - "@react-email/preview-server": ["@react-email/preview-server@4.2.4", "", { "dependencies": { "@babel/core": "7.26.10", "@babel/parser": "7.27.0", "@babel/traverse": "7.27.0", "@lottiefiles/dotlottie-react": "0.13.3", "@radix-ui/colors": "3.0.0", "@radix-ui/react-collapsible": "1.1.7", "@radix-ui/react-dropdown-menu": "2.1.10", "@radix-ui/react-popover": "1.1.10", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "1.1.7", "@radix-ui/react-toggle-group": "1.1.6", "@radix-ui/react-tooltip": "1.2.3", "@types/node": "22.14.1", "@types/normalize-path": "3.0.2", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", "@types/webpack": "5.28.5", "autoprefixer": "10.4.21", "chalk": "4.1.2", "clsx": "2.1.1", "esbuild": "0.25.0", "framer-motion": "12.7.5", "json5": "2.2.3", "log-symbols": "4.1.0", "module-punycode": "npm:punycode@2.3.1", "next": "15.4.1", "node-html-parser": "7.0.1", "ora": "5.4.1", "pretty-bytes": "6.1.1", "prism-react-renderer": "2.4.1", "react": "19.0.0", "react-dom": "19.0.0", "sharp": "0.34.1", "socket.io-client": "4.8.1", "sonner": "2.0.3", "source-map-js": "1.2.1", "spamc": "0.0.5", "stacktrace-parser": "0.1.11", "tailwind-merge": "3.2.0", "tailwindcss": "3.4.0", "use-debounce": "10.0.4", "zod": "3.24.3" } }, "sha512-QRh7MUK9rG48lwIvwHoL8ByNCNkQzX9G7hl8T+IsleI55lGeAtlAzze/QHeLfoYZ7wl5LCG05ok/00DP06Xogw=="], + "@react-email/preview-server": ["@react-email/preview-server@4.2.8", "", { "dependencies": { "@babel/core": "7.26.10", "@babel/parser": "7.27.0", "@babel/traverse": "7.27.0", "@lottiefiles/dotlottie-react": "0.13.3", "@radix-ui/colors": "3.0.0", "@radix-ui/react-collapsible": "1.1.7", "@radix-ui/react-dropdown-menu": "2.1.10", "@radix-ui/react-popover": "1.1.10", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "1.1.7", "@radix-ui/react-toggle-group": "1.1.6", "@radix-ui/react-tooltip": "1.2.3", "@types/node": "22.14.1", "@types/normalize-path": "3.0.2", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", "@types/webpack": "5.28.5", "autoprefixer": "10.4.21", "chalk": "4.1.2", "clsx": "2.1.1", "esbuild": "0.25.0", "framer-motion": "12.23.12", "json5": "2.2.3", "log-symbols": "4.1.0", "module-punycode": "npm:punycode@2.3.1", "next": "15.4.1", "node-html-parser": "7.0.1", "ora": "5.4.1", "pretty-bytes": "6.1.1", "prism-react-renderer": "2.4.1", "react": "19.0.0", "react-dom": "19.0.0", "sharp": "0.34.1", "socket.io-client": "4.8.1", "sonner": "2.0.3", "source-map-js": "1.2.1", "spamc": "0.0.5", "stacktrace-parser": "0.1.11", "tailwind-merge": "3.2.0", "tailwindcss": "3.4.0", "use-debounce": "10.0.4", "zod": "3.24.3" } }, "sha512-q/Y4VQtFsrOiTYAAh84M+acu04OROz1Ay2RQCWX6+5GlM+gZkq4tXiE7TXfTj4dFdPkPvU3mCr6LP6Y2yPnXNg=="], "@react-email/render": ["@react-email/render@1.0.5", "", { "dependencies": { "html-to-text": "9.0.5", "prettier": "3.4.2", "react-promise-suspense": "0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-CA69HYXPk21HhtAXATIr+9JJwpDNmAFCvdMUjWmeoD1+KhJ9NAxusMRxKNeibdZdslmq3edaeOKGbdQ9qjK8LQ=="],