import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' import { isArray } from 'lodash' import { Check, ChevronRight, ExternalLink, Info } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { billingPartnerLabel } from 'components/interfaces/Billing/Subscription/Subscription.utils' import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import InformationBox from 'components/ui/InformationBox' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' import { organizationKeys } from 'data/organizations/keys' import { useOrganizationBillingSubscriptionPreview } from 'data/organizations/organization-billing-subscription-preview' import { useProjectsQuery } from 'data/projects/projects-query' import { useOrgPlansQuery } from 'data/subscriptions/org-plans-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' import type { OrgPlan, SubscriptionTier } from 'data/subscriptions/types' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants' import { formatCurrency } from 'lib/helpers' import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import { Button, Modal, SidePanel, cn } from 'ui' import DowngradeModal from './DowngradeModal' import EnterpriseCard from './EnterpriseCard' import ExitSurveyModal from './ExitSurveyModal' import MembersExceedLimitModal from './MembersExceedLimitModal' import PaymentMethodSelection from './PaymentMethodSelection' import UpgradeSurveyModal from './UpgradeModal' const PlanUpdateSidePanel = () => { const router = useRouter() const selectedOrganization = useSelectedOrganization() const slug = selectedOrganization?.slug const queryClient = useQueryClient() const originalPlanRef = useRef() const [showExitSurvey, setShowExitSurvey] = useState(false) const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false) const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() const [showDowngradeError, setShowDowngradeError] = useState(false) const [selectedTier, setSelectedTier] = useState<'tier_free' | 'tier_pro' | 'tier_team'>() const [usageFeesExpanded, setUsageFeesExpanded] = useState([]) const canUpdateSubscription = useCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) const { data: allProjects } = useProjectsQuery() const orgProjects = (allProjects || []).filter( (it) => it.organization_id === selectedOrganization?.id ) const snap = useOrgSettingsPageStateSnapshot() const visible = snap.panelKey === 'subscriptionPlan' const onClose = () => { const { panel, ...queryWithoutPanel } = router.query router.push({ pathname: router.pathname, query: queryWithoutPanel }, undefined, { shallow: true, }) snap.setPanelKey(undefined) } const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({ orgSlug: slug, }) const { data: plans, isLoading: isLoadingPlans } = useOrgPlansQuery({ orgSlug: slug }) const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug }) const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation( { onSuccess: () => { toast.success(`Successfully updated subscription to ${subscriptionPlanMeta?.name}!`) setSelectedTier(undefined) onClose() window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) setShowUpgradeSurvey(true) }, onError: (error) => { toast.error(`Unable to update subscription: ${error.message}`) }, } ) const billingViaPartner = subscription?.billing_via_partner === true const billingPartner = subscription?.billing_partner const paymentViaInvoice = subscription?.payment_method_type === 'invoice' const { data: subscriptionPreview, error: subscriptionPreviewError, isLoading: subscriptionPreviewIsLoading, isSuccess: subscriptionPreviewInitialized, } = useOrganizationBillingSubscriptionPreview({ tier: selectedTier, organizationSlug: slug }) const availablePlans: OrgPlan[] = plans?.plans ?? [] const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 const subscriptionPlanMeta = subscriptionsPlans.find((tier) => tier.id === selectedTier) const expandUsageFee = (fee: string) => { setUsageFeesExpanded([...usageFeesExpanded, fee]) } const collapseUsageFee = (fee: string) => { setUsageFeesExpanded(usageFeesExpanded.filter((item) => item !== fee)) } useEffect(() => { if (visible) { setSelectedTier(undefined) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible]) useEffect(() => { if (visible && isSuccessSubscription) { originalPlanRef.current = subscription.plan.id } }, [visible, isSuccessSubscription]) const onConfirmDowngrade = () => { setSelectedTier(undefined) if (hasMembersExceedingFreeTierLimit) { setShowDowngradeError(true) } else { setShowExitSurvey(true) } } const onUpdateSubscription = async () => { if (!slug) return console.error('org slug is required') if (!selectedTier) return console.error('Selected plan is required') if (!selectedPaymentMethod && !paymentViaInvoice) { 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 planMeta = selectedTier ? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1]) : null return ( <> onClose()} header={

Change subscription plan for {selectedOrganization?.name}

} >
{subscriptionsPlans.map((plan) => { const planMeta = availablePlans.find((p) => p.id === plan.id.split('tier_')[1]) const price = planMeta?.price ?? 0 const isDowngradeOption = planMeta?.change_type === 'downgrade' const isCurrentPlan = planMeta?.id === subscription?.plan?.id const features = pickFeatures(plan, billingPartner) const footer = pickFooter(plan, billingPartner) if (plan.id === 'tier_enterprise') { return ( ) } return (

{plan.name}

{isCurrentPlan ? (
Current plan
) : plan.nameBadge ? (
{plan.nameBadge}
) : ( <> )}
{(price ?? 0) > 0 &&

From

} {isLoadingPlans ? (
) : (

{formatCurrency(price)}

)}

{plan.costUnit}

{isCurrentPlan ? ( ) : ( setSelectedTier(plan.id as any)} tooltip={{ content: { side: 'bottom', text: subscription?.plan?.id === 'enterprise' ? 'Reach out to us via support to update your plan from Enterprise' : !canUpdateSubscription ? 'You do not have permission to change the subscription plan' : undefined, }, }} > {isDowngradeOption ? 'Downgrade' : 'Upgrade'} to {plan.name} )}
    {features.map((feature) => (
  • {typeof feature === 'string' ? feature : feature[0]}

    {isArray(feature) && (

    {feature[1]}

    )}
  • ))}
{footer && (

{footer}

)}
) })}
setSelectedTier(undefined)} onConfirm={onConfirmDowngrade} projects={orgProjects} /> setSelectedTier(undefined)} onConfirm={onUpdateSubscription} dialogOverlayProps={{ className: 'pointer-events-none' }} header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${subscriptionPlanMeta?.name}`} > {subscriptionPreviewError && ( )} {subscriptionPreviewIsLoading && (
Estimating monthly costs...
)} {subscriptionPreviewInitialized && (
Item, Usage , Unit Price, Cost , ]} body={ <> {subscriptionPreview.breakdown.map((item) => ( <> {item.breakdown && item.breakdown.length > 0 && (
} defaultVisibility={true} hideCollapse={true} description={

Each project is a dedicated server and database. Paid plans come with $10 of Compute Credits to cover one project on the default Micro Compute size or parts of any compute addon. Additional unpaused projects on paid plans will incur compute usage costs starting at $10 per month, billed hourly.

{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.

)}
{subscription?.plan?.id === 'free' && ( )}
} />
)}
{!billingViaPartner ? (

Upon clicking confirm, your monthly invoice will be adjusted and your credit card will be charged immediately. Changing the plan resets your billing cycle and may result in a prorated charge for previous usage.

) : (

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.

)}
)}
setShowDowngradeError(false)} /> { setShowExitSurvey(false) if (success) onClose() }} /> { setShowUpgradeSurvey(false) if (success) onClose() }} /> ) } export default PlanUpdateSidePanel