diff --git a/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts b/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts index c73dcf5615a..2d8af813fde 100644 --- a/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts +++ b/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts @@ -26,6 +26,8 @@ export const billingPartnerLabel = (billingPartner?: string) => { return 'Fly.io' case 'aws': return 'AWS' + case 'vercel_marketplace': + return 'Vercel' default: return billingPartner } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx index 23bd7bb9ba1..0555c36687d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx @@ -67,7 +67,7 @@ const PaymentMethodSelection = ({
-

No saved payment methods

+

No payment methods

{ const queryClient = useQueryClient() const originalPlanRef = useRef() 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} /> - 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 && ( -
Item, + + Usage + , + Unit Price, + + Cost + , + ]} + body={ + <> + {subscriptionPreview.breakdown.map((item) => ( + <> + + + {item.breakdown && item.breakdown.length > 0 && ( +
+ + + Monthly Costs (excluding over-usage and credits) + + + + + {formatCurrency( + Math.round( + subscriptionPreview.breakdown.reduce( + (prev, cur) => prev + cur.total_price, + 0 + ) + ) ?? 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' && ( + } + defaultVisibility={true} + hideCollapse={true} + description={ +

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

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

- )} -
- )} -
- + )} + + + + {!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. +

+ )} +
+ )} +
+ + )} { + 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() + 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. +

+ )} +
+ )} +
+ + +
+
+
+ + {/* 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