import { useQueryClient } from '@tanstack/react-query' import { Check, ExternalLink, InfoIcon } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' import tweets from 'shared-data/tweets' import { toast } from 'sonner' import { billingPartnerLabel } from 'components/interfaces/Billing/Subscription/Subscription.utils' import AlertError from 'components/ui/AlertError' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { organizationKeys } from 'data/organizations/keys' import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizations/organization-billing-subscription-preview' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' import { SubscriptionTier } from 'data/subscriptions/types' import { PRICING_TIER_PRODUCT_IDS, PROJECT_STATUS } from 'lib/constants' import { formatCurrency } from 'lib/helpers' import { Badge, Button, Card, CardContent, Dialog, DialogContent, HoverCard, HoverCardContent, HoverCardTrigger, Table, TableBody, TableCell, TableRow, } from 'ui' import { InfoTooltip } from 'ui-patterns/info-tooltip' import PaymentMethodSelection from './PaymentMethodSelection' import { ProjectInfo } from 'data/projects/projects-query' import { Admonition } from 'ui-patterns' const getRandomTweet = () => { const filteredTweets = tweets.filter((it) => it.text.length < 180) const randomIndex = Math.floor(Math.random() * filteredTweets.length) return filteredTweets[randomIndex] } const PLAN_HEADINGS = { tier_pro: 'the Pro plan and create unlimited projects, daily backups and premium support when you need it', tier_team: 'the Team plan for SOC2, SSO, priority support and greater data and log retention', default: 'to a new plan', } as const type PlanHeadingKey = keyof typeof PLAN_HEADINGS // Add downgrade headings const DOWNGRADE_PLAN_HEADINGS = { tier_free: 'the Free plan with limited resources and active projects', tier_pro: 'the Pro plan', default: 'to a lower plan', } as const type DowngradePlanHeadingKey = keyof typeof DOWNGRADE_PLAN_HEADINGS interface Props { selectedTier: 'tier_free' | 'tier_pro' | 'tier_team' | undefined onClose: () => void subscriptionPlanMeta: any planMeta: any subscriptionPreviewError: any subscriptionPreviewIsLoading: boolean subscriptionPreviewInitialized: boolean subscriptionPreview: OrganizationBillingSubscriptionPreviewResponse | undefined billingViaPartner: boolean billingPartner?: string selectedOrganization: any subscription: any slug?: string currentPlanMeta: any projects: ProjectInfo[] } const SubscriptionPlanUpdateDialog = ({ selectedTier, onClose, subscriptionPlanMeta, planMeta, subscriptionPreviewError, subscriptionPreviewIsLoading, subscriptionPreviewInitialized, subscriptionPreview, billingViaPartner, billingPartner, selectedOrganization, subscription, slug, currentPlanMeta, projects, }: Props) => { const queryClient = useQueryClient() const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() const [testimonialTweet, setTestimonialTweet] = useState(getRandomTweet()) useEffect(() => { if (selectedTier !== undefined && selectedTier !== 'tier_free') { setTestimonialTweet(getRandomTweet()) } }, [selectedTier]) const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation( { onSuccess: () => { toast.success( `Successfully ${planMeta?.change_type === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!` ) onClose() window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) }, onError: (error) => { toast.error(`Unable to update subscription: ${error.message}`) }, } ) const onUpdateSubscription = async () => { if (!slug) return console.error('org slug is required') if (!selectedTier) return console.error('Selected plan is required') if (!selectedPaymentMethod && subscription?.payment_method_type !== 'invoice') { return toast.error('Please select a payment method') } if (selectedPaymentMethod) { queryClient.setQueriesData(organizationKeys.paymentMethods(slug), (prev: any) => { if (!prev) return prev return { ...prev, defaultPaymentMethodId: selectedPaymentMethod, data: prev.data.map((pm: any) => ({ ...pm, is_default: pm.id === selectedPaymentMethod, })), } }) } // If the user is downgrading from team, should have spend cap disabled by default const tier = subscription?.plan?.id === 'team' && selectedTier === PRICING_TIER_PRODUCT_IDS.PRO ? (PRICING_TIER_PRODUCT_IDS.PAYG as SubscriptionTier) : selectedTier updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod }) } const features = subscriptionPlanMeta?.features?.[0]?.features || [] const topFeatures = features // Get current plan features for downgrade comparison const currentPlanFeatures = currentPlanMeta?.features?.[0]?.features || [] // Features that will be lost when downgrading const featuresToLose = planMeta?.change_type === 'downgrade' ? currentPlanFeatures.filter((feature: string | [string, ...any[]]) => { const featureStr = typeof feature === 'string' ? feature : feature[0] // Check if this feature exists in the new plan return !topFeatures.some((newFeature: string | [string, ...any[]]) => { const newFeatureStr = typeof newFeature === 'string' ? newFeature : newFeature[0] return newFeatureStr === featureStr }) }) : [] return ( { if (!open) onClose() }} > event.preventDefault()} size="xlarge" className="p-0" >
{/* Left Column */}

{planMeta?.change_type === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '} {selectedOrganization?.name} to{' '} {planMeta?.change_type === 'downgrade' ? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default'] : PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}

{subscriptionPreviewIsLoading && (
)} {subscriptionPreviewInitialized && ( <> {(() => { // Calculate remaining days in current billing cycle const now = Math.floor(Date.now() / 1000) // current time in seconds const remainingSeconds = subscription?.current_period_end - now const totalSeconds = subscription?.current_period_end - subscription?.current_period_start const remainingRatio = remainingSeconds / totalSeconds // Calculate prorated credit for current plan const currentPlanMonthlyPrice = currentPlanMeta?.price ?? 0 const proratedCredit = currentPlanMonthlyPrice * remainingRatio // Calculate new plan cost const newPlanCost = subscriptionPlanMeta?.priceMonthly ?? 0 const customerBalance = ((subscription?.customer_balance ?? 0) / 100) * -1 // Calculate total charge (new plan - prorated credit) const totalCharge = Math.max( 0, newPlanCost - proratedCredit - customerBalance ) return ( <> {subscriptionPlanMeta?.name} Plan New {formatCurrency(newPlanCost)} {subscription?.plan?.id !== 'free' && ( Unused Time on {subscription?.plan?.name} Plan Your previous plan was charged upfront, so a plan change will prorate any unused time in credits. If the prorated credits exceed the new plan charge, the excessive credits are added to your organization for future use. -{formatCurrency(proratedCredit)} )} {/* Ignore rare case with negative balance (debt) */} {customerBalance > 0 && ( Credits Credits will be used first before charging your card. {formatCurrency(customerBalance)} )} Charge today {formatCurrency(totalCharge)} {subscription?.plan?.id !== 'free' && ( + current cycle usage Changing your plan resets your billing cycle. If your previous billing cycle still has any outstanding usage charges, they will be added to your total charge today. View spend )} ) })()}
Monthly invoice estimate is{' '} {formatCurrency( Math.round( subscriptionPreview?.breakdown.reduce( (prev: number, cur) => prev + cur.total_price, 0 ) ?? 0 ) )}

Your new monthly invoice

{subscriptionPreviewError && ( )} {subscriptionPreviewIsLoading && (
Estimating monthly costs...
)} {subscriptionPreviewInitialized && ( <> {/* Non-compute items and Projects list */} {(() => { // Combine all compute-related projects const computeItems = subscriptionPreview?.breakdown?.filter( (item) => item.description?.toLowerCase().includes('compute') && item.breakdown?.length > 0 ) || [] const computeCreditsItem = subscriptionPreview?.breakdown?.find((item) => item.description.startsWith('Compute Credits') ) ?? null const planItem = subscriptionPreview?.breakdown?.find((item) => item.description?.toLowerCase().includes('plan') ) const allProjects = computeItems.flatMap((item) => item.breakdown.map((project) => ({ ...project, computeType: item.description, computeCosts: Math.round( item.total_price / item.breakdown.length ), })) ) const otherItems = subscriptionPreview?.breakdown?.filter( (item) => !item.description?.toLowerCase().includes('compute') && !item.description?.toLowerCase().includes('plan') ) || [] const content = ( <> {/* Combined projects section */} {allProjects.length > 0 && ( <> {planItem?.description} {formatCurrency(planItem?.total_price)} Compute

Each project on a paid plan is a dedicated server running 24/7 with no pausing. The first project is covered by Compute Credits. Additional projects will incur compute costs starting at $10/month, independent of activity. See{' '} docs .

{subscription?.plan?.id === 'free' && ( <>

Mixing paid and non-paid projects in a single organization is not possible. If you want projects to be on the Free Plan, use self-serve project transfers.

)}
{formatCurrency( computeItems.reduce( (sum: number, item) => sum + item.total_price, 0 ) + (computeCreditsItem?.total_price ?? 0) )}
{/* Show first 3 projects */} {allProjects.map((project) => ( {project.project_name} ({project.computeType}) |{' '} {formatCurrency(project.computeCosts)} ))} {computeCreditsItem && ( Compute Credits |{' '} {formatCurrency(computeCreditsItem.total_price)} )} )} {/* Non-compute items */} {otherItems.map((item) => (
{item.description ?? 'Unknown'} {item.breakdown.length > 0 && (

Projects using {item.description}:

    {item.breakdown.map((breakdown) => (
  • {breakdown.project_name}
  • ))}
)}
{formatCurrency(item.total_price)}
))} ) return content })()} Total per month (excluding other usage) {formatCurrency( Math.round( subscriptionPreview?.breakdown?.reduce( (prev, cur) => prev + cur.total_price, 0 ) ?? 0 ) ?? 0 )}
)}
)}
{!billingViaPartner ? (
) : (

This organization is billed through our partner{' '} {billingPartnerLabel(billingPartner)}.{' '} {billingPartner === 'aws' ? ( <>The organization's credit balance will be decreased accordingly. ) : ( <>You will be charged by them directly. )}

{billingViaPartner && billingPartner === 'fly' && subscriptionPreview?.plan_change_type === 'downgrade' && (

Your organization will be downgraded at the end of your current billing cycle.

)}
)} {projects.filter( (it) => it.status === PROJECT_STATUS.ACTIVE_HEALTHY || it.status === PROJECT_STATUS.COMING_UP ).length === 0 && (
This organization has no active projects. Did you select the correct organization?
)}
{/* Right Column */}
{planMeta?.change_type === 'downgrade' ? featuresToLose.length > 0 && (

Features you'll lose

Please review this carefully before downgrading.

{featuresToLose.map((feature: string | [string, ...any[]]) => (

{typeof feature === 'string' ? feature : feature[0]}

))}
) : topFeatures.length > 0 && (

Upgrade features

{topFeatures.map((feature: string | [string, ...any[]]) => (

{typeof feature === 'string' ? feature : feature[0]}

{Array.isArray(feature) && feature.length > 1 && (

{feature[1]}

)}
))}
)} {planMeta?.change_type !== 'downgrade' && (
{testimonialTweet.text}
— @{testimonialTweet.handle}
)}
) } export default SubscriptionPlanUpdateDialog