diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx index d9f7d90b3b1..bc2048e54d6 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx @@ -5,7 +5,7 @@ import { InvoicesSection } from '../InvoicesSettings/InvoicesSection' import BillingBreakdown from './BillingBreakdown/BillingBreakdown' import { BillingCustomerData } from './BillingCustomerData/BillingCustomerData' import BillingEmail from './BillingEmail' -import CostControl from './CostControl/CostControl' +import { CostControl } from './CostControl/CostControl' import CreditBalance from './CreditBalance' import Subscription from './Subscription/Subscription' import { diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index a46f66a38be..ff59657a02b 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -9,7 +9,7 @@ import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' -import SpendCapSidePanel from './SpendCapSidePanel' +import { SpendCapSidePanel } from './SpendCapSidePanel' import { ScaffoldSection, ScaffoldSectionContent, @@ -27,20 +27,19 @@ import { BASE_PATH, DOCS_URL } from '@/lib/constants' import { MANAGED_BY } from '@/lib/constants/infrastructure' import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings' -export interface CostControlProps {} - -const CostControl = ({}: CostControlProps) => { +export const CostControl = () => { const { slug } = useParams() const { resolvedTheme } = useTheme() + const snap = useOrgSettingsPageStateSnapshot() const { data: selectedOrganization } = useSelectedOrganizationQuery() + const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') + const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.subscriptions' ) - const snap = useOrgSettingsPageStateSnapshot() - const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { data: subscription, error, @@ -203,5 +202,3 @@ const CostControl = ({}: CostControlProps) => { ) } - -export default CostControl diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx index e507106268e..0ef6e7de9f0 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx @@ -4,19 +4,49 @@ import { ChevronRight, ExternalLink } from 'lucide-react' import { useTheme } from 'next-themes' import Image from 'next/image' import Link from 'next/link' -import { useEffect, useState } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import { pricing } from 'shared-data/pricing' import { toast } from 'sonner' -import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, SidePanel } from 'ui' +import { + Button, + Card, + CardContent, + cn, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' import { Admonition } from 'ui-patterns/Admonition' -import Table from '@/components/to-be-cleaned/Table' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { BASE_PATH, DOCS_URL, PRICING_TIER_PRODUCT_IDS } from '@/lib/constants' +import { PROJECT_STATUS } from '@/lib/constants/infrastructure' import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings' +const BILLING_METRIC_CATEGORIES: (keyof typeof pricing)[] = [ + 'database', + 'auth', + 'storage', + 'realtime', + 'edge_functions', +] + const SPEND_CAP_OPTIONS: { name: string value: 'on' | 'off' @@ -37,7 +67,7 @@ const SPEND_CAP_OPTIONS: { }, ] -const SpendCapSidePanel = () => { +export const SpendCapSidePanel = () => { const { slug } = useParams() const { resolvedTheme } = useTheme() @@ -53,6 +83,19 @@ const SpendCapSidePanel = () => { const visible = snap.panelKey === 'costControl' const onClose = () => snap.setPanelKey(undefined) + const { data } = useOrgProjectsInfiniteQuery({ slug }) + const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) || [], [data?.pages]) + + const hasReplicas = useMemo( + () => + projects.some( + (it) => + it.status !== PROJECT_STATUS.INACTIVE && + it.databases.some((db) => db.type === 'READ_REPLICA') + ), + [projects] + ) + const { data: subscription, isPending: isLoading } = useOrgSubscriptionQuery({ orgSlug: slug }) const { mutate: updateOrgSubscription, isPending: isUpdating } = useOrgSubscriptionUpdateMutation( { @@ -70,12 +113,21 @@ const SpendCapSidePanel = () => { const isSpendCapOn = !subscription?.usage_billing_enabled const isTurningOnCap = !isSpendCapOn && selectedOption === 'on' const hasChanges = selectedOption !== (isSpendCapOn ? 'on' : 'off') + const isBlockedByReplicas = isTurningOnCap && hasReplicas - useEffect(() => { - if (visible && subscription !== undefined) { - setSelectedOption(isSpendCapOn ? 'on' : 'off') - } - }, [visible, isLoading, subscription, isSpendCapOn]) + const disabled = + isFreePlan || + isLoading || + !hasChanges || + isUpdating || + !canUpdateSpendCap || + isBlockedByReplicas + + const confirmTooltipText = !canUpdateSpendCap + ? 'You do not have permission to update spend cap' + : isBlockedByReplicas + ? 'Remove your read replicas before enabling the spend cap' + : undefined const onConfirm = async () => { if (!slug) return console.error('Org slug is required') @@ -87,25 +139,17 @@ const SpendCapSidePanel = () => { updateOrgSubscription({ slug, tier }) } - const billingMetricCategories: (keyof typeof pricing)[] = [ - 'database', - 'auth', - 'storage', - 'realtime', - 'edge_functions', - ] + useEffect(() => { + if (visible && subscription !== undefined) { + setSelectedOption(isSpendCapOn ? 'on' : 'off') + } + }, [visible, isLoading, subscription, isSpendCapOn]) return ( - -

Spend cap

+ onClose()}> + + + Spend cap - - } - tooltip={!canUpdateSpendCap ? 'You do not have permission to update spend cap' : undefined} - > - -
-

- Use the spend cap to manage project usage and costs, and control whether the project can - exceed the included quota allowance of any billed line item in a billing cycle -

+ - - -
- -

- How are each resource charged after exceeding the included quota? -

-
-
- - - -

Item

-
- -

Rate

-
- + +
+

+ Use the spend cap to manage project usage and costs, and control whether the project + can exceed the included quota allowance of any billed line item in a billing cycle +

+ + + +
+ +

+ How is each resource charged after exceeding the included quota? +

+
+
+ + + +
+ + + Item + Rate + + + + {BILLING_METRIC_CATEGORIES.map((categoryId) => { + const category = pricing[categoryId] + const usageItems = category.features.filter((it) => it.usage_based) + + return ( + + + + {category.title} + + + + {usageItems.map((item) => ( + + {item.title} + + {Array.isArray(item.plans['pro']) + ? item.plans['pro']?.join(', ') + : item.plans['pro']} + + + ))} + + ) + })} + +
+ + +
+
+ + {isFreePlan && ( + snap.setPanelKey('subscriptionPlan')}> + View available plans + } - body={billingMetricCategories.map((categoryId) => { - const category = pricing[categoryId] - const usageItems = category.features.filter((it: any) => it.usage_based) + /> + )} + +
+
+ {SPEND_CAP_OPTIONS.map((option) => { + const isSelected = selectedOption === option.value return ( - <> - - -

{category.title}

-
- {null} -
- {usageItems.map((item: any) => { - return ( - - -

{item.title}

-
- -

- {Array.isArray(item.plans['pro']) - ? item.plans['pro']?.join(', ') - : item.plans['pro']} -

-
-
- ) - })} - +
!isFreePlan && setSelectedOption(option.value)} + > + Spend Cap + +

+ {option.name} +

+
) })} - /> - - - - {isFreePlan && ( - snap.setPanelKey('subscriptionPlan')}> - View available plans - - } - /> - )} - -
-
- {SPEND_CAP_OPTIONS.map((option) => { - const isSelected = selectedOption === option.value - - return ( -
!isFreePlan && setSelectedOption(option.value)} - > - Spend Cap - -

- {option.name} -

-
- ) - })} +
+ + {isBlockedByReplicas ? ( + + ) : selectedOption === 'on' ? ( + + ) : ( + + )} + + {hasChanges && !disabled && ( + <> +

+ {selectedOption === 'on' + ? 'Upon clicking confirm, spend cap will be enabled for your organization and you will no longer be charged any extra for usage.' + : 'Upon clicking confirm, spend cap will be disabled for your organization and you will be charged for any usage beyond the included quota.'} +

+

+ Toggling spend cap triggers an invoice and there might be prorated charges for any + usage beyond the Pro Plans quota during this billing cycle. +

+ + )}
+ - {selectedOption === 'on' ? ( - - ) : ( - - )} - - {hasChanges && ( - <> -

- {selectedOption === 'on' - ? 'Upon clicking confirm, spend cap will be enabled for your organization and you will no longer be charged any extra for usage.' - : 'Upon clicking confirm, spend cap will be disabled for your organization and you will be charged for any usage beyond the included quota.'} -

-

- Toggling spend cap triggers an invoice and there might be prorated charges for any - usage beyond the Pro Plans quota during this billing cycle. -

- - )} -
- - + + + + Confirm + + + + ) } - -export default SpendCapSidePanel