CostControl.tsx208 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useFlag, useParams } from 'common'
3import { ExternalLink } from 'lucide-react'
4import { useTheme } from 'next-themes'
5import Image from 'next/image'
6import Link from 'next/link'
7import { Alert, AlertTitle, Button } from 'ui'
8import { Admonition } from 'ui-patterns/admonition'
9import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
10
11import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip'
12import SpendCapSidePanel from './SpendCapSidePanel'
13import {
14 ScaffoldSection,
15 ScaffoldSectionContent,
16 ScaffoldSectionDetail,
17} from '@/components/layouts/Scaffold'
18import AlertError from '@/components/ui/AlertError'
19import { InlineLink } from '@/components/ui/InlineLink'
20import NoPermission from '@/components/ui/NoPermission'
21import PartnerIcon from '@/components/ui/PartnerIcon'
22import { PARTNER_TO_NAME } from '@/components/ui/PartnerManagedResource'
23import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
24import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
25import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
26import { BASE_PATH, DOCS_URL } from '@/lib/constants'
27import { MANAGED_BY } from '@/lib/constants/infrastructure'
28import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings'
29
30export interface CostControlProps {}
31
32const CostControl = ({}: CostControlProps) => {
33 const { slug } = useParams()
34 const { resolvedTheme } = useTheme()
35 const { data: selectedOrganization } = useSelectedOrganizationQuery()
36
37 const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = useAsyncCheckPermissions(
38 PermissionAction.BILLING_READ,
39 'stripe.subscriptions'
40 )
41
42 const snap = useOrgSettingsPageStateSnapshot()
43 const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
44 const {
45 data: subscription,
46 error,
47 isPending: isLoading,
48 isSuccess,
49 isError,
50 } = useOrgSubscriptionQuery({ orgSlug: slug }, { enabled: canReadSubscriptions })
51
52 const currentPlan = subscription?.plan
53 const isUsageBillingEnabled = subscription?.usage_billing_enabled ?? false
54
55 const canChangeTier =
56 !projectUpdateDisabled && !['team', 'enterprise', 'platform'].includes(currentPlan?.id || '')
57
58 const costControlDisabled = selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE
59
60 return (
61 <>
62 <ScaffoldSection>
63 <ScaffoldSectionDetail>
64 <div className="sticky space-y-6 top-12">
65 <div className="space-y-2">
66 <p className="text-foreground text-base m-0">Cost Control</p>
67 <p className="text-sm text-foreground-light m-0">
68 Allow scaling beyond your plan's{' '}
69 <InlineLink href={`/org/${slug}/usage`}>included quota</InlineLink>.
70 </p>
71 </div>
72 <div className="space-y-2">
73 <p className="text-sm text-foreground-light m-0">More information</p>
74 <div>
75 <Link
76 href={`${DOCS_URL}/guides/platform/cost-control#spend-cap`}
77 target="_blank"
78 rel="noreferrer"
79 >
80 <div className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition">
81 <p className="text-sm m-0">Spend cap</p>
82 <ExternalLink size={16} strokeWidth={1.5} />
83 </div>
84 </Link>
85 </div>
86 </div>
87 </div>
88 </ScaffoldSectionDetail>
89 <ScaffoldSectionContent>
90 {isPermissionsLoaded && !canReadSubscriptions ? (
91 <NoPermission resourceText="update this organization's cost control" />
92 ) : (
93 <>
94 {isLoading && (
95 <div className="space-y-2">
96 <ShimmeringLoader />
97 <ShimmeringLoader className="w-3/4" />
98 <ShimmeringLoader className="w-1/2" />
99 </div>
100 )}
101
102 {isError && <AlertError subject="Failed to retrieve subscription" error={error} />}
103
104 {isSuccess && costControlDisabled && (
105 <Alert className="flex flex-col items-center gap-y-2 border-0 rounded-none">
106 <PartnerIcon
107 organization={{ managed_by: selectedOrganization?.managed_by }}
108 showTooltip={false}
109 size="large"
110 />
111
112 <AlertTitle className="text-sm">
113 The Spend Cap is not available for organizations managed by{' '}
114 {PARTNER_TO_NAME[selectedOrganization?.managed_by]}.
115 </AlertTitle>
116 </Alert>
117 )}
118
119 {isSuccess && !costControlDisabled && (
120 <div className="space-y-6">
121 {['team', 'enterprise', 'platform'].includes(currentPlan?.id || '') ? (
122 <>
123 <Admonition
124 type="default"
125 layout="horizontal"
126 title={`You will be charged for any additional usage on the ${
127 currentPlan?.name || ''
128 } plan`}
129 description={
130 <>
131 {currentPlan?.name || ''} plan requires you to have spend cap off at all
132 times. Your projects will never become unresponsive. Only when your{' '}
133 <Link
134 href={`/org/${slug}/usage`}
135 className="text-green-900 transition hover:text-green-1000"
136 >
137 included usage
138 </Link>{' '}
139 is exceeded will you be charged for any additional usage.
140 </>
141 }
142 />
143 </>
144 ) : (
145 <p className="text-sm text-foreground-light">
146 If you need to go beyond the included quota, simply switch off your spend cap
147 to pay for additional usage.
148 </p>
149 )}
150
151 <div className="flex flex-col md:flex-row gap-6">
152 <div>
153 <div className="rounded-md bg-surface-200 w-[160px] h-[96px] shadow-sm">
154 <Image
155 alt="Spend Cap"
156 width={160}
157 height={96}
158 src={
159 isUsageBillingEnabled
160 ? `${BASE_PATH}/img/spend-cap-off${
161 resolvedTheme?.includes('dark') ? '' : '--light'
162 }.png`
163 : `${BASE_PATH}/img/spend-cap-on${
164 resolvedTheme?.includes('dark') ? '' : '--light'
165 }.png`
166 }
167 />
168 </div>
169 </div>
170 <div>
171 <p className="mb-1">
172 Spend cap is {isUsageBillingEnabled ? 'disabled' : 'enabled'}
173 </p>
174 <p className="text-sm text-foreground-light">
175 {isUsageBillingEnabled ? (
176 <span>You will be charged for usage beyond the included quota.</span>
177 ) : (
178 <span>
179 You won't be charged any extra for usage. However, your projects could
180 become unresponsive or enter read only mode if you exceed the included
181 quota.
182 </span>
183 )}
184 </p>
185 <ProjectUpdateDisabledTooltip projectUpdateDisabled={projectUpdateDisabled}>
186 <Button
187 type="default"
188 className="mt-4 pointer-events-auto"
189 disabled={!canChangeTier}
190 onClick={() => snap.setPanelKey('costControl')}
191 >
192 Change spend cap
193 </Button>
194 </ProjectUpdateDisabledTooltip>
195 </div>
196 </div>
197 </div>
198 )}
199 </>
200 )}
201 </ScaffoldSectionContent>
202 </ScaffoldSection>
203 <SpendCapSidePanel />
204 </>
205 )
206}
207
208export default CostControl