mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 21:59:16 +03:00
Prevent enabling spend cap if org has projects with RRs
This commit is contained in:
1 parent
acaf640d1c
commit
a08576281a
3 files changed
+242
-187
No files matched your search
@@ -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 {
|
||||
|
||||
+5
-8
@@ -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
|
||||
+236
-178
@@ -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 (
|
||||
<SidePanel
|
||||
size="large"
|
||||
loading={isLoading || isUpdating}
|
||||
disabled={isFreePlan || isLoading || !hasChanges || isUpdating || !canUpdateSpendCap}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onConfirm={onConfirm}
|
||||
header={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h4>Spend cap</h4>
|
||||
<Sheet open={visible} onOpenChange={() => onClose()}>
|
||||
<SheetContent showClose={false} size="lg" className="flex flex-col gap-0">
|
||||
<SheetHeader className="flex items-center justify-between w-full">
|
||||
<SheetTitle>Spend cap</SheetTitle>
|
||||
<Button asChild icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href={`${DOCS_URL}/guides/platform/cost-control#spend-cap`}
|
||||
@@ -115,166 +159,180 @@ const SpendCapSidePanel = () => {
|
||||
About spend cap
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
tooltip={!canUpdateSpendCap ? 'You do not have permission to update spend cap' : undefined}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="py-6 space-y-4">
|
||||
<p className="text-sm">
|
||||
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
|
||||
</p>
|
||||
</SheetHeader>
|
||||
|
||||
<Collapsible open={showUsageCosts} onOpenChange={setShowUsageCosts}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center space-x-2 cursor-pointer">
|
||||
<ChevronRight
|
||||
strokeWidth={1.5}
|
||||
size={16}
|
||||
className={showUsageCosts ? 'rotate-90' : ''}
|
||||
/>
|
||||
<p className="text-sm text-foreground-light">
|
||||
How are each resource charged after exceeding the included quota?
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent asChild>
|
||||
<Table
|
||||
className="mt-4"
|
||||
head={
|
||||
<>
|
||||
<Table.th>
|
||||
<p className="text-xs">Item</p>
|
||||
</Table.th>
|
||||
<Table.th>
|
||||
<p className="text-xs">Rate</p>
|
||||
</Table.th>
|
||||
</>
|
||||
<SheetSection className="overflow-auto grow">
|
||||
<div className="py-6 space-y-4">
|
||||
<p className="text-sm">
|
||||
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
|
||||
</p>
|
||||
|
||||
<Collapsible open={showUsageCosts} onOpenChange={setShowUsageCosts}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center space-x-2 cursor-pointer">
|
||||
<ChevronRight
|
||||
strokeWidth={1.5}
|
||||
size={16}
|
||||
className={showUsageCosts ? 'rotate-90' : ''}
|
||||
/>
|
||||
<p className="text-sm text-foreground-light">
|
||||
How is each resource charged after exceeding the included quota?
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent asChild>
|
||||
<Card className="mt-4">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-9 text-xs">Item</TableHead>
|
||||
<TableHead className="h-9 text-xs">Rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{BILLING_METRIC_CATEGORIES.map((categoryId) => {
|
||||
const category = pricing[categoryId]
|
||||
const usageItems = category.features.filter((it) => it.usage_based)
|
||||
|
||||
return (
|
||||
<Fragment key={categoryId}>
|
||||
<TableRow>
|
||||
<TableCell className="py-2 text-xs text-foreground">
|
||||
{category.title}
|
||||
</TableCell>
|
||||
<TableCell className="py-2" />
|
||||
</TableRow>
|
||||
{usageItems.map((item) => (
|
||||
<TableRow
|
||||
key={item.title}
|
||||
className="[&>td]:text-foreground-lighter"
|
||||
>
|
||||
<TableCell className="py-2 text-xs pl-8">{item.title}</TableCell>
|
||||
<TableCell className="py-2 text-xs">
|
||||
{Array.isArray(item.plans['pro'])
|
||||
? item.plans['pro']?.join(', ')
|
||||
: item.plans['pro']}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{isFreePlan && (
|
||||
<Admonition
|
||||
type="note"
|
||||
layout="horizontal"
|
||||
title="Toggling of the spend cap is only available on the Pro Plan"
|
||||
description="Upgrade your plan to disable the spend cap"
|
||||
actions={
|
||||
<Button onClick={() => snap.setPanelKey('subscriptionPlan')}>
|
||||
View available plans
|
||||
</Button>
|
||||
}
|
||||
body={billingMetricCategories.map((categoryId) => {
|
||||
const category = pricing[categoryId]
|
||||
const usageItems = category.features.filter((it: any) => it.usage_based)
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-8! pb-4">
|
||||
<div className="flex gap-3">
|
||||
{SPEND_CAP_OPTIONS.map((option) => {
|
||||
const isSelected = selectedOption === option.value
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.tr key={categoryId}>
|
||||
<Table.td>
|
||||
<p className="text-xs text-foreground">{category.title}</p>
|
||||
</Table.td>
|
||||
<Table.td>{null}</Table.td>
|
||||
</Table.tr>
|
||||
{usageItems.map((item: any) => {
|
||||
return (
|
||||
<Table.tr key={item.title}>
|
||||
<Table.td>
|
||||
<p className="text-xs pl-4">{item.title}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<p className="text-xs pl-4">
|
||||
{Array.isArray(item.plans['pro'])
|
||||
? item.plans['pro']?.join(', ')
|
||||
: item.plans['pro']}
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn('col-span-4 group space-y-1', isFreePlan && 'opacity-75')}
|
||||
onClick={() => !isFreePlan && setSelectedOption(option.value)}
|
||||
>
|
||||
<Image
|
||||
alt="Spend Cap"
|
||||
className={cn(
|
||||
'relative rounded-xl transition border bg-no-repeat bg-center bg-cover w-[160px] h-[96px]',
|
||||
isSelected
|
||||
? 'border-foreground'
|
||||
: 'border-foreground-muted opacity-50 group-hover:border-foreground-lighter group-hover:opacity-100',
|
||||
!isFreePlan && 'cursor-pointer',
|
||||
!isFreePlan && !isSelected && 'group-hover:border-foreground-light'
|
||||
)}
|
||||
width={160}
|
||||
height={96}
|
||||
src={
|
||||
resolvedTheme?.includes('dark') ? option.imageUrl : option.imageUrlLight
|
||||
}
|
||||
/>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm transition',
|
||||
!isFreePlan && 'group-hover:text-foreground',
|
||||
isSelected ? 'text-foreground' : 'text-foreground-light'
|
||||
)}
|
||||
>
|
||||
{option.name}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{isFreePlan && (
|
||||
<Admonition
|
||||
type="note"
|
||||
layout="horizontal"
|
||||
title="Toggling of the spend cap is only available on the Pro Plan"
|
||||
description="Upgrade your plan to disable the spend cap"
|
||||
actions={
|
||||
<Button onClick={() => snap.setPanelKey('subscriptionPlan')}>
|
||||
View available plans
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-8! pb-4">
|
||||
<div className="flex gap-3">
|
||||
{SPEND_CAP_OPTIONS.map((option) => {
|
||||
const isSelected = selectedOption === option.value
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn('col-span-4 group space-y-1', isFreePlan && 'opacity-75')}
|
||||
onClick={() => !isFreePlan && setSelectedOption(option.value)}
|
||||
>
|
||||
<Image
|
||||
alt="Spend Cap"
|
||||
className={cn(
|
||||
'relative rounded-xl transition border bg-no-repeat bg-center bg-cover w-[160px] h-[96px]',
|
||||
isSelected
|
||||
? 'border-foreground'
|
||||
: 'border-foreground-muted opacity-50 group-hover:border-foreground-lighter group-hover:opacity-100',
|
||||
!isFreePlan && 'cursor-pointer',
|
||||
!isFreePlan && !isSelected && 'group-hover:border-foreground-light'
|
||||
)}
|
||||
width={160}
|
||||
height={96}
|
||||
src={resolvedTheme?.includes('dark') ? option.imageUrl : option.imageUrlLight}
|
||||
/>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm transition',
|
||||
!isFreePlan && 'group-hover:text-foreground',
|
||||
isSelected ? 'text-foreground' : 'text-foreground-light'
|
||||
)}
|
||||
>
|
||||
{option.name}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBlockedByReplicas ? (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Remove read replicas before enabling the Spend Cap"
|
||||
description="Read replicas add disk usage beyond your plan's included quota, which isn't allowed once the Spend Cap is on. Remove your read replicas first, or keep the Spend Cap disabled."
|
||||
/>
|
||||
) : selectedOption === 'on' ? (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Your projects could become unresponsive or enter read only mode"
|
||||
description="Exceeding the included quota allowance with spend cap enabled can cause your projects to become unresponsive or enter read only mode."
|
||||
/>
|
||||
) : (
|
||||
<Admonition
|
||||
type="note"
|
||||
title="Charges apply for usage beyond included quota allowance"
|
||||
description="Your projects will always remain responsive and active, and charges only apply when exceeding the included quota limit."
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasChanges && !disabled && (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
{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.'}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Toggling spend cap triggers an invoice and there might be prorated charges for any
|
||||
usage beyond the Pro Plans quota during this billing cycle.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{selectedOption === 'on' ? (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Your projects could become unresponsive or enter read only mode"
|
||||
description="Exceeding the included quota allowance with spend cap enabled can cause your projects
|
||||
to become unresponsive or enter read only mode."
|
||||
/>
|
||||
) : (
|
||||
<Admonition
|
||||
type="note"
|
||||
title="Charges apply for usage beyond included quota allowance"
|
||||
description="Your projects will always remain responsive and active, and charges only apply when
|
||||
exceeding the included quota limit."
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasChanges && (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
{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.'}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Toggling spend cap triggers an invoice and there might be prorated charges for any
|
||||
usage beyond the Pro Plans quota during this billing cycle.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
</SidePanel>
|
||||
<SheetFooter>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<ButtonTooltip
|
||||
variant="primary"
|
||||
loading={isUpdating}
|
||||
disabled={disabled}
|
||||
onClick={onConfirm}
|
||||
tooltip={{ content: { text: confirmTooltipText } }}
|
||||
>
|
||||
Confirm
|
||||
</ButtonTooltip>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export default SpendCapSidePanel
|
||||
Reference in new issue
Block a user