import { Check, InfoIcon } from 'lucide-react' import Link from 'next/link' import { useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { Elements } from '@stripe/react-stripe-js' import { loadStripe, PaymentIntentResult, StripeElementsOptions } from '@stripe/stripe-js' import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' import { billingPartnerLabel, getPlanChangeType, } from 'components/interfaces/Billing/Subscription/Subscription.utils' import AlertError from 'components/ui/AlertError' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizations/organization-billing-subscription-preview' import { ProjectInfo } from 'data/projects/projects-query' import { useConfirmPendingSubscriptionChangeMutation } from 'data/subscriptions/org-subscription-confirm-pending-change' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' import { SubscriptionTier } from 'data/subscriptions/types' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { PRICING_TIER_PRODUCT_IDS, PROJECT_STATUS, STRIPE_PUBLIC_KEY } from 'lib/constants' import { formatCurrency } from 'lib/helpers' import { useTheme } from 'next-themes' import { plans as subscriptionsPlans } from 'shared-data/plans' import { Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui' import { Admonition } from 'ui-patterns' import { InfoTooltip } from 'ui-patterns/info-tooltip' import type { PaymentMethodElementRef } from '../PaymentMethods/NewPaymentMethodElement' import PaymentMethodSelection from './PaymentMethodSelection' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) const PLAN_HEADINGS = { tier_pro: 'the Pro plan to unlock unlimited projects, daily backups, and email support whenever 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 planMeta: any subscriptionPreviewError: any subscriptionPreviewIsLoading: boolean subscriptionPreviewInitialized: boolean subscriptionPreview: OrganizationBillingSubscriptionPreviewResponse | undefined subscription: any currentPlanMeta: any projects: ProjectInfo[] } export const SubscriptionPlanUpdateDialog = ({ selectedTier, onClose, planMeta, subscriptionPreviewError, subscriptionPreviewIsLoading, subscriptionPreviewInitialized, subscriptionPreview, subscription, currentPlanMeta, projects, }: Props) => { const { resolvedTheme } = useTheme() const { data: selectedOrganization } = useSelectedOrganizationQuery() const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() const [paymentIntentSecret, setPaymentIntentSecret] = useState(null) const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) const paymentMethodSelectionRef = useRef<{ createPaymentMethod: PaymentMethodElementRef['createPaymentMethod'] }>(null) const billingViaPartner = subscription?.billing_via_partner === true const billingPartner = subscription?.billing_partner const stripeOptionsConfirm = useMemo(() => { return { clientSecret: paymentIntentSecret, appearance: getStripeElementsAppearanceOptions(resolvedTheme), } as StripeElementsOptions }, [paymentIntentSecret, resolvedTheme]) const changeType = useMemo(() => { return getPlanChangeType(subscription?.plan?.id, planMeta?.id) }, [planMeta, subscription]) const subscriptionPlanMeta = useMemo( () => subscriptionsPlans.find((tier) => tier.id === selectedTier), [selectedTier] ) const onSuccessfulPlanChange = () => { setPaymentConfirmationLoading(false) toast.success( `Successfully ${changeType === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!` ) onClose() window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) } const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation( { onSuccess: (data) => { if (data.pending_payment_intent_secret) { setPaymentIntentSecret(data.pending_payment_intent_secret) return } onSuccessfulPlanChange() }, onError: (error) => { setPaymentConfirmationLoading(false) toast.error(`Unable to update subscription: ${error.message}`) }, } ) const { mutate: confirmPendingSubscriptionChange, isLoading: isConfirming } = useConfirmPendingSubscriptionChangeMutation({ onSuccess: () => { onSuccessfulPlanChange() }, onError: (error) => { toast.error(`Unable to update subscription: ${error.message}`) }, }) const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => { // Reset payment intent secret to ensure another attempt works as expected setPaymentIntentSecret('') if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') { await confirmPendingSubscriptionChange({ slug: selectedOrganization?.slug, payment_intent_id: paymentIntentConfirmation.paymentIntent.id, }) } else { setPaymentConfirmationLoading(false) // If the payment intent is not successful, we reset the payment method and show an error toast.error(`Could not confirm payment. Please try again or use a different card.`) } } const onUpdateSubscription = async () => { if (!selectedOrganization?.slug) return console.error('org slug is required') if (!selectedTier) return console.error('Selected plan is required') setPaymentConfirmationLoading(true) const result = await paymentMethodSelectionRef.current?.createPaymentMethod() if (result) { setSelectedPaymentMethod(result.paymentMethod.id) } else { setPaymentConfirmationLoading(false) } if (!result && subscription?.payment_method_type !== 'invoice' && changeType === 'upgrade') { return } // 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: selectedOrganization?.slug, tier, paymentMethod: result?.paymentMethod?.id, address: result?.address, tax_id: result?.taxId ?? undefined, billing_name: result?.customerName ?? undefined, }) } const features = subscriptionPlanMeta?.features || [] const topFeatures = features // Get current plan features for downgrade comparison const currentPlanFeatures = currentPlanMeta?.features || [] // Features that will be lost when downgrading const featuresToLose = changeType === '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[]) => { const newFeatureStr = typeof newFeature === 'string' ? newFeature : newFeature[0] return newFeatureStr === featureStr }) }) : [] // 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 = Number(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 ( { // Do not allow closing mid-change if (isUpdating || paymentConfirmationLoading || isConfirming) { return } if (!open) onClose() }} > event.preventDefault()} size="xlarge" className="p-0 overflow-y-auto max-h-[1000px]" >
{/* Left Column */}
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
setSelectedPaymentMethod(pm)} readOnly={paymentConfirmationLoading || isConfirming || isUpdating} />
)} {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.

)}
)}
{subscriptionPreviewIsLoading && (
)} {subscriptionPreviewInitialized && ( <>
Charge today
{formatCurrency(totalCharge)} {subscription?.plan?.id !== 'free' && ( <> {' '} + current spend )}
{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)}
)}
Monthly invoice estimate

Your new monthly invoice

Paid projects run 24/7 without pausing. First project uses Compute Credits; additional projects start at $10 /month regardless of usage.{' '} Learn more .

{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 && 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 {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 && 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 )}
)}
{formatCurrency( Math.round( subscriptionPreview?.breakdown.reduce( (prev: number, cur) => prev + cur.total_price, 0 ) ?? 0 ) )}
)}
{projects.filter( (it) => it.status === PROJECT_STATUS.ACTIVE_HEALTHY || it.status === PROJECT_STATUS.COMING_UP ).length === 5 && subscriptionPreview?.plan_change_type !== 'downgrade' && (
This organization has no active projects. Did you select the correct organization?
)} {projects.filter( (it) => it.status === PROJECT_STATUS.ACTIVE_HEALTHY || it.status === PROJECT_STATUS.COMING_UP ).length === 1 && subscriptionPlanMeta?.planId === 'pro' && changeType === 'upgrade' && (
Paid projects run 24/7 without pausing. First project uses Compute Credits; additional projects cost $10+ /month regardless of usage.{' '}
Learn more
)}
{/* Right Column */}

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

{changeType === 'downgrade' ? featuresToLose.length > 0 && (

Features you'll lose

Please review 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[]) => (

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

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

{feature[1]}

)}
))}
)}
{stripePromise && paymentIntentSecret && ( paymentIntentConfirmed(paymentIntentConfirmation) } onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} /> )}
) }