diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/InvoiceEstimateTooltip.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/InvoiceEstimateTooltip.tsx new file mode 100644 index 00000000000..e710b2922d1 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/InvoiceEstimateTooltip.tsx @@ -0,0 +1,231 @@ +import { HelpCircle } from 'lucide-react' +import Link from 'next/link' +import { + cn, + HoverCard, + HoverCardContent, + HoverCardTrigger, + Table, + TableBody, + TableCell, + TableRow, +} from 'ui' +import { InfoTooltip } from 'ui-patterns/info-tooltip' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import AlertError from '@/components/ui/AlertError' +import { type OrganizationBillingSubscriptionPreviewQueryResult } from '@/data/organizations/organization-billing-subscription-preview' +import { DOCS_URL } from '@/lib/constants' +import { formatCurrency } from '@/lib/helpers' + +const CELL_CLASSNAME = 'py-2 px-0' + +interface InvoiceEstimateTooltipProps { + subscriptionPreviewQueryResult: OrganizationBillingSubscriptionPreviewQueryResult +} + +export const InvoiceEstimateTooltip = ({ + subscriptionPreviewQueryResult, +}: InvoiceEstimateTooltipProps) => { + const { + data: subscriptionPreview, + error: subscriptionPreviewError, + isPending: subscriptionPreviewIsLoading, + isSuccess: subscriptionPreviewInitialized, + } = subscriptionPreviewQueryResult + + return ( + + + + + +

Your new monthly invoice

+

+ First project included. Additional projects cost $10+/month + regardless of activity.{' '} + + 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 = ( + <> + {planItem && ( + + {planItem.description} + + {formatCurrency(planItem.total_price)} + + + )} + + {/* Combined projects section */} + {allProjects.length > 0 && ( + <> + + + Compute + + + {formatCurrency( + computeItems.reduce( + (sum: number, item) => sum + item.total_price, + 0 + ) + (computeCreditsItem?.total_price ?? 0) + )} + + + + {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( + subscriptionPreview?.breakdown?.reduce( + (prev, cur) => prev + cur.total_price, + 0 + ) ?? 0 + )} + + +
+
+
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index a527b6153bb..9babe1cb8dd 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -135,13 +135,7 @@ export const PlanUpdateSidePanel = () => { { enabled: visible } ) - const { - data: subscriptionPreview, - error: subscriptionPreviewError, - isPending: subscriptionPreviewIsLoading, - isFetching: subscriptionPreviewIsFetching, - isSuccess: subscriptionPreviewInitialized, - } = useOrganizationBillingSubscriptionPreview({ + const subscriptionPreviewData = useOrganizationBillingSubscriptionPreview({ tier: selectedTier, organizationSlug: slug, address: debouncedAddress, @@ -181,10 +175,10 @@ export const PlanUpdateSidePanel = () => { }, [visible]) useEffect(() => { - if (visible && isSuccessSubscription) { + if (visible && isSuccessSubscription && subscription.plan.id) { originalPlanRef.current = subscription.plan.id } - }, [visible, isSuccessSubscription]) + }, [visible, isSuccessSubscription, subscription?.plan.id]) const onConfirmDowngrade = () => { setSelectedTier(undefined) @@ -198,6 +192,14 @@ export const PlanUpdateSidePanel = () => { const planMeta = selectedTier ? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1]) : null + + const currentPlanMeta = { + ...availablePlans.find((p) => p.id === subscription?.plan?.id), + features: + subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`)?.features || + [], + } + const stripeProjectsUpgradeCommand = getStripeProjectsUpgradeCommand( selectedOrganization?.plan?.id ?? subscription?.plan?.id ) @@ -321,7 +323,7 @@ export const PlanUpdateSidePanel = () => { hasOrioleProjects } onClick={() => { - setSelectedTier(plan.id as any) + setSelectedTier(plan.id as 'tier_free' | 'tier_pro' | 'tier_team') sendEvent({ action: 'studio_pricing_plan_cta_clicked', properties: { @@ -403,19 +405,9 @@ export const PlanUpdateSidePanel = () => { selectedTier={selectedTier} onClose={() => setSelectedTier(undefined)} planMeta={planMeta} - subscriptionPreviewError={subscriptionPreviewError} - subscriptionPreviewIsLoading={subscriptionPreviewIsLoading} - subscriptionPreviewIsFetching={subscriptionPreviewIsFetching} - subscriptionPreviewInitialized={subscriptionPreviewInitialized} - subscriptionPreview={subscriptionPreview} - subscription={subscription} + subscriptionPreviewQueryResult={subscriptionPreviewData} projects={orgProjects} - currentPlanMeta={{ - ...availablePlans.find((p) => p.id === subscription?.plan?.id), - features: - subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`) - ?.features || [], - }} + currentPlanMeta={currentPlanMeta} onAddressChange={handleAddressChange} onTaxIdChange={handleTaxIdChange} useAsDefaultBillingAddress={useAsDefaultBillingAddress} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index 687bb4cecd3..fe86ce81387 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -1,16 +1,18 @@ import { Elements } from '@stripe/react-stripe-js' import { loadStripe, PaymentIntentResult, StripeElementsOptions } from '@stripe/stripe-js' +import { useParams } from 'common' import { Check, InfoIcon } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' import { useMemo, useRef, useState } from 'react' import { plans as subscriptionsPlans } from 'shared-data/plans' import { toast } from 'sonner' -import { Button, cn, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui' +import { Button, cn, Dialog, DialogContent } from 'ui' import { Admonition } from 'ui-patterns' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { InvoiceEstimateTooltip } from './InvoiceEstimateTooltip' import PaymentMethodSelection from './PaymentMethodSelection' import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils' import { PaymentConfirmation } from '@/components/interfaces/Billing/Payment/PaymentConfirmation' @@ -19,13 +21,13 @@ import { billingPartnerLabel, getPlanChangeType, } from '@/components/interfaces/Billing/Subscription/Subscription.utils' -import AlertError from '@/components/ui/AlertError' -import { OrganizationBillingSubscriptionPreviewData } from '@/data/organizations/organization-billing-subscription-preview' +import { type OrganizationBillingSubscriptionPreviewQueryResult } from '@/data/organizations/organization-billing-subscription-preview' import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types' import { OrgProject } from '@/data/projects/org-projects-infinite-query' import { useConfirmPendingSubscriptionChangeMutation } from '@/data/subscriptions/org-subscription-confirm-pending-change' +import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation' -import { SubscriptionTier } from '@/data/subscriptions/types' +import { OrgPlan, SubscriptionTier } from '@/data/subscriptions/types' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { DOCS_URL, @@ -62,14 +64,9 @@ type BreakdownItem = interface Props { selectedTier: 'tier_free' | 'tier_pro' | 'tier_team' | undefined onClose: () => void - planMeta: any - subscriptionPreviewError: any - subscriptionPreviewIsLoading: boolean - subscriptionPreviewIsFetching: boolean - subscriptionPreviewInitialized: boolean - subscriptionPreview: OrganizationBillingSubscriptionPreviewData | undefined - subscription: any - currentPlanMeta: any + planMeta?: OrgPlan | null + currentPlanMeta?: Partial & { features: (string | string[])[] } + subscriptionPreviewQueryResult: OrganizationBillingSubscriptionPreviewQueryResult projects: OrgProject[] onAddressChange?: (address: CustomerAddress) => void onTaxIdChange?: (taxId: CustomerTaxId | null) => void @@ -81,12 +78,7 @@ export const SubscriptionPlanUpdateDialog = ({ selectedTier, onClose, planMeta, - subscriptionPreviewError, - subscriptionPreviewIsLoading, - subscriptionPreviewIsFetching, - subscriptionPreviewInitialized, - subscriptionPreview, - subscription, + subscriptionPreviewQueryResult, currentPlanMeta, projects, onAddressChange, @@ -94,6 +86,7 @@ export const SubscriptionPlanUpdateDialog = ({ useAsDefaultBillingAddress, onUseAsDefaultBillingAddressChange, }: Props) => { + const { slug } = useParams() const { resolvedTheme } = useTheme() const { data: selectedOrganization } = useSelectedOrganizationQuery() const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() @@ -104,6 +97,16 @@ export const SubscriptionPlanUpdateDialog = ({ validateBillingProfile: () => Promise }>(null) + const { + data: subscriptionPreview, + isPending: subscriptionPreviewIsLoading, + isFetching: subscriptionPreviewIsFetching, + isSuccess: subscriptionPreviewInitialized, + } = subscriptionPreviewQueryResult + + const { data: subscription } = useOrgSubscriptionQuery({ + orgSlug: slug, + }) const billingViaPartner = subscription?.billing_via_partner === true const billingPartner = subscription?.billing_partner @@ -225,7 +228,7 @@ export const SubscriptionPlanUpdateDialog = ({ // Features that will be lost when downgrading const featuresToLose = changeType === 'downgrade' - ? currentPlanFeatures.filter((feature: string | [string, ...any[]]) => { + ? currentPlanFeatures.filter((feature) => { const featureStr = typeof feature === 'string' ? feature : feature[0] // Check if this feature exists in the new plan return !topFeatures.some((newFeature: string | string[]) => { @@ -367,6 +370,7 @@ export const SubscriptionPlanUpdateDialog = ({

This organization is billed through our partner{' '} {billingPartnerLabel(billingPartner)}.{' '} + {/* @ts-ignore [Joshen] Might be API types issue */} {billingPartner === 'aws' ? ( <>The organization's credit balance will be decreased accordingly. ) : ( @@ -448,202 +452,9 @@ export const SubscriptionPlanUpdateDialog = ({

Monthly invoice estimate - -
-

Your new monthly invoice

-

- First project included. Additional projects cost{' '} - $10+/month regardless of activity.{' '} - - 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 = ( - <> - {planItem && ( - - - {planItem.description} - - - {formatCurrency(planItem.total_price)} - - - )} - - {/* Combined projects section */} - {allProjects.length > 0 && ( - <> - - - 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( - subscriptionPreview?.breakdown?.reduce( - (prev, cur) => prev + cur.total_price, - 0 - ) ?? 0 - )} - - -
-
- - )} -
-
+
{formatCurrency( @@ -730,7 +541,7 @@ export const SubscriptionPlanUpdateDialog = ({ Please review carefully before downgrading.

- {featuresToLose.map((feature: string | [string, ...any[]]) => ( + {featuresToLose.map((feature) => (
> +export type OrganizationBillingSubscriptionPreviewQueryResult = UseQueryResult< + OrganizationBillingSubscriptionPreviewData, + ResponseError +> + export const useOrganizationBillingSubscriptionPreview = < TData = OrganizationBillingSubscriptionPreviewData, >( diff --git a/packages/shared-data/plans.ts b/packages/shared-data/plans.ts index 646913bf980..55608fc718e 100644 --- a/packages/shared-data/plans.ts +++ b/packages/shared-data/plans.ts @@ -1,7 +1,7 @@ export type PlanId = 'free' | 'pro' | 'team' | 'enterprise' export interface PricingInformation { - id: string + id: 'tier_free' | 'tier_pro' | 'tier_team' | 'tier_enterprise' planId: PlanId name: string nameBadge?: string