SpendCapSidePanel.tsx280 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import { ChevronRight, ExternalLink } from 'lucide-react'
4import { useTheme } from 'next-themes'
5import Image from 'next/image'
6import Link from 'next/link'
7import { useEffect, useState } from 'react'
8import { pricing } from 'shared-data/pricing'
9import { toast } from 'sonner'
10import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, SidePanel } from 'ui'
11import { Admonition } from 'ui-patterns/admonition'
12
13import Table from '@/components/to-be-cleaned/Table'
14import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
15import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation'
16import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
17import { BASE_PATH, DOCS_URL, PRICING_TIER_PRODUCT_IDS } from '@/lib/constants'
18import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings'
19
20const 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
40const 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
280export default SpendCapSidePanel