Feat/plan upgrade improvements (#33804)

* upgrade dialog changes

* plan upgrade modal

* update dialog improvements

* factor in customer balance

* copy

* add PH a/b test

* fix ts

* smol stuff

* fixes

* group compute fees

* smol edits

* Update SubscriptionPlanUpdateDialog.tsx

* Update SubscriptionPlanUpdateDialog.tsx

* partner billing

* downgrade

* margin

* show pricing info + filter out long tweets

* fix tooltip

---------

Co-authored-by: Kevin Grüneberg <k.grueneberg1994@gmail.com>
This commit is contained in:
Saxon FletcherandKevin Grüneberg authored and GitHub committed 2025-02-27 13:31:27 +10:00
1 parent 20f5474457
commit 7c04aadfa9
4 files changed
+889 -210

No files matched your search

@@ -26,6 +26,8 @@ export const billingPartnerLabel = (billingPartner?: string) => {
return 'Fly.io'
case 'aws':
return 'AWS'
case 'vercel_marketplace':
return 'Vercel'
default:
return billingPartner
}
@@ -67,7 +67,7 @@ const PaymentMethodSelection = ({
<div className="flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-alternative">
<div className="flex items-center space-x-4 text-foreground-light">
<AlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm">No saved payment methods</p>
<p className="text-sm">No payment methods</p>
</div>
<ButtonTooltip
@@ -26,7 +26,7 @@ import type { OrgPlan, SubscriptionTier } from 'data/subscriptions/types'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { useFlag, usePHFlag } from 'hooks/ui/useFlag'
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
import { formatCurrency } from 'lib/helpers'
import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans'
@@ -36,6 +36,7 @@ import DowngradeModal from './DowngradeModal'
import { EnterpriseCard } from './EnterpriseCard'
import ExitSurveyModal from './ExitSurveyModal'
import MembersExceedLimitModal from './MembersExceedLimitModal'
import SubscriptionPlanUpdateDialog from './SubscriptionPlanUpdateDialog'
import PaymentMethodSelection from './PaymentMethodSelection'
import UpgradeSurveyModal from './UpgradeModal'
@@ -48,6 +49,7 @@ const PlanUpdateSidePanel = () => {
const queryClient = useQueryClient()
const originalPlanRef = useRef<string>()
const allowOrioleDB = useFlag('allowOrioleDb')
const newPlanUpdateDialog = usePHFlag('newPlanUpdateDialog')
const [showExitSurvey, setShowExitSurvey] = useState(false)
const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false)
@@ -100,7 +102,6 @@ const PlanUpdateSidePanel = () => {
const billingViaPartner = subscription?.billing_via_partner === true
const billingPartner = subscription?.billing_partner
const paymentViaInvoice = subscription?.payment_method_type === 'invoice'
const {
data: subscriptionPreview,
@@ -115,14 +116,6 @@ const PlanUpdateSidePanel = () => {
orgProjects.filter((it) => it.status !== 'INACTIVE' && it.status !== 'GOING_DOWN').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)
@@ -159,10 +152,14 @@ const PlanUpdateSidePanel = () => {
}
}
const planMeta = selectedTier
? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1])
: null
const onUpdateSubscription = async () => {
if (!slug) return console.error('org slug is required')
if (!selectedTier) return console.error('Selected plan is required')
if (!selectedPaymentMethod && !paymentViaInvoice) {
if (!selectedPaymentMethod && subscription?.payment_method_type !== 'invoice') {
return toast.error('Please select a payment method')
}
@@ -189,9 +186,13 @@ const PlanUpdateSidePanel = () => {
updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod })
}
const planMeta = selectedTier
? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1])
: null
const expandUsageFee = (fee: string) => {
setUsageFeesExpanded([...usageFeesExpanded, fee])
}
const collapseUsageFee = (fee: string) => {
setUsageFeesExpanded(usageFeesExpanded.filter((item) => item !== fee))
}
return (
<>
@@ -356,218 +357,242 @@ const PlanUpdateSidePanel = () => {
projects={orgProjects}
/>
<Modal
loading={isUpdating}
alignFooter="right"
size="xlarge"
visible={selectedTier !== undefined && selectedTier !== 'tier_free'}
onCancel={() => setSelectedTier(undefined)}
onConfirm={onUpdateSubscription}
dialogOverlayProps={{ className: 'pointer-events-none' }}
header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${subscriptionPlanMeta?.name}`}
>
<Modal.Content>
{subscriptionPreviewError && (
<AlertError
error={subscriptionPreviewError}
subject="Failed to preview subscription."
/>
)}
{subscriptionPreviewIsLoading && (
<div className="space-y-2">
<span className="text-sm">Estimating monthly costs...</span>
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{subscriptionPreviewInitialized && (
<div>
<Table
className="mt-2"
borderless={true}
head={[
<Table.th key="header-item">Item</Table.th>,
<Table.th key="header-count" className="text-right pr-4">
Usage
</Table.th>,
<Table.th key="header-unit-price">Unit Price</Table.th>,
<Table.th key="header-price" className="text-right">
Cost
</Table.th>,
]}
body={
<>
{subscriptionPreview.breakdown.map((item) => (
<>
<Table.tr key={item.description}>
<Table.td>
{item.breakdown && item.breakdown.length > 0 && (
<Button
type="text"
className="!pl-0 !pr-1"
icon={
<ChevronRight
className={cn(
'transition',
usageFeesExpanded.includes(item.description) && 'rotate-90'
)}
/>
}
onClick={() =>
usageFeesExpanded.includes(item.description)
? collapseUsageFee(item.description)
: expandUsageFee(item.description)
}
/>
)}
{item.description ?? 'Unknown'}{' '}
{item.description.endsWith('Compute') && ` (Hours)`}
</Table.td>
<Table.td className="text-right pr-4 tabular-nums">
{item.quantity?.toLocaleString()}
</Table.td>
<Table.td>
{item.unit_price_desc
? item.unit_price_desc
: item.unit_price === 0
? 'FREE'
: item.unit_price
? `${formatCurrency(item.unit_price)}`
: ''}
</Table.td>
<Table.td className="text-right">
{formatCurrency(item.total_price)}
</Table.td>
</Table.tr>
{newPlanUpdateDialog === 'variant-a' ? (
<SubscriptionPlanUpdateDialog
selectedTier={selectedTier}
selectedOrganization={selectedOrganization}
onClose={() => setSelectedTier(undefined)}
subscriptionPlanMeta={subscriptionPlanMeta}
planMeta={planMeta}
subscriptionPreviewError={subscriptionPreviewError}
subscriptionPreviewIsLoading={subscriptionPreviewIsLoading}
subscriptionPreviewInitialized={subscriptionPreviewInitialized}
subscriptionPreview={subscriptionPreview}
billingViaPartner={billingViaPartner}
billingPartner={billingPartner}
subscription={subscription}
slug={slug}
currentPlanMeta={{
...availablePlans.find((p) => p.id === subscription?.plan?.id),
features:
subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`)
?.features || [],
}}
/>
) : (
<Modal
loading={isUpdating}
alignFooter="right"
size="xlarge"
visible={selectedTier !== undefined && selectedTier !== 'tier_free'}
onCancel={() => setSelectedTier(undefined)}
onConfirm={onUpdateSubscription}
dialogOverlayProps={{ className: 'pointer-events-none' }}
header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${subscriptionPlanMeta?.name}`}
>
<Modal.Content>
{subscriptionPreviewError && (
<AlertError
error={subscriptionPreviewError}
subject="Failed to preview subscription."
/>
)}
{subscriptionPreviewIsLoading && (
<div className="space-y-2">
<span className="text-sm">Estimating monthly costs...</span>
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{subscriptionPreviewInitialized && (
<div>
<Table
className="mt-2"
borderless={true}
head={[
<Table.th key="header-item">Item</Table.th>,
<Table.th key="header-count" className="text-right pr-4">
Usage
</Table.th>,
<Table.th key="header-unit-price">Unit Price</Table.th>,
<Table.th key="header-price" className="text-right">
Cost
</Table.th>,
]}
body={
<>
{subscriptionPreview.breakdown.map((item) => (
<>
<Table.tr key={item.description}>
<Table.td>
{item.breakdown && item.breakdown.length > 0 && (
<Button
type="text"
className="!pl-0 !pr-1"
icon={
<ChevronRight
className={cn(
'transition',
usageFeesExpanded.includes(item.description) && 'rotate-90'
)}
/>
}
onClick={() =>
usageFeesExpanded.includes(item.description)
? collapseUsageFee(item.description)
: expandUsageFee(item.description)
}
/>
)}
{item.description ?? 'Unknown'}{' '}
{item.description.endsWith('Compute') && ` (Hours)`}
</Table.td>
<Table.td className="text-right pr-4 tabular-nums">
{item.quantity?.toLocaleString()}
</Table.td>
<Table.td>
{item.unit_price_desc
? item.unit_price_desc
: item.unit_price === 0
? 'FREE'
: item.unit_price
? `${formatCurrency(item.unit_price)}`
: ''}
</Table.td>
<Table.td className="text-right">
{formatCurrency(item.total_price)}
</Table.td>
</Table.tr>
{usageFeesExpanded.includes(item.description) &&
item.breakdown &&
item.breakdown.length > 0 &&
item.breakdown.map((project) => (
<Table.tr key={project.project_ref}>
<Table.td className="!pl-12">{project.project_name}</Table.td>
<Table.td className="text-right pr-4 tabular-nums">
{project.usage}
</Table.td>
<Table.td />
<Table.td />
</Table.tr>
))}
</>
))}
{usageFeesExpanded.includes(item.description) &&
item.breakdown &&
item.breakdown.length > 0 &&
item.breakdown.map((project) => (
<Table.tr key={project.project_ref}>
<Table.td className="!pl-12">{project.project_name}</Table.td>
<Table.td className="text-right pr-4 tabular-nums">
{project.usage}
</Table.td>
<Table.td />
<Table.td />
</Table.tr>
))}
</>
))}
<Table.tr>
<Table.td className="font-medium">
Monthly Costs (excluding over-usage and credits)
</Table.td>
<Table.td />
<Table.td />
<Table.td className="text-right font-medium">
{formatCurrency(
Math.round(
subscriptionPreview.breakdown.reduce(
(prev, cur) => prev + cur.total_price,
0
)
) ?? 0
)}
</Table.td>
</Table.tr>
</>
}
></Table>
<Table.tr>
<Table.td className="font-medium">
Monthly Costs (excluding over-usage and credits)
</Table.td>
<Table.td />
<Table.td />
<Table.td className="text-right font-medium">
{formatCurrency(
Math.round(
subscriptionPreview.breakdown.reduce(
(prev, cur) => prev + cur.total_price,
0
)
) ?? 0
)}
</Table.td>
</Table.tr>
</>
}
></Table>
<InformationBox
className="mt-4"
title="Usage-billing for Compute"
icon={<Info />}
defaultVisibility={true}
hideCollapse={true}
description={
<div>
<p className="text-sm mt-2">
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.
</p>
{subscription?.plan?.id === 'free' && (
<InformationBox
className="mt-4"
title="Usage-billing for Compute"
icon={<Info />}
defaultVisibility={true}
hideCollapse={true}
description={
<div>
<p className="text-sm mt-2">
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.
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.
</p>
)}
<div className="space-x-3 mt-2">
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
target="_blank"
rel="noreferrer"
>
How billing for Compute works
</Link>
</Button>
{subscription?.plan?.id === 'free' && (
<p className="text-sm mt-2">
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.
</p>
)}
<div className="space-x-3 mt-2">
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/project-transfer"
href="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
target="_blank"
rel="noreferrer"
>
Project transfers
How billing for Compute works
</Link>
</Button>
)}
{subscription?.plan?.id === 'free' && (
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/project-transfer"
target="_blank"
rel="noreferrer"
>
Project transfers
</Link>
</Button>
)}
</div>
</div>
</div>
}
/>
</div>
)}
</Modal.Content>
<Modal.Content>
{!billingViaPartner ? (
<div className="py-4 space-y-2">
<p className="text-sm">
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.
</p>
<div className="!mt-4">
<PaymentMethodSelection
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={setSelectedPaymentMethod}
}
/>
</div>
</div>
) : (
<div className="py-4 space-y-2">
<p className="text-sm">
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.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing cycle.
</p>
)}
</div>
)}
</Modal.Content>
</Modal>
)}
</Modal.Content>
<Modal.Content>
{!billingViaPartner ? (
<div className="py-4 space-y-2">
<p className="text-sm">
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.
</p>
<div className="!mt-4">
<PaymentMethodSelection
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={setSelectedPaymentMethod}
/>
</div>
</div>
) : (
<div className="py-4 space-y-2">
<p className="text-sm">
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.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing cycle.
</p>
)}
</div>
)}
</Modal.Content>
</Modal>
)}
<MembersExceedLimitModal
visible={showDowngradeError}
@@ -0,0 +1,652 @@
import { useQueryClient } from '@tanstack/react-query'
import { InfoIcon, Check, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import tweets from 'shared-data/tweets'
import AlertError from 'components/ui/AlertError'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
HoverCard,
HoverCardContent,
HoverCardTrigger,
Card,
CardContent,
Badge,
} from 'ui'
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
import { organizationKeys } from 'data/organizations/keys'
import { formatCurrency } from 'lib/helpers'
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
import { SubscriptionTier } from 'data/subscriptions/types'
import { billingPartnerLabel } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import PaymentMethodSelection from './PaymentMethodSelection'
import { Button, Dialog, DialogContent } from 'ui'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizations/organization-billing-subscription-preview'
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
}
const SubscriptionPlanUpdateDialog = ({
selectedTier,
onClose,
subscriptionPlanMeta,
planMeta,
subscriptionPreviewError,
subscriptionPreviewIsLoading,
subscriptionPreviewInitialized,
subscriptionPreview,
billingViaPartner,
billingPartner,
selectedOrganization,
subscription,
slug,
currentPlanMeta,
}: Props) => {
const queryClient = useQueryClient()
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>()
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 (
<Dialog
open={selectedTier !== undefined && selectedTier !== 'tier_free'}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<DialogContent
onOpenAutoFocus={(event) => event.preventDefault()}
size="xlarge"
className="p-0"
>
<div className="grid grid-cols-1 md:grid-cols-2 h-full items-stretch">
{/* Left Column */}
<div className="p-8 pb-8 flex flex-col">
<div className="flex-1">
<h3 className="text-lg font-medium mb-4">
{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']}
</h3>
{subscriptionPreviewIsLoading && (
<div className="space-y-2 mb-4 mt-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{subscriptionPreviewInitialized && (
<>
<Table className="mt-2 mb-4 text-foreground-light">
<TableBody>
{(() => {
// 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 (
<>
<TableRow>
<TableCell className="py-2 pl-0 flex items-center gap-1">
<span>{subscriptionPlanMeta?.name} Plan</span>
<Badge variant={'brand'} size={'small'} className="ml-1">
New
</Badge>
</TableCell>
<TableCell className="py-2 pr-0 text-right">
{formatCurrency(newPlanCost)}
</TableCell>
</TableRow>
{subscription?.plan?.id !== 'free' && (
<TableRow>
<TableCell className="py-2 pl-0 flex items-center gap-1">
<span>Unused Time on {subscription?.plan?.name} Plan</span>
<InfoTooltip className="max-w-sm">
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.
</InfoTooltip>
</TableCell>
<TableCell className="py-2 pr-0 text-right">
-{formatCurrency(proratedCredit)}
</TableCell>
</TableRow>
)}
{/* Ignore rare case with negative balance (debt) */}
{customerBalance > 0 && (
<TableRow>
<TableCell className="py-2 pl-0 flex items-center gap-1">
<span>Credits</span>
<InfoTooltip>
Credits will be used first before charging your card.
</InfoTooltip>
</TableCell>
<TableCell className="py-2 pr-0 text-right">
{formatCurrency(customerBalance)}
</TableCell>
</TableRow>
)}
<TableRow className="text-foreground">
<TableCell className="py-2 pl-0 border-t">Charge today</TableCell>
<TableCell className="py-2 pr-0 text-right border-t">
{formatCurrency(totalCharge)}
</TableCell>
</TableRow>
{subscription?.plan?.id !== 'free' && (
<TableRow>
<TableCell className="py-2 pl-0 flex items-center gap-1">
<span>+ current cycle usage</span>
<InfoTooltip className="max-w-sm">
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.
</InfoTooltip>
</TableCell>
<TableCell className="py-2 pr-0 text-right">
<Link
href={`/org/${slug}/billing#breakdown`}
className="text-sm text-brand hover:text-brand-600 transition"
target="_blank"
>
View spend
</Link>
</TableCell>
</TableRow>
)}
</>
)
})()}
</TableBody>
</Table>
<HoverCard>
<HoverCardTrigger asChild>
<Card className="cursor-help text-sm">
<CardContent className="flex items-center gap-2 py-2 px-3 text-foreground-light hover:text-foreground">
<InfoIcon strokeWidth={1.5} size={16} className="text-foreground-light" />
Monthly invoice estimate is{' '}
{formatCurrency(
Math.round(
subscriptionPreview?.breakdown.reduce(
(prev: number, cur) => prev + cur.total_price,
0
) ?? 0
)
)}
</CardContent>
</Card>
</HoverCardTrigger>
<HoverCardContent className="w-[520px] p-6">
<h3 className="text-md font-medium mb-2">Your new monthly invoice</h3>
{subscriptionPreviewError && (
<AlertError
error={subscriptionPreviewError}
subject="Failed to preview subscription."
/>
)}
{subscriptionPreviewIsLoading && (
<div className="space-y-2 p-6">
<span className="text-sm">Estimating monthly costs...</span>
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{subscriptionPreviewInitialized && (
<>
<Table className="[&_tr:last-child]:border-t font-mono text-xs">
<TableBody>
{/* 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 && (
<>
<TableRow className="text-foreground-light">
<TableCell className="!py-2 px-0">
{planItem?.description}
</TableCell>
<TableCell className="text-right py-2 px-0">
{formatCurrency(planItem?.total_price)}
</TableCell>
</TableRow>
<TableRow className="text-foreground-light">
<TableCell className="!py-2 px-0 flex items-center gap-1">
<span>Compute</span>
<InfoTooltip className="max-w-sm p-3">
<p className="prose text-xs mb-2">
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{' '}
<Link
href={
'/docs/guides/platform/manage-your-usage/compute'
}
target="_blank"
>
docs
</Link>
.
</p>
{subscription?.plan?.id === 'free' && (
<>
<p className="text-xs text-foreground-light mb-3">
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.
</p>
<div className="space-x-3">
<Button
asChild
type="default"
icon={<ExternalLink strokeWidth={1.5} />}
>
<Link
href="/docs/guides/platform/manage-your-usage/compute"
target="_blank"
rel="noreferrer"
>
How billing for Compute works
</Link>
</Button>
<Button
asChild
type="default"
icon={<ExternalLink strokeWidth={1.5} />}
>
<Link
href="/docs/guides/platform/project-transfer"
target="_blank"
rel="noreferrer"
>
Project transfers
</Link>
</Button>
</div>
</>
)}
</InfoTooltip>
</TableCell>
<TableCell className="text-right py-2 px-0">
{formatCurrency(
computeItems.reduce(
(sum: number, item) => sum + item.total_price,
0
) + (computeCreditsItem?.total_price ?? 0)
)}
</TableCell>
</TableRow>
{/* Show first 3 projects */}
{allProjects.map((project) => (
<TableRow
key={project.project_ref}
className="text-foreground-light"
>
<TableCell className="!py-2 px-0 pl-6">
{project.project_name} ({project.computeType}) |{' '}
{formatCurrency(project.computeCosts)}
</TableCell>
</TableRow>
))}
{computeCreditsItem && (
<TableRow className="text-foreground-light">
<TableCell className="!py-2 px-0 pl-6">
Compute Credits |{' '}
{formatCurrency(computeCreditsItem.total_price)}
</TableCell>
</TableRow>
)}
</>
)}
{/* Non-compute items */}
{otherItems.map((item) => (
<TableRow
key={item.description}
className="text-foreground-light"
>
<TableCell className="text-xs py-2 px-0">
<div className="flex items-center gap-1 flex items-center gap-1">
<span>{item.description ?? 'Unknown'}</span>
{item.breakdown.length > 0 && (
<InfoTooltip className="max-w-sm">
<p>Projects using {item.description}:</p>
<ul className="ml-6 list-disc">
{item.breakdown.map((breakdown) => (
<li
key={`${item.description}-breakdown-${breakdown.project_ref}`}
>
{breakdown.project_name}
</li>
))}
</ul>
</InfoTooltip>
)}
</div>
</TableCell>
<TableCell className="text-right text-xs py-2 px-0">
{formatCurrency(item.total_price)}
</TableCell>
</TableRow>
))}
</>
)
return content
})()}
<TableRow>
<TableCell className="font-medium py-2 px-0">
Total per month (excluding other usage)
</TableCell>
<TableCell className="text-right font-medium py-2 px-0">
{formatCurrency(
Math.round(
subscriptionPreview?.breakdown?.reduce(
(prev, cur) => prev + cur.total_price,
0
) ?? 0
) ?? 0
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</>
)}
</HoverCardContent>
</HoverCard>
</>
)}
</div>
<div className="mt-4 pt-4">
{!billingViaPartner ? (
<div className="space-y-4">
<PaymentMethodSelection
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={setSelectedPaymentMethod}
/>
</div>
) : (
<div className="mb-4">
<p className="text-sm">
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.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing
cycle.
</p>
)}
</div>
)}
<div className="flex space-x-4">
<Button type="default" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
loading={isUpdating}
type="primary"
onClick={onUpdateSubscription}
className="flex-1"
>
Confirm {planMeta?.change_type === 'downgrade' ? 'Downgrade' : 'Upgrade'}
</Button>
</div>
</div>
</div>
{/* Right Column */}
<div className="bg-surface-100 p-8 flex flex-col border-l">
{planMeta?.change_type === 'downgrade'
? featuresToLose.length > 0 && (
<div className="mb-4">
<h3 className="text-sm mb-1">Features you'll lose</h3>
<p className="text-xs text-foreground-light mb-4">
Please review this carefully before downgrading.
</p>
<div className="space-y-2 mb-4 text-foreground-light">
{featuresToLose.map((feature: string | [string, ...any[]]) => (
<div
key={typeof feature === 'string' ? feature : feature[0]}
className="flex items-center gap-2"
>
<div className="w-4">
<InfoIcon className="h-3 w-3 text-amber-900" strokeWidth={3} />
</div>
<p className="text-sm">
{typeof feature === 'string' ? feature : feature[0]}
</p>
</div>
))}
</div>
</div>
)
: topFeatures.length > 0 && (
<div className="mb-4">
<h3 className="text-sm mb-4">Upgrade features</h3>
<div className="space-y-2 mb-4 text-foreground-light">
{topFeatures.map((feature: string | [string, ...any[]]) => (
<div
key={typeof feature === 'string' ? feature : feature[0]}
className="flex items-center gap-2"
>
<div className="w-4">
<Check className="h-3 w-3 text-brand" strokeWidth={3} />
</div>
<div className="text-sm">
<p>{typeof feature === 'string' ? feature : feature[0]}</p>
{Array.isArray(feature) && feature.length > 1 && (
<p className="text-foreground-lighter text-xs">{feature[1]}</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{planMeta?.change_type !== 'downgrade' && (
<div className="border-t pt-6">
<blockquote className="text-sm text-foreground-light italic">
{testimonialTweet.text}
<div className="mt-2 text-foreground">— @{testimonialTweet.handle}</div>
</blockquote>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}
export default SubscriptionPlanUpdateDialog