SpendCapSidePanel.tsx280 lines · main
| 1 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 2 | import { useParams } from 'common' |
| 3 | import { ChevronRight, ExternalLink } from 'lucide-react' |
| 4 | import { useTheme } from 'next-themes' |
| 5 | import Image from 'next/image' |
| 6 | import Link from 'next/link' |
| 7 | import { useEffect, useState } from 'react' |
| 8 | import { pricing } from 'shared-data/pricing' |
| 9 | import { toast } from 'sonner' |
| 10 | import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, SidePanel } from 'ui' |
| 11 | import { Admonition } from 'ui-patterns/admonition' |
| 12 | |
| 13 | import Table from '@/components/to-be-cleaned/Table' |
| 14 | import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' |
| 15 | import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation' |
| 16 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 17 | import { BASE_PATH, DOCS_URL, PRICING_TIER_PRODUCT_IDS } from '@/lib/constants' |
| 18 | import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings' |
| 19 | |
| 20 | const SPEND_CAP_OPTIONS: { |
| 21 | name: string |
| 22 | value: 'on' | 'off' |
| 23 | imageUrl: string |
| 24 | imageUrlLight: string |
| 25 | }[] = [ |
| 26 | { |
| 27 | name: 'Spend cap enabled', |
| 28 | value: 'on', |
| 29 | imageUrl: `${BASE_PATH}/img/spend-cap-on.png`, |
| 30 | imageUrlLight: `${BASE_PATH}/img/spend-cap-on--light.png`, |
| 31 | }, |
| 32 | { |
| 33 | name: 'Spend cap disabled', |
| 34 | value: 'off', |
| 35 | imageUrl: `${BASE_PATH}/img/spend-cap-off.png`, |
| 36 | imageUrlLight: `${BASE_PATH}/img/spend-cap-off--light.png`, |
| 37 | }, |
| 38 | ] |
| 39 | |
| 40 | const SpendCapSidePanel = () => { |
| 41 | const { slug } = useParams() |
| 42 | const { resolvedTheme } = useTheme() |
| 43 | |
| 44 | const [showUsageCosts, setShowUsageCosts] = useState(false) |
| 45 | const [selectedOption, setSelectedOption] = useState<'on' | 'off'>() |
| 46 | |
| 47 | const { can: canUpdateSpendCap } = useAsyncCheckPermissions( |
| 48 | PermissionAction.BILLING_WRITE, |
| 49 | 'stripe.subscriptions' |
| 50 | ) |
| 51 | |
| 52 | const snap = useOrgSettingsPageStateSnapshot() |
| 53 | const visible = snap.panelKey === 'costControl' |
| 54 | const onClose = () => snap.setPanelKey(undefined) |
| 55 | |
| 56 | const { data: subscription, isPending: isLoading } = useOrgSubscriptionQuery({ orgSlug: slug }) |
| 57 | const { mutate: updateOrgSubscription, isPending: isUpdating } = useOrgSubscriptionUpdateMutation( |
| 58 | { |
| 59 | onSuccess: () => { |
| 60 | toast.success(`Successfully ${isTurningOnCap ? 'enabled' : 'disabled'} spend cap`) |
| 61 | onClose() |
| 62 | }, |
| 63 | onError: (error) => { |
| 64 | toast.error(`Failed to toggle spend cap: ${error.message}`) |
| 65 | }, |
| 66 | } |
| 67 | ) |
| 68 | |
| 69 | const isFreePlan = subscription?.plan?.id === 'free' |
| 70 | const isSpendCapOn = !subscription?.usage_billing_enabled |
| 71 | const isTurningOnCap = !isSpendCapOn && selectedOption === 'on' |
| 72 | const hasChanges = selectedOption !== (isSpendCapOn ? 'on' : 'off') |
| 73 | |
| 74 | useEffect(() => { |
| 75 | if (visible && subscription !== undefined) { |
| 76 | setSelectedOption(isSpendCapOn ? 'on' : 'off') |
| 77 | } |
| 78 | }, [visible, isLoading, subscription, isSpendCapOn]) |
| 79 | |
| 80 | const onConfirm = async () => { |
| 81 | if (!slug) return console.error('Org slug is required') |
| 82 | |
| 83 | const tier = ( |
| 84 | selectedOption === 'on' ? PRICING_TIER_PRODUCT_IDS.PRO : PRICING_TIER_PRODUCT_IDS.PAYG |
| 85 | ) as 'tier_pro' | 'tier_payg' |
| 86 | |
| 87 | updateOrgSubscription({ slug, tier }) |
| 88 | } |
| 89 | |
| 90 | const billingMetricCategories: (keyof typeof pricing)[] = [ |
| 91 | 'database', |
| 92 | 'auth', |
| 93 | 'storage', |
| 94 | 'realtime', |
| 95 | 'edge_functions', |
| 96 | ] |
| 97 | |
| 98 | return ( |
| 99 | <SidePanel |
| 100 | size="large" |
| 101 | loading={isLoading || isUpdating} |
| 102 | disabled={isFreePlan || isLoading || !hasChanges || isUpdating || !canUpdateSpendCap} |
| 103 | visible={visible} |
| 104 | onCancel={onClose} |
| 105 | onConfirm={onConfirm} |
| 106 | header={ |
| 107 | <div className="flex items-center justify-between w-full"> |
| 108 | <h4>Spend cap</h4> |
| 109 | <Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}> |
| 110 | <Link |
| 111 | href={`${DOCS_URL}/guides/platform/cost-control#spend-cap`} |
| 112 | target="_blank" |
| 113 | rel="noreferrer" |
| 114 | > |
| 115 | About spend cap |
| 116 | </Link> |
| 117 | </Button> |
| 118 | </div> |
| 119 | } |
| 120 | tooltip={!canUpdateSpendCap ? 'You do not have permission to update spend cap' : undefined} |
| 121 | > |
| 122 | <SidePanel.Content> |
| 123 | <div className="py-6 space-y-4"> |
| 124 | <p className="text-sm"> |
| 125 | Use the spend cap to manage project usage and costs, and control whether the project can |
| 126 | exceed the included quota allowance of any billed line item in a billing cycle |
| 127 | </p> |
| 128 | |
| 129 | <Collapsible open={showUsageCosts} onOpenChange={setShowUsageCosts}> |
| 130 | <CollapsibleTrigger asChild> |
| 131 | <div className="flex items-center space-x-2 cursor-pointer"> |
| 132 | <ChevronRight |
| 133 | strokeWidth={1.5} |
| 134 | size={16} |
| 135 | className={showUsageCosts ? 'rotate-90' : ''} |
| 136 | /> |
| 137 | <p className="text-sm text-foreground-light"> |
| 138 | How are each resource charged after exceeding the included quota? |
| 139 | </p> |
| 140 | </div> |
| 141 | </CollapsibleTrigger> |
| 142 | <CollapsibleContent asChild> |
| 143 | <Table |
| 144 | className="mt-4" |
| 145 | head={ |
| 146 | <> |
| 147 | <Table.th> |
| 148 | <p className="text-xs">Item</p> |
| 149 | </Table.th> |
| 150 | <Table.th> |
| 151 | <p className="text-xs">Rate</p> |
| 152 | </Table.th> |
| 153 | </> |
| 154 | } |
| 155 | body={billingMetricCategories.map((categoryId) => { |
| 156 | const category = pricing[categoryId] |
| 157 | const usageItems = category.features.filter((it: any) => it.usage_based) |
| 158 | |
| 159 | return ( |
| 160 | <> |
| 161 | <Table.tr key={categoryId}> |
| 162 | <Table.td> |
| 163 | <p className="text-xs text-foreground">{category.title}</p> |
| 164 | </Table.td> |
| 165 | <Table.td>{null}</Table.td> |
| 166 | </Table.tr> |
| 167 | {usageItems.map((item: any) => { |
| 168 | return ( |
| 169 | <Table.tr key={item.title}> |
| 170 | <Table.td> |
| 171 | <p className="text-xs pl-4">{item.title}</p> |
| 172 | </Table.td> |
| 173 | <Table.td> |
| 174 | <p className="text-xs pl-4"> |
| 175 | {Array.isArray(item.plans['pro']) |
| 176 | ? item.plans['pro']?.join(', ') |
| 177 | : item.plans['pro']} |
| 178 | </p> |
| 179 | </Table.td> |
| 180 | </Table.tr> |
| 181 | ) |
| 182 | })} |
| 183 | </> |
| 184 | ) |
| 185 | })} |
| 186 | /> |
| 187 | </CollapsibleContent> |
| 188 | </Collapsible> |
| 189 | |
| 190 | {isFreePlan && ( |
| 191 | <Admonition |
| 192 | type="note" |
| 193 | layout="horizontal" |
| 194 | title="Toggling of the spend cap is only available on the Pro Plan" |
| 195 | description="Upgrade your plan to disable the spend cap" |
| 196 | actions={ |
| 197 | <Button type="default" onClick={() => snap.setPanelKey('subscriptionPlan')}> |
| 198 | View available plans |
| 199 | </Button> |
| 200 | } |
| 201 | /> |
| 202 | )} |
| 203 | |
| 204 | <div className="mt-8! pb-4"> |
| 205 | <div className="flex gap-3"> |
| 206 | {SPEND_CAP_OPTIONS.map((option) => { |
| 207 | const isSelected = selectedOption === option.value |
| 208 | |
| 209 | return ( |
| 210 | <div |
| 211 | key={option.value} |
| 212 | className={cn('col-span-4 group space-y-1', isFreePlan && 'opacity-75')} |
| 213 | onClick={() => !isFreePlan && setSelectedOption(option.value)} |
| 214 | > |
| 215 | <Image |
| 216 | alt="Spend Cap" |
| 217 | className={cn( |
| 218 | 'relative rounded-xl transition border bg-no-repeat bg-center bg-cover w-[160px] h-[96px]', |
| 219 | isSelected |
| 220 | ? 'border-foreground' |
| 221 | : 'border-foreground-muted opacity-50 group-hover:border-foreground-lighter group-hover:opacity-100', |
| 222 | !isFreePlan && 'cursor-pointer', |
| 223 | !isFreePlan && !isSelected && 'group-hover:border-foreground-light' |
| 224 | )} |
| 225 | width={160} |
| 226 | height={96} |
| 227 | src={resolvedTheme?.includes('dark') ? option.imageUrl : option.imageUrlLight} |
| 228 | /> |
| 229 | |
| 230 | <p |
| 231 | className={cn( |
| 232 | 'text-sm transition', |
| 233 | !isFreePlan && 'group-hover:text-foreground', |
| 234 | isSelected ? 'text-foreground' : 'text-foreground-light' |
| 235 | )} |
| 236 | > |
| 237 | {option.name} |
| 238 | </p> |
| 239 | </div> |
| 240 | ) |
| 241 | })} |
| 242 | </div> |
| 243 | </div> |
| 244 | |
| 245 | {selectedOption === 'on' ? ( |
| 246 | <Admonition |
| 247 | type="warning" |
| 248 | title="Your projects could become unresponsive or enter read only mode" |
| 249 | description="Exceeding the included quota allowance with spend cap enabled can cause your projects |
| 250 | to become unresponsive or enter read only mode." |
| 251 | /> |
| 252 | ) : ( |
| 253 | <Admonition |
| 254 | type="note" |
| 255 | title="Charges apply for usage beyond included quota allowance" |
| 256 | description="Your projects will always remain responsive and active, and charges only apply when |
| 257 | exceeding the included quota limit." |
| 258 | /> |
| 259 | )} |
| 260 | |
| 261 | {hasChanges && ( |
| 262 | <> |
| 263 | <p className="text-sm"> |
| 264 | {selectedOption === 'on' |
| 265 | ? 'Upon clicking confirm, spend cap will be enabled for your organization and you will no longer be charged any extra for usage.' |
| 266 | : 'Upon clicking confirm, spend cap will be disabled for your organization and you will be charged for any usage beyond the included quota.'} |
| 267 | </p> |
| 268 | <p className="text-sm"> |
| 269 | Toggling spend cap triggers an invoice and there might be prorated charges for any |
| 270 | usage beyond the Pro Plans quota during this billing cycle. |
| 271 | </p> |
| 272 | </> |
| 273 | )} |
| 274 | </div> |
| 275 | </SidePanel.Content> |
| 276 | </SidePanel> |
| 277 | ) |
| 278 | } |
| 279 | |
| 280 | export default SpendCapSidePanel |