mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 23:29:08 +03:00
Merge pull request #15519 from supabase/feat/org-level-billing
Billing Revamp Org Part 1
This commit is contained in:
55 files changed
+5340
-18
No files matched your search
@@ -75,6 +75,7 @@ const BillingEmail = () => {
|
||||
useEffect(() => {
|
||||
const values = { billing_email: billing_email ?? '' }
|
||||
resetForm({ values, initialValues: values })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slug])
|
||||
|
||||
return (
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
export interface ProjectUpdateDisabledTooltipProps {
|
||||
projectUpdateDisabled: boolean
|
||||
}
|
||||
|
||||
const ProjectUpdateDisabledTooltip = ({
|
||||
projectUpdateDisabled,
|
||||
children,
|
||||
}: PropsWithChildren<ProjectUpdateDisabledTooltipProps>) => {
|
||||
return (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
||||
{projectUpdateDisabled ? (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
Subscription changes are currently disabled. Our engineers are working on a fix.
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
) : null}
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectUpdateDisabledTooltip
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
export const COUNTRIES = [
|
||||
{ code: 'AF', name: 'Afghanistan' },
|
||||
{ code: 'AL', name: 'Albania' },
|
||||
{ code: 'DZ', name: 'Algeria' },
|
||||
{ code: 'AS', name: 'American Samoa' },
|
||||
{ code: 'AD', name: 'Andorra' },
|
||||
{ code: 'AO', name: 'Angola' },
|
||||
{ code: 'AI', name: 'Anguilla' },
|
||||
{ code: 'AQ', name: 'Antarctica' },
|
||||
{ code: 'AG', name: 'Antigua and Barbuda' },
|
||||
{ code: 'AR', name: 'Argentina' },
|
||||
{ code: 'AM', name: 'Armenia' },
|
||||
{ code: 'AW', name: 'Aruba' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'AT', name: 'Austria' },
|
||||
{ code: 'AZ', name: 'Azerbaijan' },
|
||||
{ code: 'BS', name: 'Bahamas' },
|
||||
{ code: 'BH', name: 'Bahrain' },
|
||||
{ code: 'BD', name: 'Bangladesh' },
|
||||
{ code: 'BB', name: 'Barbados' },
|
||||
{ code: 'BY', name: 'Belarus' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'BZ', name: 'Belize' },
|
||||
{ code: 'BJ', name: 'Benin' },
|
||||
{ code: 'BM', name: 'Bermuda' },
|
||||
{ code: 'BT', name: 'Bhutan' },
|
||||
{ code: 'BO', name: 'Bolivia' },
|
||||
{ code: 'BA', name: 'Bosnia and Herzegovina' },
|
||||
{ code: 'BW', name: 'Botswana' },
|
||||
{ code: 'BV', name: 'Bouvet Island' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'IO', name: 'British Indian Ocean Territory' },
|
||||
{ code: 'VG', name: 'British Virgin Islands' },
|
||||
{ code: 'BN', name: 'Brunei Darussalam' },
|
||||
{ code: 'BG', name: 'Bulgaria' },
|
||||
{ code: 'BF', name: 'Burkina Faso' },
|
||||
{ code: 'BI', name: 'Burundi' },
|
||||
{ code: 'KH', name: 'Cambodia' },
|
||||
{ code: 'CM', name: 'Cameroon' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'CV', name: 'Cape Verde' },
|
||||
{ code: 'KY', name: 'Cayman Islands' },
|
||||
{ code: 'CF', name: 'Central African Republic' },
|
||||
{ code: 'TD', name: 'Chad' },
|
||||
{ code: 'CL', name: 'Chile' },
|
||||
{ code: 'CN', name: 'China' },
|
||||
{ code: 'CX', name: 'Christmas Island' },
|
||||
{ code: 'CC', name: 'Cocos (Keeling) Islands' },
|
||||
{ code: 'CO', name: 'Colombia' },
|
||||
{ code: 'KM', name: 'Comoros' },
|
||||
{ code: 'CG', name: 'Congo' },
|
||||
{ code: 'CK', name: 'Cook Islands' },
|
||||
{ code: 'CR', name: 'Costa Rica' },
|
||||
{ code: 'HR', name: 'Croatia' },
|
||||
{ code: 'CU', name: 'Cuba' },
|
||||
{ code: 'CW', name: 'Curaçao' },
|
||||
{ code: 'CY', name: 'Cyprus' },
|
||||
{ code: 'CZ', name: 'Czech Republic' },
|
||||
{ code: 'CI', name: "Côte d'Ivoire" },
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'DJ', name: 'Djibouti' },
|
||||
{ code: 'DM', name: 'Dominica' },
|
||||
{ code: 'DO', name: 'Dominican Republic' },
|
||||
{ code: 'EC', name: 'Ecuador' },
|
||||
{ code: 'EG', name: 'Egypt' },
|
||||
{ code: 'SV', name: 'El Salvador' },
|
||||
{ code: 'GQ', name: 'Equatorial Guinea' },
|
||||
{ code: 'ER', name: 'Eritrea' },
|
||||
{ code: 'EE', name: 'Estonia' },
|
||||
{ code: 'SZ', name: 'Eswatini' },
|
||||
{ code: 'ET', name: 'Ethiopia' },
|
||||
{ code: 'FK', name: 'Falkland Islands' },
|
||||
{ code: 'FO', name: 'Faroe Islands' },
|
||||
{ code: 'FJ', name: 'Fiji' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'GF', name: 'French Guiana' },
|
||||
{ code: 'PF', name: 'French Polynesia' },
|
||||
{ code: 'TF', name: 'French Southern Territories' },
|
||||
{ code: 'GA', name: 'Gabon' },
|
||||
{ code: 'GM', name: 'Gambia' },
|
||||
{ code: 'GE', name: 'Georgia' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'GH', name: 'Ghana' },
|
||||
{ code: 'GI', name: 'Gibraltar' },
|
||||
{ code: 'GR', name: 'Greece' },
|
||||
{ code: 'GL', name: 'Greenland' },
|
||||
{ code: 'GD', name: 'Grenada' },
|
||||
{ code: 'GP', name: 'Guadeloupe' },
|
||||
{ code: 'GU', name: 'Guam' },
|
||||
{ code: 'GT', name: 'Guatemala' },
|
||||
{ code: 'GG', name: 'Guernsey' },
|
||||
{ code: 'GN', name: 'Guinea' },
|
||||
{ code: 'GW', name: 'Guinea-Bissau' },
|
||||
{ code: 'GY', name: 'Guyana' },
|
||||
{ code: 'HT', name: 'Haiti' },
|
||||
{ code: 'HM', name: 'Heard & McDonald Islands' },
|
||||
{ code: 'HN', name: 'Honduras' },
|
||||
{ code: 'HK', name: 'Hong Kong' },
|
||||
{ code: 'HU', name: 'Hungary' },
|
||||
{ code: 'IS', name: 'Iceland' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'ID', name: 'Indonesia' },
|
||||
{ code: 'IR', name: 'Iran' },
|
||||
{ code: 'IQ', name: 'Iraq' },
|
||||
{ code: 'IE', name: 'Ireland' },
|
||||
{ code: 'IM', name: 'Isle of Man' },
|
||||
{ code: 'IL', name: 'Israel' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'JM', name: 'Jamaica' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'JE', name: 'Jersey' },
|
||||
{ code: 'JO', name: 'Jordan' },
|
||||
{ code: 'KZ', name: 'Kazakhstan' },
|
||||
{ code: 'KE', name: 'Kenya' },
|
||||
{ code: 'KI', name: 'Kiribati' },
|
||||
{ code: 'KW', name: 'Kuwait' },
|
||||
{ code: 'KG', name: 'Kyrgyzstan' },
|
||||
{ code: 'LA', name: 'Laos' },
|
||||
{ code: 'LV', name: 'Latvia' },
|
||||
{ code: 'LB', name: 'Lebanon' },
|
||||
{ code: 'LS', name: 'Lesotho' },
|
||||
{ code: 'LR', name: 'Liberia' },
|
||||
{ code: 'LY', name: 'Libya' },
|
||||
{ code: 'LI', name: 'Liechtenstein' },
|
||||
{ code: 'LT', name: 'Lithuania' },
|
||||
{ code: 'LU', name: 'Luxembourg' },
|
||||
{ code: 'MO', name: 'Macao' },
|
||||
{ code: 'MG', name: 'Madagascar' },
|
||||
{ code: 'MW', name: 'Malawi' },
|
||||
{ code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'MV', name: 'Maldives' },
|
||||
{ code: 'ML', name: 'Mali' },
|
||||
{ code: 'MT', name: 'Malta' },
|
||||
{ code: 'MH', name: 'Marshall Islands' },
|
||||
{ code: 'MQ', name: 'Martinique' },
|
||||
{ code: 'MR', name: 'Mauritania' },
|
||||
{ code: 'MU', name: 'Mauritius' },
|
||||
{ code: 'YT', name: 'Mayotte' },
|
||||
{ code: 'MX', name: 'Mexico' },
|
||||
{ code: 'FM', name: 'Micronesia' },
|
||||
{ code: 'MD', name: 'Moldova' },
|
||||
{ code: 'MC', name: 'Monaco' },
|
||||
{ code: 'MN', name: 'Mongolia' },
|
||||
{ code: 'ME', name: 'Montenegro' },
|
||||
{ code: 'MS', name: 'Montserrat' },
|
||||
{ code: 'MA', name: 'Morocco' },
|
||||
{ code: 'MZ', name: 'Mozambique' },
|
||||
{ code: 'MM', name: 'Myanmar' },
|
||||
{ code: 'NA', name: 'Namibia' },
|
||||
{ code: 'NR', name: 'Nauru' },
|
||||
{ code: 'NP', name: 'Nepal' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'NC', name: 'New Caledonia' },
|
||||
{ code: 'NZ', name: 'New Zealand' },
|
||||
{ code: 'NI', name: 'Nicaragua' },
|
||||
{ code: 'NE', name: 'Niger' },
|
||||
{ code: 'NG', name: 'Nigeria' },
|
||||
{ code: 'NU', name: 'Niue' },
|
||||
{ code: 'NF', name: 'Norfolk Island' },
|
||||
{ code: 'KP', name: 'North Korea' },
|
||||
{ code: 'MK', name: 'North Macedonia' },
|
||||
{ code: 'MP', name: 'Northern Mariana Islands' },
|
||||
{ code: 'NO', name: 'Norway' },
|
||||
{ code: 'OM', name: 'Oman' },
|
||||
{ code: 'PK', name: 'Pakistan' },
|
||||
{ code: 'PW', name: 'Palau' },
|
||||
{ code: 'PS', name: 'Palestinian Territories' },
|
||||
{ code: 'PA', name: 'Panama' },
|
||||
{ code: 'PG', name: 'Papua New Guinea' },
|
||||
{ code: 'PY', name: 'Paraguay' },
|
||||
{ code: 'PE', name: 'Peru' },
|
||||
{ code: 'PH', name: 'Philippines' },
|
||||
{ code: 'PN', name: 'Pitcairn Islands' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'PR', name: 'Puerto Rico' },
|
||||
{ code: 'QA', name: 'Qatar' },
|
||||
{ code: 'RO', name: 'Romania' },
|
||||
{ code: 'RU', name: 'Russia' },
|
||||
{ code: 'RW', name: 'Rwanda' },
|
||||
{ code: 'RE', name: 'Réunion' },
|
||||
{ code: 'BL', name: 'Saint Barthélemy' },
|
||||
{ code: 'WS', name: 'Samoa' },
|
||||
{ code: 'SM', name: 'San Marino' },
|
||||
{ code: 'ST', name: 'Sao Tome and Principe' },
|
||||
{ code: 'SA', name: 'Saudi Arabia' },
|
||||
{ code: 'SN', name: 'Senegal' },
|
||||
{ code: 'RS', name: 'Serbia' },
|
||||
{ code: 'SC', name: 'Seychelles' },
|
||||
{ code: 'SL', name: 'Sierra Leone' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'SX', name: 'Sint Maarten' },
|
||||
{ code: 'SK', name: 'Slovakia' },
|
||||
{ code: 'SI', name: 'Slovenia' },
|
||||
{ code: 'SB', name: 'Solomon Islands' },
|
||||
{ code: 'SO', name: 'Somalia' },
|
||||
{ code: 'ZA', name: 'South Africa' },
|
||||
{ code: 'GS', name: 'South Georgia and the South Sandwich Islands' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'SS', name: 'South Sudan' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'LK', name: 'Sri Lanka' },
|
||||
{ code: 'SH', name: 'St Helena' },
|
||||
{ code: 'KN', name: 'St Kitts and Nevis' },
|
||||
{ code: 'LC', name: 'St Lucia' },
|
||||
{ code: 'MF', name: 'St Martin' },
|
||||
{ code: 'PM', name: 'St Pierre and Miquelon' },
|
||||
{ code: 'VC', name: 'St Vincent and the Grenadines' },
|
||||
{ code: 'SD', name: 'Sudan' },
|
||||
{ code: 'SR', name: 'Suriname' },
|
||||
{ code: 'SJ', name: 'Svalbard & Jan Mayen' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'CH', name: 'Switzerland' },
|
||||
{ code: 'SY', name: 'Syria' },
|
||||
{ code: 'TW', name: 'Taiwan' },
|
||||
{ code: 'TJ', name: 'Tajikistan' },
|
||||
{ code: 'TZ', name: 'Tanzania' },
|
||||
{ code: 'TH', name: 'Thailand' },
|
||||
{ code: 'TL', name: 'Timor-Leste' },
|
||||
{ code: 'TG', name: 'Togo' },
|
||||
{ code: 'TK', name: 'Tokelau' },
|
||||
{ code: 'TO', name: 'Tonga' },
|
||||
{ code: 'TT', name: 'Trinidad & Tobago' },
|
||||
{ code: 'TN', name: 'Tunisia' },
|
||||
{ code: 'TR', name: 'Turkey' },
|
||||
{ code: 'TM', name: 'Turkmenistan' },
|
||||
{ code: 'TC', name: 'Turks & Caicos Islands' },
|
||||
{ code: 'TV', name: 'Tuvalu' },
|
||||
{ code: 'VI', name: 'US Virgin Islands' },
|
||||
{ code: 'UG', name: 'Uganda' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
{ code: 'AE', name: 'United Arab Emirates' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'US', name: 'United States of America' },
|
||||
{ code: 'UY', name: 'Uruguay' },
|
||||
{ code: 'UZ', name: 'Uzbekistan' },
|
||||
{ code: 'VU', name: 'Vanuatu' },
|
||||
{ code: 'VA', name: 'Vatican City' },
|
||||
{ code: 'VE', name: 'Venezuela' },
|
||||
{ code: 'VN', name: 'Vietnam' },
|
||||
{ code: 'WF', name: 'Wallis & Futuna' },
|
||||
{ code: 'EH', name: 'Western Sahara' },
|
||||
{ code: 'YE', name: 'Yemen' },
|
||||
{ code: 'ZM', name: 'Zambia' },
|
||||
{ code: 'ZW', name: 'Zimbabwe' },
|
||||
{ code: 'AX', name: 'Åland Islands' },
|
||||
]
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
ScaffoldSection,
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldSectionDetail,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
|
||||
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { Form, Input, Listbox } from 'ui'
|
||||
import { COUNTRIES } from './BillingAddress.constants'
|
||||
|
||||
const BillingAddress = () => {
|
||||
const { ui } = useStore()
|
||||
const { slug } = useParams()
|
||||
const { data, error, isLoading, isSuccess, isError } = useOrganizationCustomerProfileQuery({
|
||||
slug,
|
||||
})
|
||||
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
|
||||
|
||||
const formId = 'billing-address-form'
|
||||
const { city, country, line1, line2, postal_code, state } = data?.address ?? {}
|
||||
|
||||
const canReadBillingAddress = useCheckPermissions(
|
||||
PermissionAction.BILLING_READ,
|
||||
'stripe.customer'
|
||||
)
|
||||
const canUpdateBillingAddress = useCheckPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
'stripe.customer'
|
||||
)
|
||||
|
||||
const initialValues = { city, country, line1, line2, postal_code, state }
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => {
|
||||
if (!slug) return console.error('Slug is required')
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await updateCustomerProfile({ slug, address: values })
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: 'Successfully updated billing address',
|
||||
})
|
||||
resetForm({ values, initialValues: values })
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to update billing address: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldSection>
|
||||
<ScaffoldSectionDetail>
|
||||
<div className="sticky top-16">
|
||||
<p className="text-base">Billing Address</p>
|
||||
<p className="text-sm text-scale-1000">This will be reflected in every invoice</p>
|
||||
</div>
|
||||
</ScaffoldSectionDetail>
|
||||
<ScaffoldSectionContent>
|
||||
{!canReadBillingAddress ? (
|
||||
<NoPermission resourceText="view this organization's billing address" />
|
||||
) : (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<AlertError
|
||||
subject="Unable to retrieve organization customer profile"
|
||||
error={error as any}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<Form validateOnBlur id={formId} initialValues={initialValues} onSubmit={onSubmit}>
|
||||
{({ isSubmitting, values, initialValues, handleReset, resetForm }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
if (isSuccess && data !== undefined) {
|
||||
const { city, country, line1, line2, postal_code, state } =
|
||||
data?.address ?? {}
|
||||
const values = { city, country, line1, line2, postal_code, state }
|
||||
resetForm({ values, initialValues: values })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSuccess])
|
||||
|
||||
return (
|
||||
<FormPanel
|
||||
footer={
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isSubmitting}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={handleReset}
|
||||
helper={
|
||||
!canUpdateBillingAddress
|
||||
? "You need additional permissions to manage this organization's billing address"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormSection>
|
||||
<FormSectionContent fullWidth loading={false} className="!gap-2">
|
||||
<Input
|
||||
id="line1"
|
||||
name="line1"
|
||||
placeholder="Address line 1"
|
||||
disabled={!canUpdateBillingAddress}
|
||||
/>
|
||||
<Input
|
||||
id="line2"
|
||||
name="line2"
|
||||
placeholder="Address line 2"
|
||||
disabled={!canUpdateBillingAddress}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Listbox
|
||||
className="w-full"
|
||||
id="country"
|
||||
name="country"
|
||||
placeholder="Country"
|
||||
disabled={!canUpdateBillingAddress}
|
||||
>
|
||||
<Listbox.Option label="---" key="empty" value="">
|
||||
---
|
||||
</Listbox.Option>
|
||||
{COUNTRIES.map((country) => (
|
||||
<Listbox.Option
|
||||
label={country.name}
|
||||
key={country.code}
|
||||
value={country.code}
|
||||
>
|
||||
{country.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
<Input
|
||||
className="w-full"
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
placeholder="Postal code"
|
||||
disabled={!canUpdateBillingAddress}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
className="w-full"
|
||||
id="city"
|
||||
name="city"
|
||||
placeholder="City"
|
||||
disabled={!canUpdateBillingAddress}
|
||||
/>
|
||||
<Input
|
||||
className="w-full"
|
||||
id="state"
|
||||
name="state"
|
||||
placeholder="State"
|
||||
disabled={!canUpdateBillingAddress}
|
||||
/>
|
||||
</div>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScaffoldSectionContent>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
|
||||
export default BillingAddress
|
||||
@@ -0,0 +1,126 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
ScaffoldSection,
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldSectionDetail,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms'
|
||||
import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
|
||||
import { patch } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { Form, Input } from 'ui'
|
||||
|
||||
const BillingEmail = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { ui } = useStore()
|
||||
const { slug } = useParams()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const { name, billing_email } = selectedOrganization ?? {}
|
||||
|
||||
const formId = 'org-billing-email'
|
||||
const initialValues = { billing_email: billing_email ?? '' }
|
||||
|
||||
const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
|
||||
const canReadBillingEmail = useCheckPermissions(PermissionAction.READ, 'organizations')
|
||||
|
||||
const onUpdateOrganizationEmail = async (values: any, { setSubmitting, resetForm }: any) => {
|
||||
if (!canUpdateOrganization) {
|
||||
return ui.setNotification({
|
||||
category: 'error',
|
||||
message: 'You do not have the required permissions to update this organization',
|
||||
})
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
const response = await patch(`${API_URL}/organizations/${slug}`, { ...values, name })
|
||||
if (response.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to update organization: ${response.error.message}`,
|
||||
})
|
||||
} else {
|
||||
const { billing_email } = response
|
||||
resetForm({
|
||||
values: { billing_email },
|
||||
initialValues: { billing_email },
|
||||
})
|
||||
|
||||
invalidateOrganizationsQuery(queryClient)
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: 'Successfully saved settings',
|
||||
})
|
||||
}
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldSection>
|
||||
<ScaffoldSectionDetail>
|
||||
<div className="sticky space-y-6 top-16">
|
||||
<div>
|
||||
<p className="text-base">Email Recipient</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
All billing correspondence will go to this email
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScaffoldSectionDetail>
|
||||
<ScaffoldSectionContent>
|
||||
<Form id={formId} initialValues={initialValues} onSubmit={onUpdateOrganizationEmail}>
|
||||
{({ isSubmitting, handleReset, values, initialValues, resetForm }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
// [Alaister] although this "technically" is breaking the rules of React hooks
|
||||
// it won't error because the hooks are always rendered in the same order
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
const values = { billing_email: billing_email ?? '' }
|
||||
resetForm({ values, initialValues: values })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slug])
|
||||
|
||||
return (
|
||||
<FormPanel
|
||||
footer={
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isSubmitting}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={handleReset}
|
||||
helper={
|
||||
!canUpdateOrganization
|
||||
? "You need additional permissions to manage this organization's settings"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormSection>
|
||||
<FormSectionContent fullWidth loading={false}>
|
||||
<Input
|
||||
id="billing_email"
|
||||
size="small"
|
||||
label="Email address"
|
||||
type={canReadBillingEmail ? 'text' : 'password'}
|
||||
disabled={!canUpdateOrganization}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
</ScaffoldSectionContent>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
|
||||
export default BillingEmail
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
export const CANCELLATION_REASONS = [
|
||||
'Pricing',
|
||||
"My project isn't getting traction",
|
||||
'Poor customer service',
|
||||
'Missing feature',
|
||||
"I didn't see the value",
|
||||
"Supabase didn't meet my needs",
|
||||
'Dashboard is too complicated',
|
||||
'Postgres is too complicated',
|
||||
'Problem not solved',
|
||||
'Bug issues',
|
||||
'I decided to use something else',
|
||||
'My work has finished/discontinued',
|
||||
'I’m migrating to/starting a new project',
|
||||
'None of the above',
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold'
|
||||
import BillingAddress from './BillingAddress/BillingAddress'
|
||||
import BillingEmail from './BillingEmail'
|
||||
import CostControl from './CostControl/CostControl'
|
||||
import CreditBalance from './CreditBalance'
|
||||
import PaymentMethods from './PaymentMethods/PaymentMethods'
|
||||
import Subscription from './Subscription/Subscription'
|
||||
import TaxID from './TaxID/TaxID'
|
||||
|
||||
const BillingSettings = () => {
|
||||
return (
|
||||
<>
|
||||
<ScaffoldContainer>
|
||||
<Subscription />
|
||||
</ScaffoldContainer>
|
||||
|
||||
<ScaffoldDivider />
|
||||
|
||||
<ScaffoldContainer>
|
||||
<CostControl />
|
||||
</ScaffoldContainer>
|
||||
|
||||
<ScaffoldDivider />
|
||||
|
||||
<ScaffoldContainer>
|
||||
<CreditBalance />
|
||||
</ScaffoldContainer>
|
||||
|
||||
<ScaffoldDivider />
|
||||
|
||||
<ScaffoldContainer>
|
||||
<PaymentMethods />
|
||||
</ScaffoldContainer>
|
||||
|
||||
<ScaffoldDivider />
|
||||
|
||||
<ScaffoldContainer>
|
||||
<BillingEmail />
|
||||
</ScaffoldContainer>
|
||||
|
||||
<ScaffoldDivider />
|
||||
|
||||
<ScaffoldContainer>
|
||||
<BillingAddress />
|
||||
</ScaffoldContainer>
|
||||
|
||||
<ScaffoldDivider />
|
||||
|
||||
<ScaffoldContainer>
|
||||
<TaxID />
|
||||
</ScaffoldContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BillingSettings
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useParams, useTheme } from 'common'
|
||||
import {
|
||||
ScaffoldSection,
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldSectionDetail,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useFlag } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
|
||||
import { Alert, Button, IconExternalLink } from 'ui'
|
||||
import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip'
|
||||
import SpendCapSidePanel from './SpendCapSidePanel'
|
||||
|
||||
export interface CostControlProps {}
|
||||
|
||||
const CostControl = ({}: CostControlProps) => {
|
||||
const { slug } = useParams()
|
||||
const { isDarkMode } = useTheme()
|
||||
|
||||
const snap = useOrgSettingsPageStateSnapshot()
|
||||
const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
|
||||
const {
|
||||
data: subscription,
|
||||
error,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
} = useOrgSubscriptionQuery({ orgSlug: slug })
|
||||
|
||||
const currentPlan = subscription?.plan
|
||||
const isUsageBillingEnabled = subscription?.usage_billing_enabled ?? false
|
||||
|
||||
const canChangeTier =
|
||||
!projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id || '')
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection>
|
||||
<ScaffoldSectionDetail>
|
||||
<div className="sticky space-y-6 top-16">
|
||||
<div>
|
||||
<p className="text-base">Cost Control</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
Control whether to use beyond your plans included quota
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-scale-1100">More information</p>
|
||||
<div>
|
||||
<Link href="https://supabase.com/docs/guides/platform/spend-cap">
|
||||
<a target="_blank" rel="noreferrer">
|
||||
<div className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition">
|
||||
<p className="text-sm">Spend cap</p>
|
||||
<IconExternalLink size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaffoldSectionDetail>
|
||||
<ScaffoldSectionContent>
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <AlertError subject="Failed to retrieve subscription" error={error} />}
|
||||
|
||||
{isSuccess && (
|
||||
<div className="space-y-6">
|
||||
{['team', 'enterprise'].includes(currentPlan?.id || '') ? (
|
||||
<Alert
|
||||
withIcon
|
||||
variant="info"
|
||||
title={`You will be charged for any additional usage on the ${
|
||||
currentPlan?.name || ''
|
||||
} plan`}
|
||||
>
|
||||
{currentPlan?.name || ''} plan requires you to have spend cap off at all times.
|
||||
Your projects will never become unresponsive. Only when your{' '}
|
||||
<Link href="#breakdown">
|
||||
<a className="text-sm text-green-900 transition hover:text-green-1000">
|
||||
included usage
|
||||
</a>
|
||||
</Link>{' '}
|
||||
is exceeded will you be charged for any additional usage.
|
||||
</Alert>
|
||||
) : (
|
||||
<p className="text-sm text-scale-1000">
|
||||
You can control whether your organization is charged for additional usage beyond
|
||||
the{' '}
|
||||
<Link href="#breakdown">
|
||||
<a className="text-sm text-green-900 transition hover:text-green-1000">
|
||||
included quota
|
||||
</a>
|
||||
</Link>{' '}
|
||||
of your subscription plan. If you need to go beyond the included quota, simply
|
||||
switch off your spend cap to pay for additional usage.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-6">
|
||||
<div>
|
||||
<div className="rounded-md bg-scale-100 dark:bg-scale-400 w-[160px] h-[96px] shadow">
|
||||
<img
|
||||
alt="Spend Cap"
|
||||
width={160}
|
||||
height={96}
|
||||
src={
|
||||
isUsageBillingEnabled
|
||||
? `${BASE_PATH}/img/spend-cap-off${isDarkMode ? '' : '--light'}.png?v=3`
|
||||
: `${BASE_PATH}/img/spend-cap-on${isDarkMode ? '' : '--light'}.png?v=3`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1">
|
||||
Spend cap is {isUsageBillingEnabled ? 'disabled' : 'enabled'}
|
||||
</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
{isUsageBillingEnabled ? (
|
||||
<span>You will be charged for any usage above the included quota.</span>
|
||||
) : (
|
||||
<span>
|
||||
You won't be charged any extra for usage. However, your projects could
|
||||
become unresponsive or enter read only mode if you exceed the included
|
||||
quota.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{isUsageBillingEnabled && (
|
||||
<p className="text-sm text-scale-1000 mt-1">
|
||||
Your projects will never become unresponsive. Only when your usage reaches the
|
||||
quota limit will you be charged for any excess usage.
|
||||
</p>
|
||||
)}
|
||||
<ProjectUpdateDisabledTooltip projectUpdateDisabled={projectUpdateDisabled}>
|
||||
<Button
|
||||
type="default"
|
||||
className="mt-4"
|
||||
disabled={!canChangeTier}
|
||||
onClick={() => snap.setPanelKey('costControl')}
|
||||
>
|
||||
Change spend cap
|
||||
</Button>
|
||||
</ProjectUpdateDisabledTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScaffoldSectionContent>
|
||||
</ScaffoldSection>
|
||||
<SpendCapSidePanel />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CostControl
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import clsx from 'clsx'
|
||||
import { useParams, useTheme } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { BASE_PATH, PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
|
||||
import Telemetry from 'lib/telemetry'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { pricing } from 'shared-data/pricing'
|
||||
import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
|
||||
import { Alert, Button, Collapsible, IconChevronRight, IconExternalLink, SidePanel } from 'ui'
|
||||
|
||||
const SPEND_CAP_OPTIONS: {
|
||||
name: string
|
||||
value: 'on' | 'off'
|
||||
imageUrl: string
|
||||
imageUrlLight: string
|
||||
}[] = [
|
||||
{
|
||||
name: 'Spend cap enabled',
|
||||
value: 'on',
|
||||
imageUrl: `${BASE_PATH}/img/spend-cap-on.png`,
|
||||
imageUrlLight: `${BASE_PATH}/img/spend-cap-on--light.png`,
|
||||
},
|
||||
{
|
||||
name: 'Spend cap disabled',
|
||||
value: 'off',
|
||||
imageUrl: `${BASE_PATH}/img/spend-cap-off.png`,
|
||||
imageUrlLight: `${BASE_PATH}/img/spend-cap-off--light.png`,
|
||||
},
|
||||
]
|
||||
|
||||
const SpendCapSidePanel = () => {
|
||||
const { ui } = useStore()
|
||||
const router = useRouter()
|
||||
const { slug } = useParams()
|
||||
const { isDarkMode } = useTheme()
|
||||
|
||||
const [showUsageCosts, setShowUsageCosts] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
|
||||
const [selectedOption, setSelectedOption] = useState<'on' | 'off'>()
|
||||
|
||||
const canUpdateSpendCap = useCheckPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
'stripe.subscriptions'
|
||||
)
|
||||
|
||||
const snap = useOrgSettingsPageStateSnapshot()
|
||||
const visible = snap.panelKey === 'costControl'
|
||||
const onClose = () => snap.setPanelKey(undefined)
|
||||
|
||||
const { data: subscription, isLoading } = useOrgSubscriptionQuery({ orgSlug: slug })
|
||||
const { mutateAsync: updateOrgSubscription } = useOrgSubscriptionUpdateMutation()
|
||||
|
||||
const isFreePlan = subscription?.plan?.id === 'free'
|
||||
const isSpendCapOn = !subscription?.usage_billing_enabled
|
||||
const isTurningOnCap = !isSpendCapOn && selectedOption === 'on'
|
||||
const hasChanges = selectedOption !== (isSpendCapOn ? 'on' : 'off')
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && subscription !== undefined) {
|
||||
setSelectedOption(isSpendCapOn ? 'on' : 'off')
|
||||
Telemetry.sendActivity(
|
||||
{
|
||||
activity: 'Side Panel Viewed',
|
||||
source: 'Dashboard',
|
||||
data: {
|
||||
title: 'Spend cap',
|
||||
section: 'Cost Control',
|
||||
},
|
||||
},
|
||||
router
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, isLoading, subscription, isSpendCapOn])
|
||||
|
||||
const onConfirm = async () => {
|
||||
if (!slug) return console.error('Org slug is required')
|
||||
|
||||
try {
|
||||
const tier = (
|
||||
selectedOption === 'on' ? PRICING_TIER_PRODUCT_IDS.PRO : PRICING_TIER_PRODUCT_IDS.PAYG
|
||||
) as 'tier_pro' | 'tier_payg'
|
||||
setIsSubmitting(true)
|
||||
await updateOrgSubscription({ slug, tier })
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully ${isTurningOnCap ? 'enabled' : 'disabled'} spend cap`,
|
||||
})
|
||||
onClose()
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Unable to toggle spend cap: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const billingMetricCategories: (keyof typeof pricing)[] = [
|
||||
'database',
|
||||
'auth',
|
||||
'storage',
|
||||
'realtime',
|
||||
'edge_functions',
|
||||
]
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
size="large"
|
||||
loading={isLoading || isSubmitting}
|
||||
disabled={isFreePlan || isLoading || !hasChanges || isSubmitting || !canUpdateSpendCap}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onConfirm={onConfirm}
|
||||
header={
|
||||
<div className="flex items-center justify-between">
|
||||
<h4>Spend cap</h4>
|
||||
<Link href="https://supabase.com/docs/guides/platform/spend-cap">
|
||||
<a target="_blank" rel="noreferrer">
|
||||
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
|
||||
About spend cap
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</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>
|
||||
|
||||
<Collapsible open={showUsageCosts} onOpenChange={setShowUsageCosts}>
|
||||
<Collapsible.Trigger asChild>
|
||||
<div className="flex items-center space-x-2 cursor-pointer">
|
||||
<IconChevronRight
|
||||
strokeWidth={1.5}
|
||||
size={16}
|
||||
className={showUsageCosts ? 'rotate-90' : ''}
|
||||
/>
|
||||
<p className="text-sm text-scale-1100">
|
||||
How are each resource charged after exceeding the included quota?
|
||||
</p>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content 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>
|
||||
</>
|
||||
}
|
||||
body={billingMetricCategories.map((categoryId) => {
|
||||
const category = pricing[categoryId]
|
||||
const usageItems = category.features.filter((it: any) => it.usage_based)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.tr key={categoryId}>
|
||||
<Table.td>
|
||||
<p className="text-xs text-scale-1200">{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">
|
||||
{item.plans[subscription?.plan?.id || 'pro']}
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
|
||||
{isFreePlan && (
|
||||
<Alert
|
||||
withIcon
|
||||
variant="info"
|
||||
title="Toggling of the spend cap is only available on the Pro plan"
|
||||
actions={
|
||||
<Button type="default" onClick={() => snap.setPanelKey('subscriptionPlan')}>
|
||||
View available plans
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Upgrade your plan to disable the spend cap
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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={clsx('col-span-4 group space-y-1', isFreePlan && 'opacity-75')}
|
||||
onClick={() => {
|
||||
!isFreePlan && setSelectedOption(option.value)
|
||||
Telemetry.sendActivity(
|
||||
{
|
||||
activity: 'Option Selected',
|
||||
source: 'Dashboard',
|
||||
data: {
|
||||
title: 'Spend cap',
|
||||
section: 'Cost Control',
|
||||
option: option.name,
|
||||
},
|
||||
},
|
||||
router
|
||||
)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="Spend Cap"
|
||||
className={clsx(
|
||||
'relative rounded-xl transition border bg-no-repeat bg-center bg-cover w-[160px] h-[96px]',
|
||||
isSelected
|
||||
? 'border-scale-1200'
|
||||
: 'border-scale-900 opacity-50 group-hover:border-scale-1000 group-hover:opacity-100',
|
||||
!isFreePlan && 'cursor-pointer',
|
||||
!isFreePlan && !isSelected && 'group-hover:border-scale-1100'
|
||||
)}
|
||||
width={160}
|
||||
height={96}
|
||||
src={isDarkMode ? option.imageUrl : option.imageUrlLight}
|
||||
/>
|
||||
|
||||
<p
|
||||
className={clsx(
|
||||
'text-sm transition',
|
||||
!isFreePlan && 'group-hover:text-scale-1200',
|
||||
isSelected ? 'text-scale-1200' : 'text-scale-1000'
|
||||
)}
|
||||
>
|
||||
{option.name}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedOption === 'on' ? (
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
title="Your projects could become unresponsive or enter read only mode"
|
||||
>
|
||||
Exceeding the included quota allowance with spend cap enabled can cause your projects
|
||||
to become unresponsive or enter read only mode.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
withIcon
|
||||
variant="info"
|
||||
title="Charges apply for usage beyond included quota allowance"
|
||||
>
|
||||
Your projects will always remain responsive and active, and charges only apply when
|
||||
exceeding the included quota limit.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{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 beyong the included quota.'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
</SidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default SpendCapSidePanel
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
ScaffoldSection,
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldSectionDetail,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
|
||||
import { Badge } from 'ui'
|
||||
|
||||
const CreditBalance = () => {
|
||||
const { slug } = useParams()
|
||||
const { data, error, isLoading, isError, isSuccess } = useOrganizationCustomerProfileQuery({
|
||||
slug,
|
||||
})
|
||||
|
||||
const customerBalance = (data?.balance ?? 0) / 100
|
||||
const isCredit = customerBalance < 0
|
||||
const isDebt = customerBalance > 0
|
||||
const balance =
|
||||
isCredit && customerBalance !== 0
|
||||
? customerBalance.toFixed(2).toString().replace('-', '')
|
||||
: customerBalance.toFixed(2)
|
||||
|
||||
return (
|
||||
<ScaffoldSection>
|
||||
<ScaffoldSectionDetail>
|
||||
<div className="sticky top-16">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-base">Credit balance</p>
|
||||
{isCredit && <Badge>You have credits available</Badge>}
|
||||
{isDebt && <Badge color="red">Outstanding payments</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-scale-1000">
|
||||
Charges will be deducted from your balance first
|
||||
</p>
|
||||
</div>
|
||||
</ScaffoldSectionDetail>
|
||||
<ScaffoldSectionContent>
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<AlertError subject="Unable to retrieve organization customer profile" error={error} />
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<div className="flex items-end space-x-1">
|
||||
{isDebt && <h4 className="opacity-50">-</h4>}
|
||||
<h4 className="opacity-50">$</h4>
|
||||
<h2 className="text-4xl relative top-[2px]">{balance}</h2>
|
||||
{isCredit && <h4 className="opacity-50">/credits</h4>}
|
||||
</div>
|
||||
)}
|
||||
</ScaffoldSectionContent>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreditBalance
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { useParams } from 'common'
|
||||
import { OrganizationPaymentMethod } from 'data/organizations/organization-payment-methods-query'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import {
|
||||
SubscriptionTier,
|
||||
useOrgSubscriptionUpdateMutation,
|
||||
} from 'data/subscriptions/org-subscription-update-mutation'
|
||||
import { useStore } from 'hooks'
|
||||
import { Button, Modal } from 'ui'
|
||||
|
||||
export interface ChangePaymentMethodModalProps {
|
||||
selectedPaymentMethod?: OrganizationPaymentMethod
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ChangePaymentMethodModal = ({
|
||||
selectedPaymentMethod,
|
||||
onClose,
|
||||
}: ChangePaymentMethodModalProps) => {
|
||||
const { ui } = useStore()
|
||||
const { slug } = useParams()
|
||||
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug })
|
||||
const { mutateAsync: updateOrgSubscription, isLoading: isUpdating } =
|
||||
useOrgSubscriptionUpdateMutation()
|
||||
|
||||
const onConfirmUpdate = async () => {
|
||||
if (!slug) return console.error('Slug is required')
|
||||
if (!subscription) return console.error('Subscription is required')
|
||||
if (!selectedPaymentMethod) return console.error('Card ID is required')
|
||||
|
||||
const selectedTier =
|
||||
subscription.plan.id === 'pro' && subscription.usage_billing_enabled
|
||||
? 'tier_payg'
|
||||
: `tier_${subscription.plan.id}`
|
||||
|
||||
try {
|
||||
await updateOrgSubscription({
|
||||
slug,
|
||||
tier: selectedTier as SubscriptionTier,
|
||||
paymentMethod: selectedPaymentMethod.id,
|
||||
})
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully removed payment method ending with ${selectedPaymentMethod.card.last4}`,
|
||||
})
|
||||
onClose()
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to change payment method: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={selectedPaymentMethod !== undefined}
|
||||
size="medium"
|
||||
header={`Confirm to use payment method ending with ${selectedPaymentMethod?.card.last4}`}
|
||||
onCancel={() => onClose()}
|
||||
customFooter={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="default" disabled={isUpdating} onClick={() => onClose()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={isUpdating}
|
||||
loading={isUpdating}
|
||||
onClick={onConfirmUpdate}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="py-4">
|
||||
<Modal.Content>
|
||||
<p className="text-sm">
|
||||
Upon clicking confirm, all future charges will be deducted from the card ending with{' '}
|
||||
{selectedPaymentMethod?.card.last4}. There are no immediate charges.
|
||||
</p>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChangePaymentMethodModal
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { useParams } from 'common'
|
||||
import { useOrganizationPaymentMethodDeleteMutation } from 'data/organizations/organization-payment-method-delete-mutation'
|
||||
import { OrganizationPaymentMethod } from 'data/organizations/organization-payment-methods-query'
|
||||
import { useStore } from 'hooks'
|
||||
import { Alert, Button, Modal } from 'ui'
|
||||
|
||||
export interface DeletePaymentMethodModalProps {
|
||||
selectedPaymentMethod?: OrganizationPaymentMethod
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const DeletePaymentMethodModal = ({
|
||||
selectedPaymentMethod,
|
||||
onClose,
|
||||
}: DeletePaymentMethodModalProps) => {
|
||||
const { ui } = useStore()
|
||||
const { slug } = useParams()
|
||||
const { mutateAsync: deletePayment, isLoading: isDeleting } =
|
||||
useOrganizationPaymentMethodDeleteMutation()
|
||||
|
||||
const onConfirmDelete = async () => {
|
||||
if (!slug) return console.error('Slug is required')
|
||||
if (!selectedPaymentMethod) return console.error('Card ID is required')
|
||||
|
||||
try {
|
||||
await deletePayment({ slug, cardId: selectedPaymentMethod.id })
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully removed payment method ending with ${selectedPaymentMethod.card.last4}`,
|
||||
})
|
||||
onClose()
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to delete payment method: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={selectedPaymentMethod !== undefined}
|
||||
size="medium"
|
||||
header={`Confirm to delete payment method ending with ${selectedPaymentMethod?.card.last4}`}
|
||||
onCancel={() => onClose()}
|
||||
customFooter={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="default" disabled={isDeleting} onClick={() => onClose()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={isDeleting}
|
||||
loading={isDeleting}
|
||||
onClick={onConfirmDelete}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="py-4">
|
||||
<Modal.Content>
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
title="This will permanently delete your payment method."
|
||||
>
|
||||
<p className="">
|
||||
Any subscription currently charging this payment method will start charging your
|
||||
default payment method.
|
||||
</p>
|
||||
</Alert>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeletePaymentMethodModal
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { AddNewPaymentMethodModal } from 'components/interfaces/Billing'
|
||||
import {
|
||||
ScaffoldSection,
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldSectionDetail,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { organizationKeys } from 'data/organizations/keys'
|
||||
import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { getURL } from 'lib/helpers'
|
||||
import { Badge, Button, Dropdown, IconCreditCard, IconMoreHorizontal, IconPlus } from 'ui'
|
||||
import ChangePaymentMethodModal from './ChangePaymentMethodModal'
|
||||
import DeletePaymentMethodModal from './DeletePaymentMethodModal'
|
||||
|
||||
const PaymentMethods = () => {
|
||||
const { ui } = useStore()
|
||||
const { slug } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedMethodForUse, setSelectedMethodForUse] = useState<any>()
|
||||
const [selectedMethodToDelete, setSelectedMethodToDelete] = useState<any>()
|
||||
const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false)
|
||||
|
||||
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug })
|
||||
const {
|
||||
data: paymentMethods,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useOrganizationPaymentMethodsQuery({ slug })
|
||||
const sortedPaymentMethods =
|
||||
paymentMethods?.sort((a, b) => (a.id === subscription?.payment_method_id ? -1 : 0)) ?? []
|
||||
|
||||
const canReadPaymentMethods = useCheckPermissions(
|
||||
PermissionAction.BILLING_READ,
|
||||
'stripe.payment_methods'
|
||||
)
|
||||
const canUpdatePaymentMethods = useCheckPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
'stripe.payment_methods'
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection>
|
||||
<ScaffoldSectionDetail>
|
||||
<div className="sticky space-y-6 top-16">
|
||||
<div>
|
||||
<p className="text-base">Payment methods</p>
|
||||
<p className="text-sm text-scale-1000 mb-2">
|
||||
This copy might need to change, when adding a new payment method, either remove the
|
||||
old one or go to your projects' subscription to explicitly update the payment
|
||||
method.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScaffoldSectionDetail>
|
||||
<ScaffoldSectionContent>
|
||||
{!canReadPaymentMethods ? (
|
||||
<NoPermission resourceText="view this organization's payment methods" />
|
||||
) : (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<AlertError subject="Unable to retrieve payment methods" error={error as any} />
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<FormPanel
|
||||
footer={
|
||||
<div className="flex items-center justify-between py-4 px-8">
|
||||
{!canUpdatePaymentMethods ? (
|
||||
<p className="text-sm text-scale-1000">
|
||||
You need additional permissions to manage payment methods
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconPlus />}
|
||||
disabled={!canUpdatePaymentMethods}
|
||||
onClick={() => setShowAddPaymentMethodModal(true)}
|
||||
>
|
||||
Add new card
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormSection>
|
||||
<FormSectionContent fullWidth loading={false}>
|
||||
{(paymentMethods?.length ?? 0) === 0 ? (
|
||||
<div className="flex items-center space-x-2 opacity-50">
|
||||
<IconCreditCard />
|
||||
<p className="text-sm">No payment methods</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedPaymentMethods?.map((paymentMethod) => {
|
||||
const isActive = subscription?.payment_method_id === paymentMethod.id
|
||||
return (
|
||||
<div
|
||||
key={paymentMethod.id}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center space-x-8">
|
||||
<img
|
||||
alt="Credit card brand"
|
||||
src={`${BASE_PATH}/img/payment-methods/${paymentMethod.card.brand
|
||||
.replace(' ', '-')
|
||||
.toLowerCase()}.png`}
|
||||
width="32"
|
||||
/>
|
||||
<p className="prose text-sm font-mono">
|
||||
**** **** **** {paymentMethod.card.last4}
|
||||
</p>
|
||||
<p className="text-sm tabular-nums">
|
||||
Expires: {paymentMethod.card.exp_month}/
|
||||
{paymentMethod.card.exp_year}
|
||||
</p>
|
||||
</div>
|
||||
{isActive && <Badge color="green">Active</Badge>}
|
||||
{canUpdatePaymentMethods && !isActive ? (
|
||||
<Dropdown
|
||||
size="tiny"
|
||||
align="end"
|
||||
overlay={[
|
||||
<Dropdown.Item
|
||||
key="make-default"
|
||||
onClick={() => setSelectedMethodForUse(paymentMethod)}
|
||||
>
|
||||
Use this card
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Separator key="card-separator" />,
|
||||
<Dropdown.Item
|
||||
key="delete-method"
|
||||
onClick={() => setSelectedMethodToDelete(paymentMethod)}
|
||||
>
|
||||
Delete card
|
||||
</Dropdown.Item>,
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type="outline"
|
||||
icon={<IconMoreHorizontal />}
|
||||
className="hover:border-gray-500 px-1"
|
||||
/>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScaffoldSectionContent>
|
||||
</ScaffoldSection>
|
||||
|
||||
<AddNewPaymentMethodModal
|
||||
visible={showAddPaymentMethodModal}
|
||||
returnUrl={`${getURL()}/org/${slug}/billing`}
|
||||
onCancel={() => setShowAddPaymentMethodModal(false)}
|
||||
onConfirm={async () => {
|
||||
setShowAddPaymentMethodModal(false)
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: 'Successfully added new payment method',
|
||||
})
|
||||
await queryClient.invalidateQueries(organizationKeys.paymentMethods(slug))
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChangePaymentMethodModal
|
||||
selectedPaymentMethod={selectedMethodForUse}
|
||||
onClose={() => setSelectedMethodForUse(undefined)}
|
||||
/>
|
||||
|
||||
<DeletePaymentMethodModal
|
||||
selectedPaymentMethod={selectedMethodToDelete}
|
||||
onClose={() => setSelectedMethodToDelete(undefined)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentMethods
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import clsx from 'clsx'
|
||||
import { Button, IconCheck } from 'ui'
|
||||
import { PricingInformation } from 'shared-data'
|
||||
|
||||
export interface EnterpriseCardProps {
|
||||
plan: PricingInformation
|
||||
isCurrentPlan: boolean
|
||||
}
|
||||
|
||||
const EnterpriseCard = ({ plan, isCurrentPlan }: EnterpriseCardProps) => {
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={clsx(
|
||||
'grid grid-cols-1 md:grid-cols-3 border rounded-md bg-scale-200',
|
||||
'py-4 col-span-12 justify-between gap-x-8'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col justify-center px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className={clsx('text-brand-900 text-sm uppercase')}>{plan.name}</p>
|
||||
{isCurrentPlan ? (
|
||||
<div className="text-xs bg-scale-500 text-scale-1000 rounded px-2 py-0.5">
|
||||
Current plan
|
||||
</div>
|
||||
) : plan.nameBadge ? (
|
||||
<div className="text-xs bg-brand-400 text-brand-900 rounded px-2 py-0.5">
|
||||
{plan.nameBadge}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-sm mt-2 mb-4">{plan.description}</p>
|
||||
|
||||
<a href={plan.href} className="hidden md:block" target="_blank">
|
||||
<Button block type="default" size="tiny">
|
||||
{plan.cta}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center col-span-2 px-4 md:px-0">
|
||||
<ul role="list" className="text-xs text-scale-1000 md:grid md:grid-cols-2 md:gap-x-10">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center py-2 first:mt-0">
|
||||
<IconCheck className="text-brand-900 h-4 w-4" aria-hidden="true" strokeWidth={3} />
|
||||
<span className="dark:text-scale-1200 mb-0 ml-3 ">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a href={plan.href} className="visible md:hidden mt-8" target="_blank">
|
||||
<Button block type="default" size="tiny">
|
||||
{plan.cta}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnterpriseCard
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { includes, without } from 'lodash'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useReducer, useRef, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { setProjectStatus } from 'data/projects/projects-query'
|
||||
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
|
||||
import { useProjectSubscriptionUpdateMutation } from 'data/subscriptions/project-subscription-update-mutation'
|
||||
import { useFlag, useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import { Button, Input, Modal } from 'ui'
|
||||
import { CANCELLATION_REASONS } from '../BillingSettings.constants'
|
||||
import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip'
|
||||
|
||||
export interface ExitSurveyModalProps {
|
||||
visible: boolean
|
||||
onClose: (success?: boolean) => void
|
||||
}
|
||||
|
||||
// [Joshen] For context - Exit survey is only when going to free plan from a paid plan
|
||||
// [Joshen TODO] Remove all contexts of projects - i'm presuming we still want the exit survey
|
||||
|
||||
const ExitSurveyModal = ({ visible, onClose }: ExitSurveyModalProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { ui } = useStore()
|
||||
const { ref: projectRef } = useParams()
|
||||
const captchaRef = useRef<HCaptcha>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [selectedReasons, dispatchSelectedReasons] = useReducer(reducer, [])
|
||||
|
||||
const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
|
||||
const { data: addons } = useProjectAddonsQuery({ projectRef })
|
||||
const { mutateAsync: updateSubscriptionTier } = useProjectSubscriptionUpdateMutation()
|
||||
|
||||
const subscriptionAddons = addons?.selected_addons ?? []
|
||||
const hasComputeInstance = subscriptionAddons.find((addon) => addon.type === 'compute_instance')
|
||||
|
||||
function reducer(state: any, action: any) {
|
||||
if (includes(state, action.target.value)) {
|
||||
return without(state, action.target.value)
|
||||
} else {
|
||||
return [...state, action.target.value]
|
||||
}
|
||||
}
|
||||
|
||||
const resetCaptcha = () => {
|
||||
setCaptchaToken(null)
|
||||
captchaRef.current?.resetCaptcha()
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (selectedReasons.length === 0) {
|
||||
return ui.setNotification({
|
||||
category: 'error',
|
||||
message: 'Please select at least one reason for canceling your subscription',
|
||||
})
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
let token = captchaToken
|
||||
|
||||
try {
|
||||
if (!token) {
|
||||
const captchaResponse = await captchaRef.current?.execute({ async: true })
|
||||
token = captchaResponse?.response ?? null
|
||||
await downgradeProject()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to downgrade project: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downgradeProject = async (values?: any) => {
|
||||
// Update the subscription first, followed by posting the exit survey if successful
|
||||
// If compute instance is present within the existing subscription, then a restart will be triggered
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
|
||||
try {
|
||||
await updateSubscriptionTier({ projectRef, tier: 'tier_free' })
|
||||
resetCaptcha()
|
||||
} catch (error: any) {
|
||||
return ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to cancel subscription: ${error.message}`,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const feedbackRes = await post(`${API_URL}/feedback/downgrade`, {
|
||||
projectRef,
|
||||
reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''),
|
||||
additionalFeedback: message,
|
||||
exitAction: 'downgrade',
|
||||
})
|
||||
if (feedbackRes.error) throw feedbackRes.error
|
||||
} catch (error: any) {
|
||||
return ui.setNotification({
|
||||
error,
|
||||
category: 'info',
|
||||
message: `Failed to submit exit survey: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
duration: hasComputeInstance ? 8000 : 4000,
|
||||
message: hasComputeInstance
|
||||
? 'Your project has been downgraded and is currently restarting to update its instance size'
|
||||
: 'Successfully downgraded project to the free plan',
|
||||
})
|
||||
if (hasComputeInstance) {
|
||||
setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING)
|
||||
router.push(`/project/${projectRef}`)
|
||||
}
|
||||
onClose(true)
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="self-center">
|
||||
<HCaptcha
|
||||
ref={captchaRef}
|
||||
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
|
||||
size="invisible"
|
||||
onVerify={(token) => {
|
||||
setCaptchaToken(token)
|
||||
if (document !== undefined) document.body.classList.remove('!pointer-events-auto')
|
||||
}}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
onOpen={() => {
|
||||
// [Joshen] This is to ensure that hCaptcha popup remains clickable
|
||||
if (document !== undefined) document.body.classList.add('!pointer-events-auto')
|
||||
}}
|
||||
onClose={() => {
|
||||
if (document !== undefined) document.body.classList.remove('!pointer-events-auto')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
hideFooter
|
||||
size="xlarge"
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
header="We're sad that you're leaving"
|
||||
>
|
||||
<Modal.Content>
|
||||
<div className="py-6">
|
||||
<p className="text-sm text-scale-1100">
|
||||
We always strive to improve Supabase as much as we can. Please let us know the reasons
|
||||
you are canceling your subscription so that we can improve in the future.
|
||||
</p>
|
||||
<div className="space-y-8 mt-6">
|
||||
<div className="flex flex-wrap gap-2" data-toggle="buttons">
|
||||
{CANCELLATION_REASONS.map((option) => {
|
||||
const active = selectedReasons.find((x) => x === option)
|
||||
return (
|
||||
<label
|
||||
key={option}
|
||||
className={`
|
||||
flex cursor-pointer items-center space-x-2 rounded-md py-1
|
||||
pl-2 pr-3 text-center text-sm
|
||||
shadow-sm transition-all duration-100
|
||||
${
|
||||
active
|
||||
? ` bg-scale-1200 text-scale-100 opacity-100 hover:bg-opacity-75`
|
||||
: ` bg-scale-700 text-scale-1200 opacity-25 hover:opacity-50`
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="options"
|
||||
value={option}
|
||||
className="hidden"
|
||||
onClick={dispatchSelectedReasons}
|
||||
/>
|
||||
<div>{option}</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-area-text-sm">
|
||||
<Input.TextArea
|
||||
id="message"
|
||||
name="message"
|
||||
value={message}
|
||||
onChange={(event: any) => setMessage(event.target.value)}
|
||||
label="Anything else that we can improve on?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
|
||||
<div className="flex items-center justify-between border-t px-4 py-4">
|
||||
<p className="text-xs text-scale-900">
|
||||
The unused amount for the remaining of your billing cycle will be refunded as credits
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button type="default" onClick={() => onClose()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<ProjectUpdateDisabledTooltip projectUpdateDisabled={projectUpdateDisabled}>
|
||||
<Button
|
||||
type="danger"
|
||||
loading={isSubmitting}
|
||||
disabled={projectUpdateDisabled || isSubmitting}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Confirm downgrade
|
||||
</Button>
|
||||
</ProjectUpdateDisabledTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExitSurveyModal
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query'
|
||||
import { useSelectedOrganization } from 'hooks'
|
||||
import { Button, Modal } from 'ui'
|
||||
|
||||
// [Joshen TODO] Check if we still need this?
|
||||
|
||||
export interface MembersExceedLimitModalProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const MembersExceedLimitModal = ({ visible, onClose }: MembersExceedLimitModalProps) => {
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const slug = selectedOrganization?.slug
|
||||
const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug })
|
||||
|
||||
return (
|
||||
<Modal
|
||||
hideFooter
|
||||
visible={visible}
|
||||
size="medium"
|
||||
header="Your organization has members who have exceeded their free project limits"
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="py-4 space-y-4">
|
||||
<Modal.Content>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-scale-1100">
|
||||
The following members have reached their maximum limits for the number of active free
|
||||
plan projects within organizations where they are an administrator or owner:
|
||||
</p>
|
||||
<ul className="pl-5 text-sm list-disc text-scale-1100">
|
||||
{(membersExceededLimit || []).map((member, idx: number) => (
|
||||
<li key={`member-${idx}`}>
|
||||
{member.username || member.primary_email} (Limit: {member.free_project_limit} free
|
||||
projects)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-scale-1100">
|
||||
These members will need to either delete, pause, or upgrade one or more of these
|
||||
projects before you're able to downgrade this project.
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button htmlType="button" type="default" onClick={onClose} block>
|
||||
Understood
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MembersExceedLimitModal
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
|
||||
import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query'
|
||||
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { getURL } from 'lib/helpers'
|
||||
import { Button, IconAlertCircle, IconCreditCard, IconLoader, IconPlus, Listbox } from 'ui'
|
||||
import { AddNewPaymentMethodModal } from 'components/interfaces/Billing'
|
||||
|
||||
export interface PaymentMethodSelectionProps {
|
||||
selectedPaymentMethod?: string
|
||||
onSelectPaymentMethod: (id: string) => void
|
||||
}
|
||||
|
||||
const PaymentMethodSelection = ({
|
||||
selectedPaymentMethod,
|
||||
onSelectPaymentMethod,
|
||||
}: PaymentMethodSelectionProps) => {
|
||||
const { ui } = useStore()
|
||||
const { ref: projectRef } = useParams()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const slug = selectedOrganization?.slug
|
||||
const [showAddNewPaymentMethodModal, setShowAddNewPaymentMethodModal] = useState(false)
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isSuccess: loadedPaymentMethods,
|
||||
refetch: refetchPaymentMethods,
|
||||
} = useOrganizationPaymentMethodsQuery({ slug })
|
||||
const paymentMethods = useMemo(() => data ?? [], [data])
|
||||
|
||||
const { data: customerProfile, isSuccess: loadedCustomerProfile } =
|
||||
useOrganizationCustomerProfileQuery({ slug })
|
||||
|
||||
const canUpdatePaymentMethods = useCheckPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
'stripe.payment_methods'
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedPaymentMethods && loadedCustomerProfile && paymentMethods.length > 0) {
|
||||
const selectedPaymentMethodExists = paymentMethods.some(
|
||||
(it) => it.id === selectedPaymentMethod
|
||||
)
|
||||
|
||||
if (!selectedPaymentMethod || !selectedPaymentMethodExists) {
|
||||
const defaultPaymentMethod = paymentMethods.find(
|
||||
(method) => method.id === customerProfile.invoice_settings.default_payment_method
|
||||
)
|
||||
if (defaultPaymentMethod !== undefined) {
|
||||
onSelectPaymentMethod(defaultPaymentMethod.id)
|
||||
} else {
|
||||
onSelectPaymentMethod(paymentMethods[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
loadedPaymentMethods,
|
||||
loadedCustomerProfile,
|
||||
selectedPaymentMethod,
|
||||
customerProfile,
|
||||
paymentMethods,
|
||||
onSelectPaymentMethod,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Select payment method</p>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center px-4 py-2 space-x-4 border rounded-md border-scale-700 bg-scale-400">
|
||||
<IconLoader className="animate-spin" size={14} />
|
||||
<p className="text-sm text-scale-1100">Retrieving payment methods</p>
|
||||
</div>
|
||||
) : paymentMethods.length === 0 ? (
|
||||
<div className="flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-scale-100">
|
||||
<div className="flex items-center space-x-4 text-scale-1100">
|
||||
<IconAlertCircle size={16} strokeWidth={1.5} />
|
||||
<p className="text-sm">No saved payment methods</p>
|
||||
</div>
|
||||
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={!canUpdatePaymentMethods}
|
||||
icon={<IconCreditCard />}
|
||||
onClick={() => setShowAddNewPaymentMethodModal(true)}
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdatePaymentMethods && (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow', // background
|
||||
'w-48 border border-scale-200 text-center', //border
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to add new payment methods to this
|
||||
organization
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
) : (
|
||||
<Listbox value={selectedPaymentMethod} onChange={onSelectPaymentMethod}>
|
||||
{paymentMethods.map((method: any) => {
|
||||
const label = `•••• •••• •••• ${method.card.last4}`
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={method.id}
|
||||
label={label}
|
||||
value={method.id}
|
||||
addOnBefore={() => {
|
||||
return (
|
||||
<img
|
||||
alt="Credit Card Brand"
|
||||
src={`${BASE_PATH}/img/payment-methods/${method.card.brand
|
||||
.replace(' ', '-')
|
||||
.toLowerCase()}.png`}
|
||||
width="32"
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div>{label}</div>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
className="flex items-center px-3 py-2 space-x-2 transition cursor-pointer group hover:bg-scale-500"
|
||||
onClick={() => setShowAddNewPaymentMethodModal(true)}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
<p className="transition text-scale-1000 group-hover:text-scale-1200">
|
||||
Add new payment method
|
||||
</p>
|
||||
</div>
|
||||
</Listbox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddNewPaymentMethodModal
|
||||
visible={showAddNewPaymentMethodModal}
|
||||
returnUrl={`${getURL()}/project/${projectRef}/settings/billing/subscription?panel=subscriptionPlan`}
|
||||
onCancel={() => setShowAddNewPaymentMethodModal(false)}
|
||||
onConfirm={async () => {
|
||||
setShowAddNewPaymentMethodModal(false)
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: 'Successfully added new payment method',
|
||||
})
|
||||
const { data } = await refetchPaymentMethods()
|
||||
if (data?.length) {
|
||||
// Preselect the card that was just added
|
||||
const mostRecentPaymentMethod = data.reduce(
|
||||
(prev, current) => (prev.created > current.created ? prev : current),
|
||||
data[0]
|
||||
)
|
||||
onSelectPaymentMethod(mostRecentPaymentMethod.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentMethodSelection
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useOrgPlansQuery } from 'data/subscriptions/org-plans-query'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
|
||||
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
|
||||
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
|
||||
import Telemetry from 'lib/telemetry'
|
||||
import { useRouter } from 'next/router'
|
||||
import { plans as subscriptionsPlans } from 'shared-data/plans'
|
||||
import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
|
||||
import { Alert, Button, IconCheck, IconExternalLink, Modal, SidePanel } from 'ui'
|
||||
import EnterpriseCard from './EnterpriseCard'
|
||||
import ExitSurveyModal from './ExitSurveyModal'
|
||||
import MembersExceedLimitModal from './MembersExceedLimitModal'
|
||||
import PaymentMethodSelection from './PaymentMethodSelection'
|
||||
|
||||
// [Joshen TODO] Need to remove all contexts of "projects"
|
||||
|
||||
const PlanUpdateSidePanel = () => {
|
||||
const { ui } = useStore()
|
||||
const router = useRouter()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const slug = selectedOrganization?.slug
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showExitSurvey, setShowExitSurvey] = useState(false)
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>()
|
||||
const [showDowngradeError, setShowDowngradeError] = useState(false)
|
||||
const [selectedTier, setSelectedTier] = useState<'tier_free' | 'tier_pro' | 'tier_team'>()
|
||||
|
||||
const canUpdateSubscription = useCheckPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
'stripe.subscriptions'
|
||||
)
|
||||
|
||||
const snap = useOrgSettingsPageStateSnapshot()
|
||||
const visible = snap.panelKey === 'subscriptionPlan'
|
||||
const onClose = () => snap.setPanelKey(undefined)
|
||||
|
||||
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug })
|
||||
const { data: plans, isLoading: isLoadingPlans } = useOrgPlansQuery({ orgSlug: slug })
|
||||
const { mutateAsync: updateOrgSubscription } = useOrgSubscriptionUpdateMutation()
|
||||
// const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug }) // [Joshen] Still need?
|
||||
|
||||
const availablePlans = plans ?? []
|
||||
const subscriptionAddons: any[] = [] // [Joshen] Still need?
|
||||
|
||||
// const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0
|
||||
const hasMembersExceedingFreeTierLimit = false // [Joshen] Still need?
|
||||
const selectedTierMeta = subscriptionsPlans.find((tier) => tier.id === selectedTier)
|
||||
const selectedPlanMeta = availablePlans.find(
|
||||
(plan) => plan.id === selectedTier?.split('tier_')[1]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedTier(undefined)
|
||||
Telemetry.sendActivity(
|
||||
{
|
||||
activity: 'Side Panel Viewed',
|
||||
source: 'Dashboard',
|
||||
data: {
|
||||
title: 'Change Subscription Plan',
|
||||
section: 'Subscription plan',
|
||||
},
|
||||
},
|
||||
router
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible])
|
||||
|
||||
const onConfirmDowngrade = () => {
|
||||
setSelectedTier(undefined)
|
||||
if (hasMembersExceedingFreeTierLimit) {
|
||||
setShowDowngradeError(true)
|
||||
} else {
|
||||
setShowExitSurvey(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateSubscription = async () => {
|
||||
if (!slug) return console.error('org slug is required')
|
||||
if (!selectedTier) return console.error('Selected plan is required')
|
||||
if (!selectedPaymentMethod) {
|
||||
return ui.setNotification({ category: 'error', message: 'Please select a payment method' })
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
await updateOrgSubscription({
|
||||
slug,
|
||||
tier: selectedTier,
|
||||
paymentMethod: selectedPaymentMethod,
|
||||
})
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully updated subscription to ${selectedTierMeta?.name}!`,
|
||||
})
|
||||
setSelectedTier(undefined)
|
||||
onClose()
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Unable to update subscription: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidePanel
|
||||
hideFooter
|
||||
size="xxlarge"
|
||||
visible={visible}
|
||||
onCancel={() => onClose()}
|
||||
header={
|
||||
<div className="flex items-center justify-between">
|
||||
<h4>Change subscription plan</h4>
|
||||
<Link href="https://supabase.com/pricing">
|
||||
<a target="_blank" rel="noreferrer">
|
||||
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
|
||||
Pricing
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="py-6 grid grid-cols-12 gap-3">
|
||||
{subscriptionsPlans.map((plan) => {
|
||||
const planMeta = availablePlans.find((p) => p.id === plan.id.split('tier_')[1])
|
||||
const tierMeta = subscriptionsPlans.find((it) => it.id === plan.id)
|
||||
const price = planMeta?.price ?? 0
|
||||
const isDowngradeOption = planMeta?.change_type === 'downgrade'
|
||||
const isCurrentPlan = planMeta?.id === subscription?.plan?.id
|
||||
|
||||
if (plan.id === 'tier_enterprise') {
|
||||
return <EnterpriseCard key={plan.id} plan={plan} isCurrentPlan={isCurrentPlan} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={clsx(
|
||||
'border rounded-md px-4 py-4 flex flex-col items-start justify-between',
|
||||
plan.id === 'tier_enterprise' ? 'col-span-12' : 'col-span-12 md:col-span-4',
|
||||
plan.id === 'tier_enterprise' ? 'bg-scale-200' : 'bg-scale-300'
|
||||
)}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className={clsx('text-brand-900 text-sm uppercase')}>{plan.name}</p>
|
||||
{isCurrentPlan ? (
|
||||
<div className="text-xs bg-scale-500 text-scale-1000 rounded px-2 py-0.5">
|
||||
Current plan
|
||||
</div>
|
||||
) : plan.nameBadge ? (
|
||||
<div className="text-xs bg-brand-400 text-brand-900 rounded px-2 py-0.5">
|
||||
{plan.nameBadge}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center space-x-1">
|
||||
{(price ?? 0) > 0 && <p className="text-scale-1000 text-sm">From</p>}
|
||||
{isLoadingPlans ? (
|
||||
<div className="h-[28px] flex items-center justify-center">
|
||||
<ShimmeringLoader className="w-[30px] h-[24px]" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-scale-1200 text-lg">${price}</p>
|
||||
)}
|
||||
<p className="text-scale-1000 text-sm">{tierMeta?.costUnit}</p>
|
||||
</div>
|
||||
<div className={clsx('flex mt-1 mb-4', !tierMeta?.warning && 'opacity-0')}>
|
||||
<div className="bg-scale-200 text-brand-1100 border shadow-sm rounded-md bg-opacity-30 py-0.5 px-2 text-xs">
|
||||
{tierMeta?.warning}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentPlan ? (
|
||||
<Button block disabled type="default">
|
||||
Current plan
|
||||
</Button>
|
||||
) : plan.id !== PRICING_TIER_PRODUCT_IDS.TEAM ? (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
block
|
||||
disabled={
|
||||
// no self-serve downgrades from team plan right now
|
||||
(plan.id !== PRICING_TIER_PRODUCT_IDS.TEAM &&
|
||||
['team', 'enterprise'].includes(subscription?.plan?.id || '')) ||
|
||||
!canUpdateSubscription
|
||||
}
|
||||
type={isDowngradeOption ? 'default' : 'primary'}
|
||||
onClick={() => {
|
||||
setSelectedTier(plan.id as any)
|
||||
Telemetry.sendActivity(
|
||||
{
|
||||
activity: 'Popup Viewed',
|
||||
source: 'Dashboard',
|
||||
data: {
|
||||
title: isDowngradeOption
|
||||
? 'Downgrade'
|
||||
: 'Upgrade' + ' to ' + plan.name,
|
||||
section: 'Subscription plan',
|
||||
},
|
||||
},
|
||||
router
|
||||
)
|
||||
}}
|
||||
>
|
||||
{isDowngradeOption ? 'Downgrade' : 'Upgrade'} to {plan.name}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateSubscription ? (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You do not have permission to change the subscription plan.
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
) : null}
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
<Link href={plan.href} passHref className="hidden md:block">
|
||||
<a target="_blank">
|
||||
<Button block type="primary">
|
||||
Contact Us
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="border-t my-6" />
|
||||
|
||||
<ul role="list">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex py-2">
|
||||
<div className="w-[12px]">
|
||||
<IconCheck
|
||||
className="h-3 w-3 text-brand-900 translate-y-[2.5px]"
|
||||
aria-hidden="true"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</div>
|
||||
<p className="ml-3 text-xs text-scale-1100">{feature}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{plan.footer && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<p className="text-scale-1000 text-xs">{plan.footer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
</SidePanel>
|
||||
|
||||
<Modal
|
||||
size="medium"
|
||||
alignFooter="right"
|
||||
visible={selectedTier === 'tier_free'}
|
||||
onCancel={() => setSelectedTier(undefined)}
|
||||
onConfirm={onConfirmDowngrade}
|
||||
header={`Confirm to downgrade to ${selectedTierMeta?.name}`}
|
||||
>
|
||||
{/* [JOSHEN] We could make this better by only showing a danger warning if the project is already above the free plan limits */}
|
||||
<Modal.Content>
|
||||
<div className="py-6">
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
title="Downgrading to the free plan will lead to reductions in your project's capacity"
|
||||
>
|
||||
<p>
|
||||
If you're already past the limits of the free plan, your project could become
|
||||
unresponsive or enter read only mode.
|
||||
</p>
|
||||
{subscriptionAddons.length > 0 && (
|
||||
<>
|
||||
<p className="mt-2">
|
||||
Your project's add ons will also be removed, which includes:
|
||||
</p>
|
||||
<ul className="list-disc pl-6">
|
||||
{subscriptionAddons.map((addon) => (
|
||||
<li key={addon.type} className="mt-0.5">
|
||||
{addon.variant.name}{' '}
|
||||
{addon.type === 'compute_instance'
|
||||
? 'compute instance'
|
||||
: addon.type === 'pitr'
|
||||
? 'PITR'
|
||||
: ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
loading={isSubmitting}
|
||||
alignFooter="right"
|
||||
className="!w-[450px]"
|
||||
visible={selectedTier !== undefined && selectedTier !== 'tier_free'}
|
||||
onCancel={() => setSelectedTier(undefined)}
|
||||
onConfirm={onUpdateSubscription}
|
||||
overlayClassName="pointer-events-none"
|
||||
header={`Confirm to upgrade to ${selectedTierMeta?.name}`}
|
||||
>
|
||||
<Modal.Content>
|
||||
<div className="py-6 space-y-2">
|
||||
<p className="text-sm">
|
||||
Upon clicking confirm, the amount of ${selectedPlanMeta?.price ?? 'Unknown'} will be
|
||||
added to your monthly invoice and your credit card will be charged immediately.
|
||||
Changing the plan resets your billing cycle and may result in a prorated charge for
|
||||
previous usage.
|
||||
</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
You will also be able to change your project's add-ons after upgrading your project's
|
||||
plan.
|
||||
</p>
|
||||
<div className="!mt-6">
|
||||
<PaymentMethodSelection
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={setSelectedPaymentMethod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
|
||||
<MembersExceedLimitModal
|
||||
visible={showDowngradeError}
|
||||
onClose={() => setShowDowngradeError(false)}
|
||||
/>
|
||||
|
||||
<ExitSurveyModal
|
||||
visible={showExitSurvey}
|
||||
onClose={(success?: boolean) => {
|
||||
setShowExitSurvey(false)
|
||||
if (success) onClose()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlanUpdateSidePanel
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
ScaffoldSection,
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldSectionDetail,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import SparkBar from 'components/ui/SparkBar'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useFlag } from 'hooks'
|
||||
import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
|
||||
import { Alert, Button, IconExternalLink } from 'ui'
|
||||
import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip'
|
||||
import PlanUpdateSidePanel from './PlanUpdateSidePanel'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
|
||||
const Subscription = () => {
|
||||
const { slug } = useParams()
|
||||
const snap = useOrgSettingsPageStateSnapshot()
|
||||
const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
|
||||
|
||||
const {
|
||||
data: subscription,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useOrgSubscriptionQuery({ orgSlug: slug })
|
||||
|
||||
const currentPlan = subscription?.plan
|
||||
const planName = currentPlan?.name ?? 'Unknown'
|
||||
const billingCycleStart = dayjs.unix(subscription?.current_period_start ?? 0).utc()
|
||||
const billingCycleEnd = dayjs.unix(subscription?.current_period_end ?? 0).utc()
|
||||
const daysToCycleEnd = billingCycleEnd.diff(dayjs(), 'days')
|
||||
const daysWithinCycle = billingCycleEnd.diff(billingCycleStart, 'days')
|
||||
|
||||
const canChangeTier =
|
||||
!projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id ?? '')
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection>
|
||||
<ScaffoldSectionDetail>
|
||||
<div className="sticky space-y-6 top-16">
|
||||
<p className="text-base">Subscription Plan</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-scale-1100">More information</p>
|
||||
<div>
|
||||
<Link href="https://supabase.com/pricing">
|
||||
<a target="_blank" rel="noreferrer">
|
||||
<div className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition">
|
||||
<p className="text-sm">Pricing</p>
|
||||
<IconExternalLink size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaffoldSectionDetail>
|
||||
<ScaffoldSectionContent>
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <AlertError subject="Failed to retrieve subscription" error={error} />}
|
||||
|
||||
{isSuccess && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-sm">This organization is currently on the plan:</p>
|
||||
<p className="text-2xl text-brand-900 uppercase">
|
||||
{currentPlan?.name ?? 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ProjectUpdateDisabledTooltip projectUpdateDisabled={projectUpdateDisabled}>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={!canChangeTier}
|
||||
onClick={() => snap.setPanelKey('subscriptionPlan')}
|
||||
>
|
||||
Change subscription plan
|
||||
</Button>
|
||||
</ProjectUpdateDisabledTooltip>
|
||||
{!canChangeTier &&
|
||||
(projectUpdateDisabled ? (
|
||||
<Alert withIcon variant="info" title={`Unable to update plan from ${planName}`}>
|
||||
We have temporarily disabled project and subscription changes - our engineers
|
||||
are working on a fix.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
withIcon
|
||||
variant="info"
|
||||
title={`Unable to update plan from ${planName}`}
|
||||
actions={[
|
||||
<div key="contact-support">
|
||||
<Link
|
||||
href={`/support/new?category=sales&subject=Change%20plan%20away%20from%20${planName}`}
|
||||
>
|
||||
<a>
|
||||
<Button type="default">Contact support</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>,
|
||||
]}
|
||||
>
|
||||
Please contact us if you'd like to change your plan.
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!subscription?.usage_billing_enabled && (
|
||||
<Alert
|
||||
withIcon
|
||||
variant="info"
|
||||
title="This organization is limited by the included usage"
|
||||
actions={
|
||||
currentPlan?.id === 'free' ? (
|
||||
<Button type="default" onClick={() => snap.setPanelKey('subscriptionPlan')}>
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="default" onClick={() => snap.setPanelKey('costControl')}>
|
||||
Adjust Spend Cap
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-scale-1000 mr-2">
|
||||
When this organization exceeds its{' '}
|
||||
<Link href="#breakdown">
|
||||
<a className="text-sm text-green-900 transition hover:text-green-1000">
|
||||
included usage quotas
|
||||
</a>
|
||||
</Link>
|
||||
, its projects may become unresponsive.{' '}
|
||||
{currentPlan?.id === 'free' ? (
|
||||
<p className="pr-4 mt-1">
|
||||
If you wish to exceed the included usage, you should upgrade to a paid plan.
|
||||
</p>
|
||||
) : (
|
||||
<p className="pr-4 mt-1">
|
||||
You currently have Spend Cap enabled - when you exceed your plan's limit,
|
||||
you will experience restrictions. To scale seamlessly and pay for
|
||||
over-usage, you can adjust your Cost Control settings.
|
||||
</p>
|
||||
)}
|
||||
</p>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SparkBar
|
||||
type="horizontal"
|
||||
value={daysWithinCycle - daysToCycleEnd}
|
||||
max={daysWithinCycle}
|
||||
barClass="bg-scale-1200"
|
||||
labelBottom={`Current billing cycle (${billingCycleStart.format(
|
||||
'MMM DD'
|
||||
)} - ${billingCycleEnd.format('MMM DD')})`}
|
||||
bgClass="bg-gray-300 dark:bg-gray-600"
|
||||
labelBottomClass="!text-scale-1000 pb-1"
|
||||
labelTop={`${daysToCycleEnd} Days left`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ScaffoldSectionContent>
|
||||
</ScaffoldSection>
|
||||
<PlanUpdateSidePanel />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Subscription
|
||||
@@ -0,0 +1,459 @@
|
||||
// List of available Tax IDs as reflected in Stripe's web portal
|
||||
// This was manually ported over so there may be a chance of mistakes
|
||||
// Last updated as of 29th March 2022.
|
||||
// The code may not necessarily match with the name (ref SE_VAT)
|
||||
// https://stripe.com/docs/api/customer_tax_ids/create
|
||||
export interface TaxId {
|
||||
name: string
|
||||
code: string
|
||||
country: string
|
||||
placeholder: string
|
||||
vatPrefix?: string
|
||||
}
|
||||
|
||||
export const TAX_IDS: TaxId[] = [
|
||||
{
|
||||
name: 'AE TRN',
|
||||
code: 'ae_trn',
|
||||
country: 'United Arab Emirates',
|
||||
placeholder: '123456789012345',
|
||||
},
|
||||
{
|
||||
name: 'AT VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Austria',
|
||||
placeholder: 'ATU12345678',
|
||||
vatPrefix: 'ATU',
|
||||
},
|
||||
{
|
||||
name: 'AU ABN',
|
||||
code: 'au_abn',
|
||||
country: 'Australia',
|
||||
placeholder: '12345678912',
|
||||
},
|
||||
{
|
||||
name: 'AU ARN',
|
||||
code: 'au_arn',
|
||||
country: 'Australia',
|
||||
placeholder: '123456789123',
|
||||
},
|
||||
{
|
||||
name: 'BE VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Belgium',
|
||||
placeholder: 'BE0123456789',
|
||||
vatPrefix: 'BE',
|
||||
},
|
||||
{
|
||||
name: 'BG VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Bulgaria',
|
||||
placeholder: 'BG0123456789',
|
||||
vatPrefix: 'BG',
|
||||
},
|
||||
{
|
||||
name: 'BR CNPJ',
|
||||
code: 'br_cnpj',
|
||||
country: 'Brazil',
|
||||
placeholder: '01.234.456/5432-10',
|
||||
},
|
||||
{
|
||||
name: 'BR CPF',
|
||||
code: 'br_cpf',
|
||||
country: 'Brazil',
|
||||
placeholder: '123.456.789-87',
|
||||
},
|
||||
{
|
||||
name: 'CA BN',
|
||||
code: 'ca_bn',
|
||||
country: 'Canada',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
name: 'CA GST/HST',
|
||||
code: 'ca_gst_hst',
|
||||
country: 'Canada',
|
||||
placeholder: '123456789RT0002',
|
||||
},
|
||||
{
|
||||
name: 'CA PST-BC',
|
||||
code: 'ca_pst_bc',
|
||||
country: 'Canada',
|
||||
placeholder: 'PST-1234-5678',
|
||||
},
|
||||
{
|
||||
name: 'CA PST-MB',
|
||||
code: 'ca_pst_mb',
|
||||
country: 'Canada',
|
||||
placeholder: '123456-7',
|
||||
},
|
||||
{
|
||||
name: 'CA PST-SK',
|
||||
code: 'ca_pst_mb',
|
||||
country: 'Canada',
|
||||
placeholder: '1234567',
|
||||
},
|
||||
{
|
||||
name: 'CA QST',
|
||||
code: 'ca_qst',
|
||||
country: 'Canada',
|
||||
placeholder: '1234567890TQ1234',
|
||||
},
|
||||
{
|
||||
name: 'CH VAT',
|
||||
code: 'ch_vat',
|
||||
country: 'Switzerland',
|
||||
placeholder: 'CHE-123.456.789 MWST',
|
||||
vatPrefix: 'CHE',
|
||||
},
|
||||
{
|
||||
name: 'CL TIN',
|
||||
code: 'cl_tin',
|
||||
country: 'Chile',
|
||||
placeholder: '12.345.678-K',
|
||||
},
|
||||
{
|
||||
name: 'CY VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Cyprus',
|
||||
placeholder: 'CY12345678Z',
|
||||
vatPrefix: 'CY',
|
||||
},
|
||||
{
|
||||
name: 'CZ VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Czech Republic',
|
||||
placeholder: 'CZ1234567890',
|
||||
vatPrefix: 'CZ',
|
||||
},
|
||||
{
|
||||
name: 'DE VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Germany',
|
||||
placeholder: 'DE123456789',
|
||||
vatPrefix: 'DE',
|
||||
},
|
||||
{
|
||||
name: 'DK VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Denmark',
|
||||
placeholder: 'DK12345678',
|
||||
vatPrefix: 'DK',
|
||||
},
|
||||
{
|
||||
name: 'EE VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Estonia',
|
||||
placeholder: 'EE123456789',
|
||||
vatPrefix: 'EE',
|
||||
},
|
||||
{
|
||||
name: 'ES CIF',
|
||||
code: 'es_cif',
|
||||
country: 'Spain',
|
||||
placeholder: 'A12345678',
|
||||
},
|
||||
{
|
||||
name: 'ES VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Spain',
|
||||
placeholder: 'ESX1234567Z',
|
||||
vatPrefix: 'ES',
|
||||
},
|
||||
{
|
||||
name: 'FI VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Finland',
|
||||
placeholder: 'FI12345678',
|
||||
vatPrefix: 'FI',
|
||||
},
|
||||
{
|
||||
name: 'FR VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'France',
|
||||
placeholder: 'FRAB123456789',
|
||||
vatPrefix: 'FR',
|
||||
},
|
||||
{
|
||||
name: 'GB VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'United Kingdom',
|
||||
placeholder: 'GB123456789',
|
||||
vatPrefix: 'GB',
|
||||
},
|
||||
{
|
||||
name: 'GE VAT',
|
||||
code: 'ge_vat',
|
||||
country: 'Georgia',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
name: 'EL VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Greece',
|
||||
placeholder: 'EL123456789',
|
||||
vatPrefix: 'EL',
|
||||
},
|
||||
{
|
||||
name: 'HK BR',
|
||||
code: 'hk_br',
|
||||
country: 'Hong Kong SAR China',
|
||||
placeholder: '12345678',
|
||||
},
|
||||
{
|
||||
name: 'HR VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Croatia',
|
||||
placeholder: 'HR12345678912',
|
||||
vatPrefix: 'HR',
|
||||
},
|
||||
{
|
||||
name: 'HU VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Hungary',
|
||||
placeholder: 'HU12345678912',
|
||||
vatPrefix: 'HU',
|
||||
},
|
||||
{
|
||||
name: 'ID NPWP',
|
||||
code: 'id_npwp',
|
||||
country: 'Indonesia',
|
||||
placeholder: '12.345.678.9-012.345',
|
||||
},
|
||||
{
|
||||
name: 'IE VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Ireland',
|
||||
placeholder: 'IE1234567AB',
|
||||
vatPrefix: 'IE',
|
||||
},
|
||||
{
|
||||
name: 'IL VAT',
|
||||
code: 'il_vat',
|
||||
country: 'Israel',
|
||||
placeholder: '000012345',
|
||||
},
|
||||
{
|
||||
name: 'IN GST',
|
||||
code: 'in_gst',
|
||||
country: 'India',
|
||||
placeholder: '12ABCDE3456FGZH',
|
||||
},
|
||||
{
|
||||
name: 'IS VAT',
|
||||
code: 'is_vat',
|
||||
country: 'Iceland',
|
||||
placeholder: '123456',
|
||||
},
|
||||
{
|
||||
name: 'IT VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Italy',
|
||||
placeholder: 'IT12345678912',
|
||||
vatPrefix: 'IT',
|
||||
},
|
||||
{
|
||||
name: 'JP CN',
|
||||
code: 'jp_cn',
|
||||
country: 'Japan',
|
||||
placeholder: '1234567891234',
|
||||
},
|
||||
{
|
||||
name: 'JP RN',
|
||||
code: 'jp_rn',
|
||||
country: 'Japan',
|
||||
placeholder: '12345',
|
||||
},
|
||||
{
|
||||
name: 'KR BRN',
|
||||
code: 'kr_brn',
|
||||
country: 'Korea',
|
||||
placeholder: '123-45-67890',
|
||||
},
|
||||
{
|
||||
name: 'LI UID',
|
||||
code: 'li_uid',
|
||||
country: 'Liechtenstein',
|
||||
placeholder: 'CHE123456789',
|
||||
},
|
||||
{
|
||||
name: 'LT VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Lithuania',
|
||||
placeholder: 'LT123456789123',
|
||||
vatPrefix: 'LT',
|
||||
},
|
||||
{
|
||||
name: 'LU VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Luxembourg',
|
||||
placeholder: 'LU12345678',
|
||||
vatPrefix: 'LU',
|
||||
},
|
||||
{
|
||||
name: 'LV VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Latvia',
|
||||
placeholder: 'LV12345678912',
|
||||
vatPrefix: 'LV',
|
||||
},
|
||||
{
|
||||
name: 'MT VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Malta',
|
||||
placeholder: 'MT12345678',
|
||||
vatPrefix: 'MT',
|
||||
},
|
||||
{
|
||||
name: 'MX RFC',
|
||||
code: 'mx_rfc',
|
||||
country: 'Mexico',
|
||||
placeholder: 'ABC010203AB9',
|
||||
},
|
||||
{
|
||||
name: 'MY FRP',
|
||||
code: 'my_frp',
|
||||
country: 'Malaysia',
|
||||
placeholder: '12345678',
|
||||
},
|
||||
{
|
||||
name: 'MY ITN',
|
||||
code: 'my_itn',
|
||||
country: 'Malaysia',
|
||||
placeholder: 'C 1234567890',
|
||||
},
|
||||
{
|
||||
name: 'MY SST',
|
||||
code: 'my_sst',
|
||||
country: 'Malaysia',
|
||||
placeholder: 'A12-3456-78912345',
|
||||
},
|
||||
{
|
||||
name: 'NL VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Netherlands',
|
||||
placeholder: 'NL123456789B12',
|
||||
vatPrefix: 'NL',
|
||||
},
|
||||
{
|
||||
name: 'NO VAT',
|
||||
code: 'no_vat',
|
||||
country: 'Norway',
|
||||
placeholder: '123456789MVA',
|
||||
},
|
||||
{
|
||||
name: 'NZ GST',
|
||||
code: 'nz_gst',
|
||||
country: 'New Zealand',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
name: 'PL VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Poland',
|
||||
placeholder: 'PL1234567890',
|
||||
vatPrefix: 'PL',
|
||||
},
|
||||
{
|
||||
name: 'PT VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Portugal',
|
||||
placeholder: 'PT123456789',
|
||||
vatPrefix: 'PT',
|
||||
},
|
||||
{
|
||||
name: 'RO VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Romania',
|
||||
placeholder: 'RO1234567891',
|
||||
vatPrefix: 'RO',
|
||||
},
|
||||
{
|
||||
name: 'RU INN',
|
||||
code: 'ru_inn',
|
||||
country: 'Russia',
|
||||
placeholder: '1234567891',
|
||||
},
|
||||
{
|
||||
name: 'RU KPP',
|
||||
code: 'ru_kpp',
|
||||
country: 'Russia',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
name: 'SA VAT',
|
||||
code: 'sa_vat',
|
||||
country: 'Saudi Arabia',
|
||||
placeholder: '123456789012345',
|
||||
},
|
||||
{
|
||||
name: 'SE VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Sweden',
|
||||
placeholder: 'SE123456789123',
|
||||
vatPrefix: 'SE',
|
||||
},
|
||||
{
|
||||
name: 'SG GST',
|
||||
code: 'sg_gst',
|
||||
country: 'Singapore',
|
||||
placeholder: 'M12345678X',
|
||||
},
|
||||
{
|
||||
name: 'SG UEN',
|
||||
code: 'sg_uen',
|
||||
country: 'Singapore',
|
||||
placeholder: '123456789F',
|
||||
},
|
||||
{
|
||||
name: 'SI VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Slovenia',
|
||||
placeholder: 'SI12345678',
|
||||
vatPrefix: 'SI',
|
||||
},
|
||||
{
|
||||
name: 'SK VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'Slovakia',
|
||||
placeholder: 'SK1234567891',
|
||||
vatPrefix: 'SK',
|
||||
},
|
||||
{
|
||||
name: 'TH VAT',
|
||||
code: 'th_vat',
|
||||
country: 'Thailand',
|
||||
placeholder: '1234567891234',
|
||||
},
|
||||
{
|
||||
name: 'TW VAT',
|
||||
code: 'tw_vat',
|
||||
country: 'Taiwan',
|
||||
placeholder: '12345678',
|
||||
},
|
||||
{
|
||||
name: 'UA VAT',
|
||||
code: 'ua_vat',
|
||||
country: 'Ukraine',
|
||||
placeholder: '123456789',
|
||||
},
|
||||
{
|
||||
name: 'US EIN',
|
||||
code: 'us_ein',
|
||||
country: 'United States',
|
||||
placeholder: '12-3456789',
|
||||
},
|
||||
{
|
||||
name: 'XI VAT',
|
||||
code: 'eu_vat',
|
||||
country: 'United Kingdom (Northern Ireland)',
|
||||
placeholder: 'XI123456789',
|
||||
vatPrefix: 'XI',
|
||||
},
|
||||
{
|
||||
name: 'ZA VAT',
|
||||
code: 'za_vat',
|
||||
country: 'South Africa',
|
||||
placeholder: '4123456789',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,254 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
ScaffoldSection,
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldSectionDetail,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { TaxId, useOrganizationTaxIDsQuery } from 'data/organizations/organization-tax-ids-query'
|
||||
import {
|
||||
TaxIdValue,
|
||||
useOrganizationTaxIDsUpdateMutation,
|
||||
} from 'data/organizations/organization-tax-ids-update-mutation'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { isEqual } from 'lodash'
|
||||
import { Button, Form, IconPlus, IconX, Input, Listbox } from 'ui'
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
import { sanitizeTaxID } from './TaxID.utilts'
|
||||
|
||||
const TaxID = () => {
|
||||
const { ui } = useStore()
|
||||
const { slug } = useParams()
|
||||
const {
|
||||
data: taxIds,
|
||||
error,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
} = useOrganizationTaxIDsQuery({ slug })
|
||||
const { mutateAsync: updateTaxIDs } = useOrganizationTaxIDsUpdateMutation()
|
||||
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
const [taxIdValues, setTaxIdValues] = useState<TaxIdValue[]>([])
|
||||
|
||||
const formattedTaxIds =
|
||||
taxIds?.map((taxId: TaxId) => {
|
||||
return {
|
||||
id: taxId.id,
|
||||
type: taxId.type,
|
||||
value: taxId.value,
|
||||
name:
|
||||
taxId.type === 'eu_vat'
|
||||
? `${taxId.country} VAT`
|
||||
: TAX_IDS.find((option) => option.code === taxId.type)?.name ?? '',
|
||||
}
|
||||
}) ?? []
|
||||
|
||||
const formId = 'tax-id-form'
|
||||
const hasChanges = !isEqual(taxIdValues, formattedTaxIds)
|
||||
const canReadTaxIds = useCheckPermissions(PermissionAction.BILLING_READ, 'stripe.tax_ids')
|
||||
const canUpdateTaxIds = useCheckPermissions(PermissionAction.BILLING_WRITE, 'stripe.tax_ids')
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
if (taxIdValues.length === 0) {
|
||||
setTaxIdValues(formattedTaxIds)
|
||||
} else {
|
||||
const erroredTaxIds = taxIdValues.filter((taxId: any) => errors.includes(taxId.id))
|
||||
setTaxIdValues(formattedTaxIds.concat(erroredTaxIds))
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSuccess])
|
||||
|
||||
const onUpdateTaxId = (id: string, key: string, value: string) => {
|
||||
const updatedTaxIds = taxIdValues.map((taxId: any) => {
|
||||
if (taxId.id === id) {
|
||||
if (key === 'name') {
|
||||
const selectedTaxIdOption = TAX_IDS.find((option) => option.name === value)
|
||||
return { ...taxId, [key]: value, type: selectedTaxIdOption?.code }
|
||||
} else {
|
||||
return { ...taxId, [key]: value }
|
||||
}
|
||||
}
|
||||
return taxId
|
||||
})
|
||||
setTaxIdValues(updatedTaxIds)
|
||||
}
|
||||
|
||||
const onAddNewTaxId = () => {
|
||||
const newTaxId = { id: uuidv4(), type: TAX_IDS[0].code, value: '', name: TAX_IDS[0].name }
|
||||
const updatedTaxIds = taxIdValues.concat([newTaxId])
|
||||
setTaxIdValues(updatedTaxIds)
|
||||
}
|
||||
|
||||
const onRemoveTaxId = (id: string) => {
|
||||
const updatedTaxIds = taxIdValues.filter((taxId) => taxId.id !== id)
|
||||
setTaxIdValues(updatedTaxIds)
|
||||
}
|
||||
|
||||
const onResetTaxIds = () => {
|
||||
setTaxIdValues(formattedTaxIds)
|
||||
}
|
||||
|
||||
const onSaveTaxIds = async (values: any, { setSubmitting }: any) => {
|
||||
if (!slug) return console.error('Slug is required')
|
||||
if (taxIds === undefined) return console.error('Tax IDs are required')
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
const newIds = taxIdValues.map((x) => sanitizeTaxID(x))
|
||||
const { errors } = await updateTaxIDs({ slug, existingIds: taxIds, newIds })
|
||||
setErrors(errors?.map((taxId) => taxId.id) ?? [])
|
||||
|
||||
if (errors !== undefined && errors.length > 0) {
|
||||
errors.forEach((taxId: any) => {
|
||||
ui.setNotification({ category: 'error', message: taxId.result.error.message })
|
||||
})
|
||||
} else {
|
||||
ui.setNotification({ category: 'success', message: 'Successfully updated tax IDs' })
|
||||
}
|
||||
} catch (error: any) {
|
||||
ui.setNotification({ category: 'error', message: `Failed to save tax IDs: ${error.message}` })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldSection>
|
||||
<ScaffoldSectionDetail>
|
||||
<div className="sticky space-y-6 top-16">
|
||||
<div>
|
||||
<p className="text-base">Tax ID</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
If you would like to include specific tax ID(s) to your invoices.
|
||||
</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
Make sure the tax ID looks exactly like the placeholder text.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScaffoldSectionDetail>
|
||||
<ScaffoldSectionContent>
|
||||
{!canReadTaxIds ? (
|
||||
<NoPermission resourceText="view this organization's tax IDs" />
|
||||
) : (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<AlertError error={error} subject="Unable to retrieve organization tax IDs" />
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<Form validateOnBlur id={formId} initialValues={{}} onSubmit={onSaveTaxIds}>
|
||||
{({ isSubmitting }: { isSubmitting: boolean }) => (
|
||||
<FormPanel
|
||||
footer={
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isSubmitting}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={() => onResetTaxIds()}
|
||||
helper={
|
||||
!canUpdateTaxIds
|
||||
? "You need additional permissions to manage this organization's tax IDs"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormSection>
|
||||
<FormSectionContent fullWidth loading={false}>
|
||||
{taxIdValues.length >= 1 ? (
|
||||
<div className="w-full space-y-2">
|
||||
{taxIdValues.map((taxId, idx: number) => {
|
||||
const selectedTaxId = TAX_IDS.find(
|
||||
(option) => option.code === taxId.type
|
||||
)
|
||||
return (
|
||||
<div key={`tax-id-${idx}`} className="flex items-center space-x-2">
|
||||
<Listbox
|
||||
value={selectedTaxId?.name}
|
||||
onChange={(e: any) => onUpdateTaxId(taxId.id, 'name', e)}
|
||||
disabled={!canUpdateTaxIds}
|
||||
className="w-[200px]"
|
||||
>
|
||||
{TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map(
|
||||
(option) => (
|
||||
<Listbox.Option
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
label={option.name}
|
||||
>
|
||||
{option.country} - {option.name}
|
||||
</Listbox.Option>
|
||||
)
|
||||
)}
|
||||
</Listbox>
|
||||
<Input
|
||||
value={taxId.value}
|
||||
placeholder={selectedTaxId?.placeholder ?? ''}
|
||||
onChange={(e: any) =>
|
||||
onUpdateTaxId(taxId.id, 'value', e.target.value)
|
||||
}
|
||||
disabled={!canUpdateTaxIds}
|
||||
/>
|
||||
{canUpdateTaxIds && (
|
||||
<Button
|
||||
type="text"
|
||||
className="px-1"
|
||||
icon={<IconX />}
|
||||
onClick={() => onRemoveTaxId(taxId.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="flex items-center space-x-2 text-sm text-scale-900">
|
||||
No tax IDs
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{canUpdateTaxIds && (
|
||||
<div
|
||||
className="flex cursor-pointer items-center space-x-2 opacity-50 transition hover:opacity-100"
|
||||
onClick={() => onAddNewTaxId()}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
<p className="text-sm">Add another ID</p>
|
||||
</div>
|
||||
)}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScaffoldSectionContent>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaxID
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TaxId } from 'data/organizations/organization-tax-ids-query'
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
import { TaxIdValue } from 'data/organizations/organization-tax-ids-update-mutation'
|
||||
|
||||
/**
|
||||
* Sanitize EU VAT ids so they get prepended with the country code
|
||||
*
|
||||
* Ex: Belgium's VAT ID should be BE0123456789 not 0123456789
|
||||
* Only EU VAT ids are affected
|
||||
*/
|
||||
export const sanitizeTaxID = (taxId: TaxIdValue) => {
|
||||
let newID = taxId
|
||||
|
||||
const selectedTaxId = TAX_IDS.find((option) => option.code === taxId.type)
|
||||
|
||||
const vatIdPrefix = selectedTaxId?.vatPrefix
|
||||
|
||||
// if the value doesn't start with the prefix, prepend them
|
||||
if (vatIdPrefix && !taxId.value.startsWith(vatIdPrefix)) {
|
||||
newID.value = `${vatIdPrefix}${taxId.value}`
|
||||
}
|
||||
|
||||
return newID
|
||||
}
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Alert, Badge, Button, IconExternalLink, IconTruck } from 'ui'
|
||||
import Link from 'next/link'
|
||||
import { Badge, Button, IconExternalLink, IconTruck } from 'ui'
|
||||
|
||||
import Panel from 'components/ui/Panel'
|
||||
import MigrateOrganizationBillingButton from './MigrateOrganizationBillingButton'
|
||||
import Link from 'next/link'
|
||||
|
||||
const OrganizationBillingMigrationPanel = observer(() => {
|
||||
return (
|
||||
|
||||
@@ -174,7 +174,6 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
|
||||
<div className="flex items-center space-x-3">
|
||||
<p className="text-xs text-scale-900">You can rename your organization later</p>
|
||||
<Button
|
||||
block
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
loading={newOrgLoading}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useDailyStatsQuery } from 'data/analytics/daily-stats-query'
|
||||
import { DataPoint } from 'data/analytics/constants'
|
||||
import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query'
|
||||
import UsageSection from './UsageSection'
|
||||
|
||||
export interface ActivityProps {
|
||||
projectRef: string
|
||||
startDate: string | undefined
|
||||
endDate: string | undefined
|
||||
subscription: ProjectSubscriptionResponse | undefined
|
||||
currentBillingCycleSelected: boolean
|
||||
}
|
||||
|
||||
const Activity = ({
|
||||
projectRef,
|
||||
subscription,
|
||||
startDate,
|
||||
endDate,
|
||||
currentBillingCycleSelected,
|
||||
}: ActivityProps) => {
|
||||
const { data: mauData, isLoading: isLoadingMauData } = useDailyStatsQuery({
|
||||
projectRef,
|
||||
attribute: 'total_auth_billing_period_mau',
|
||||
interval: '1d',
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
|
||||
const { data: mauSSOData, isLoading: isLoadingMauSSOData } = useDailyStatsQuery({
|
||||
projectRef,
|
||||
attribute: 'total_auth_billing_period_sso_mau',
|
||||
interval: '1d',
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
|
||||
const { data: assetTransformationsData, isLoading: isLoadingAssetTransformationsData } =
|
||||
useDailyStatsQuery({
|
||||
projectRef,
|
||||
attribute: 'total_storage_image_render_count',
|
||||
interval: '1d',
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
|
||||
const { data: funcInvocationsData, isLoading: isLoadingFuncInvocationsData } = useDailyStatsQuery(
|
||||
{
|
||||
projectRef,
|
||||
attribute: 'total_func_invocations',
|
||||
interval: '1d',
|
||||
startDate,
|
||||
endDate,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: realtimeMessagesData, isLoading: isLoadingRealtimeMessagesData } =
|
||||
useDailyStatsQuery({
|
||||
projectRef,
|
||||
attribute: 'total_realtime_message_count',
|
||||
interval: '1d',
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
|
||||
const { data: realtimeConnectionsData, isLoading: isLoadingRealtimeConnectionsData } =
|
||||
useDailyStatsQuery({
|
||||
projectRef,
|
||||
attribute: 'total_realtime_peak_connection',
|
||||
interval: '1d',
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
|
||||
const chartMeta: {
|
||||
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean }
|
||||
} = {
|
||||
monthly_active_users: {
|
||||
data: mauData?.data ?? [],
|
||||
margin: 18,
|
||||
isLoading: isLoadingMauData,
|
||||
hasNoData: mauData?.hasNoData ?? false,
|
||||
},
|
||||
monthly_active_sso_users: {
|
||||
data: mauSSOData?.data ?? [],
|
||||
margin: 20,
|
||||
isLoading: isLoadingMauSSOData,
|
||||
hasNoData: mauSSOData?.hasNoData ?? false,
|
||||
},
|
||||
storage_image_render_count: {
|
||||
data: assetTransformationsData?.data ?? [],
|
||||
margin: 0,
|
||||
isLoading: isLoadingMauSSOData,
|
||||
hasNoData: assetTransformationsData?.hasNoData ?? false,
|
||||
},
|
||||
func_invocations: {
|
||||
data: funcInvocationsData?.data ?? [],
|
||||
margin: 26,
|
||||
isLoading: isLoadingFuncInvocationsData,
|
||||
hasNoData: funcInvocationsData?.hasNoData ?? false,
|
||||
},
|
||||
realtime_message_count: {
|
||||
data: realtimeMessagesData?.data ?? [],
|
||||
margin: 38,
|
||||
isLoading: isLoadingRealtimeMessagesData,
|
||||
hasNoData: realtimeMessagesData?.hasNoData ?? false,
|
||||
},
|
||||
realtime_peak_connection: {
|
||||
data: realtimeConnectionsData?.data ?? [],
|
||||
margin: 0,
|
||||
isLoading: isLoadingRealtimeConnectionsData,
|
||||
hasNoData: realtimeConnectionsData?.hasNoData ?? false,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<UsageSection
|
||||
projectRef={projectRef}
|
||||
categoryKey="activity"
|
||||
chartMeta={chartMeta}
|
||||
subscription={subscription}
|
||||
currentBillingCycleSelected={currentBillingCycleSelected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Activity
|
||||
@@ -0,0 +1,136 @@
|
||||
import { DataPoint } from 'data/analytics/constants'
|
||||
import { useDailyStatsQuery } from 'data/analytics/daily-stats-query'
|
||||
import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query'
|
||||
import UsageSection from './UsageSection'
|
||||
|
||||
const GB = 1073741824
|
||||
const MOCK_DATA = [
|
||||
{
|
||||
id: '1',
|
||||
loopId: '1',
|
||||
period_start: '2023-06-29T07:05:12Z',
|
||||
periodStartFormatted: '29 Jun 2023',
|
||||
total_storage_egress: (1.53 * GB).toString(),
|
||||
total_egress_modified: (1.12 * GB).toString(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
loopId: '2',
|
||||
period_start: '2023-06-30T07:05:12Z',
|
||||
periodStartFormatted: '30 Jun 2023',
|
||||
total_storage_egress: (1 * GB).toString(),
|
||||
total_egress_modified: (1.1 * GB).toString(),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
loopId: '3',
|
||||
period_start: '2023-07-01T07:05:12Z',
|
||||
periodStartFormatted: '01 Jul 2023',
|
||||
total_storage_egress: (1 * GB).toString(),
|
||||
total_egress_modified: (1.2 * GB).toString(),
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
loopId: '4',
|
||||
period_start: '2023-07-02T07:05:12Z',
|
||||
periodStartFormatted: '02 Jul 2023',
|
||||
total_storage_egress: (1 * GB).toString(),
|
||||
total_egress_modified: (1.5 * GB).toString(),
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
loopId: '5',
|
||||
period_start: '2023-07-03T07:05:12Z',
|
||||
periodStartFormatted: '03 Jul 2023',
|
||||
total_storage_egress: (1 * GB).toString(),
|
||||
total_egress_modified: (2 * GB).toString(),
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
loopId: '6',
|
||||
period_start: '2023-07-04T07:05:12Z',
|
||||
periodStartFormatted: '04 Jul 2023',
|
||||
total_storage_egress: (1 * GB).toString(),
|
||||
total_egress_modified: (3 * GB).toString(),
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
loopId: '7',
|
||||
period_start: '2023-07-05T07:05:12Z',
|
||||
periodStartFormatted: '05 Jul 2023',
|
||||
total_storage_egress: (1 * GB).toString(),
|
||||
total_egress_modified: (4.5 * GB).toString(),
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
loopId: '8',
|
||||
period_start: '2023-07-06T07:05:12Z',
|
||||
periodStartFormatted: '06 Jul 2023',
|
||||
total_storage_egress: (1 * GB).toString(),
|
||||
total_egress_modified: (4 * GB).toString(),
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
loopId: '9',
|
||||
period_start: '2023-07-07T07:05:12Z',
|
||||
periodStartFormatted: '07 Jul 2023',
|
||||
total_storage_egress: (1 * GB).toString(),
|
||||
total_egress_modified: (3.8 * GB).toString(),
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
loopId: '10',
|
||||
period_start: '2023-07-08T07:05:12Z',
|
||||
periodStartFormatted: '08 Jul 2023',
|
||||
total_storage_egress: (1 * GB).toString(),
|
||||
total_egress_modified: (3.5 * GB).toString(),
|
||||
},
|
||||
]
|
||||
|
||||
export interface BandwidthProps {
|
||||
projectRef: string
|
||||
startDate: string | undefined
|
||||
endDate: string | undefined
|
||||
subscription: ProjectSubscriptionResponse | undefined
|
||||
currentBillingCycleSelected: boolean
|
||||
}
|
||||
|
||||
// [Joshen TODO] Needs to take in org slug and eventually use daily stats org query
|
||||
const Bandwidth = ({
|
||||
projectRef,
|
||||
subscription,
|
||||
startDate,
|
||||
endDate,
|
||||
currentBillingCycleSelected,
|
||||
}: BandwidthProps) => {
|
||||
// const { data: dbEgressData, isLoading: isLoadingDbEgressData } = useDailyStatsQuery({
|
||||
// projectRef,
|
||||
// attribute: 'total_egress_modified',
|
||||
// interval: '1d',
|
||||
// startDate,
|
||||
// endDate,
|
||||
// })
|
||||
|
||||
const chartMeta: {
|
||||
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean }
|
||||
} = {
|
||||
db_egress: {
|
||||
data: MOCK_DATA, // dbEgressData?.data ?? [],
|
||||
margin: 16,
|
||||
isLoading: false, // isLoadingDbEgressData,
|
||||
hasNoData: false, // dbEgressData?.hasNoData ?? false,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<UsageSection
|
||||
projectRef={projectRef}
|
||||
categoryKey="bandwidth"
|
||||
chartMeta={chartMeta}
|
||||
subscription={subscription}
|
||||
currentBillingCycleSelected={currentBillingCycleSelected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Bandwidth
|
||||
@@ -0,0 +1,69 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { Badge, IconExternalLink } from 'ui'
|
||||
import { CategoryAttribute } from './Usage.constants'
|
||||
import Link from 'next/link'
|
||||
import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold'
|
||||
|
||||
export interface SectionContent {
|
||||
section: CategoryAttribute
|
||||
includedInPlan?: boolean
|
||||
}
|
||||
|
||||
const SectionContent = ({
|
||||
section,
|
||||
includedInPlan,
|
||||
children,
|
||||
}: PropsWithChildren<SectionContent>) => {
|
||||
const { name, description, links } = section
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldContainer>
|
||||
<div className="mx-auto flex flex-col gap-10 py-16">
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 lg:col-span-5">
|
||||
<div className="sticky top-32 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-base capitalize">{name}</p>
|
||||
{includedInPlan === false && <Badge color="gray">Not included</Badge>}
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{description.split('\n').map((value, idx) => (
|
||||
<p key={`desc-${idx}`} className="text-sm text-scale-1000 pr-8">
|
||||
{value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{links && links.length && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-scale-1200 dark:text-scale-1100 mb-2">
|
||||
More information
|
||||
</p>
|
||||
{links.map((link) => (
|
||||
<div key={link.url}>
|
||||
<Link href={link.url}>
|
||||
<a target="_blank" rel="noreferrer">
|
||||
<div className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition">
|
||||
<p className="text-sm">{link.name}</p>
|
||||
<IconExternalLink size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-12 lg:col-span-7 space-y-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaffoldContainer>
|
||||
<ScaffoldDivider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SectionContent
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface SectionHeaderProps {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const SectionHeader = ({ title, description }: SectionHeaderProps) => {
|
||||
return (
|
||||
<div className="mx-auto flex flex-col gap-10 py-16">
|
||||
<div>
|
||||
<p className="text-xl">{title}</p>
|
||||
<p className="text-sm text-scale-1000">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SectionHeader
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useDailyStatsQuery } from 'data/analytics/daily-stats-query'
|
||||
import { DataPoint } from 'data/analytics/constants'
|
||||
import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query'
|
||||
import UsageSection from './UsageSection'
|
||||
|
||||
export interface SizeAndCountsProps {
|
||||
projectRef: string
|
||||
startDate: string | undefined
|
||||
endDate: string | undefined
|
||||
subscription: ProjectSubscriptionResponse | undefined
|
||||
currentBillingCycleSelected: boolean
|
||||
}
|
||||
|
||||
const SizeAndCounts = ({
|
||||
projectRef,
|
||||
startDate,
|
||||
endDate,
|
||||
subscription,
|
||||
currentBillingCycleSelected,
|
||||
}: SizeAndCountsProps) => {
|
||||
const { data: dbSizeData, isLoading: isLoadingDbSizeData } = useDailyStatsQuery({
|
||||
projectRef,
|
||||
attribute: 'total_db_size_bytes',
|
||||
interval: '1d',
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
|
||||
const { data: storageSizeData, isLoading: isLoadingStorageSizeData } = useDailyStatsQuery({
|
||||
projectRef,
|
||||
attribute: 'total_storage_size_bytes',
|
||||
interval: '1d',
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
|
||||
const { data: functionCountData, isLoading: isLoadingFunctionCountData } = useDailyStatsQuery({
|
||||
projectRef,
|
||||
attribute: 'total_func_count',
|
||||
interval: '1d',
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
|
||||
const chartMeta: {
|
||||
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean }
|
||||
} = {
|
||||
db_size: {
|
||||
isLoading: isLoadingDbSizeData,
|
||||
margin: 14,
|
||||
data: dbSizeData?.data ?? [],
|
||||
hasNoData: dbSizeData?.hasNoData ?? false,
|
||||
},
|
||||
storage_size: {
|
||||
isLoading: isLoadingStorageSizeData,
|
||||
margin: 14,
|
||||
data: storageSizeData?.data ?? [],
|
||||
hasNoData: storageSizeData?.hasNoData ?? false,
|
||||
},
|
||||
func_count: {
|
||||
isLoading: isLoadingFunctionCountData,
|
||||
margin: 0,
|
||||
data: functionCountData?.data ?? [],
|
||||
hasNoData: functionCountData?.hasNoData ?? false,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<UsageSection
|
||||
projectRef={projectRef}
|
||||
categoryKey="sizeCount"
|
||||
chartMeta={chartMeta}
|
||||
subscription={subscription}
|
||||
currentBillingCycleSelected={currentBillingCycleSelected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SizeAndCounts
|
||||
@@ -0,0 +1,302 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/BillingV2/Billing.constants'
|
||||
import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query'
|
||||
import { ProjectUsageData } from 'data/usage/project-usage-query'
|
||||
import { Alert, Badge, Button, IconExternalLink } from 'ui'
|
||||
|
||||
export const COLOR_MAP = {
|
||||
white: { bar: 'fill-scale-1200', marker: 'bg-scale-1200' },
|
||||
green: { bar: 'fill-green-1000', marker: 'bg-green-1000' },
|
||||
blue: { bar: 'fill-blue-1000', marker: 'bg-blue-1000' },
|
||||
}
|
||||
|
||||
export const Y_DOMAIN_CEILING_MULTIPLIER = 4 / 3
|
||||
|
||||
export const USAGE_STATUS = {
|
||||
NORMAL: 'NORMAL',
|
||||
APPROACHING: 'APPROACHING',
|
||||
EXCEEDED: 'EXCEEDED',
|
||||
}
|
||||
|
||||
export interface Attribute {
|
||||
key: string
|
||||
name?: string
|
||||
color: 'white' | 'blue' | 'green'
|
||||
}
|
||||
export interface CategoryAttribute {
|
||||
anchor: string
|
||||
key: string // Property from project usage
|
||||
attributes: Attribute[] // For querying against stats-daily / infra-monitoring
|
||||
name: string
|
||||
chartPrefix?: string
|
||||
unit: 'bytes' | 'absolute' | 'percentage'
|
||||
links?: {
|
||||
name: string
|
||||
url: string
|
||||
}[]
|
||||
description: string
|
||||
chartDescription: string
|
||||
additionalInfo?: (
|
||||
ref: string,
|
||||
subscription?: ProjectSubscriptionResponse,
|
||||
usage?: ProjectUsageData
|
||||
) => JSX.Element | null
|
||||
}
|
||||
|
||||
export type CategoryMetaKey = 'bandwidth' | 'sizeCount' | 'activity'
|
||||
|
||||
export interface CategoryMeta {
|
||||
key: 'bandwidth' | 'sizeCount' | 'activity'
|
||||
name: string
|
||||
description: string
|
||||
attributes: CategoryAttribute[]
|
||||
}
|
||||
|
||||
// [Joshen TODO] We need to update this
|
||||
export const USAGE_CATEGORIES: CategoryMeta[] = [
|
||||
{
|
||||
key: 'bandwidth',
|
||||
name: 'Bandwidth',
|
||||
description: 'Amount of data transmitted over all network connections',
|
||||
attributes: [
|
||||
{
|
||||
anchor: 'dbEgress',
|
||||
key: 'db_egress', // This needs to be updated based on the new org usage endpoint
|
||||
attributes: [
|
||||
{ key: 'total_egress_modified', name: 'DB Egress', color: 'green' },
|
||||
{ key: 'total_storage_egress', name: 'Storage Egress', color: 'blue' },
|
||||
],
|
||||
name: 'Total Egress',
|
||||
unit: 'bytes',
|
||||
description:
|
||||
'Contains any outgoing traffic (egress) from your database.\nBilling is based on the total sum of egress in GB throughout your billing period.',
|
||||
chartDescription: 'The data refreshes every 24 hours.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sizeCount',
|
||||
name: 'Size & Counts',
|
||||
description: 'Amount of resources your project is consuming',
|
||||
attributes: [
|
||||
{
|
||||
anchor: 'dbSize',
|
||||
key: 'db_size',
|
||||
attributes: [{ key: 'total_db_size_bytes', color: 'white' }],
|
||||
name: 'Database size',
|
||||
chartPrefix: 'Average ',
|
||||
unit: 'bytes',
|
||||
description:
|
||||
'Database size refers to the monthly average storage usage, as reported by Postgres. Paid plans use auto-scaling disks.\nBilling is based on the average daily database size used in GB throughout the billing period. Billing is independent of the provisioned disk size.',
|
||||
links: [
|
||||
{
|
||||
name: 'Documentation',
|
||||
url: 'https://supabase.com/docs/guides/platform/database-size',
|
||||
},
|
||||
],
|
||||
chartDescription: 'The data refreshes every 24 hours.',
|
||||
additionalInfo: (
|
||||
ref: string,
|
||||
subscription?: ProjectSubscriptionResponse,
|
||||
usage?: ProjectUsageData
|
||||
) => {
|
||||
const usageMeta = usage?.['db_size'] ?? undefined
|
||||
const usageRatio =
|
||||
typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0
|
||||
const hasLimit = usageMeta && usageMeta.limit > 0
|
||||
|
||||
const isApproachingLimit = hasLimit && usageRatio >= USAGE_APPROACHING_THRESHOLD
|
||||
const isExceededLimit = hasLimit && usageRatio >= 1
|
||||
|
||||
return subscription?.plan?.id !== 'free' ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm">Disk size:</p>
|
||||
<p className="text-sm">{usage?.disk_volume_size_gb} GB</p>
|
||||
<Badge color="green" size="small">
|
||||
Auto-scaling
|
||||
</Badge>
|
||||
</div>
|
||||
<Link href="https://supabase.com/docs/guides/platform/database-usage#disk-management">
|
||||
<a>
|
||||
<Button size="tiny" type="default" icon={<IconExternalLink size={14} />}>
|
||||
What is disk size?
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-scale-1000 mt-3">
|
||||
If you reach 95% of the disk space, your project will enter read-only mode. Your
|
||||
disk storage expands automatically when the database reaches 90% of the disk size.
|
||||
The disk is expanded to be 50% larger. Auto-scaling can only take place once every 6
|
||||
hours. You can also{' '}
|
||||
<Link passHref href={`/project/${ref}/settings/database#diskManagement`}>
|
||||
<a className="text-brand-900 transition hover:text-brand-1000">preprovision</a>
|
||||
</Link>{' '}
|
||||
disk for loading larger amounts of data.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{(isApproachingLimit || isExceededLimit) && (
|
||||
<Alert
|
||||
withIcon
|
||||
variant={isExceededLimit ? 'danger' : 'warning'}
|
||||
title={
|
||||
isExceededLimit
|
||||
? 'Exceeding database size limit'
|
||||
: 'Nearing database size limit'
|
||||
}
|
||||
>
|
||||
<div className="flex w-full items-center flex-col justify-center space-y-2 md:flex-row md:justify-between">
|
||||
<div>
|
||||
When you reach your database size limit, your project can go into read-only
|
||||
mode. Please upgrade your plan.
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
anchor: 'storageSize',
|
||||
key: 'storage_size',
|
||||
attributes: [{ key: 'total_storage_size_bytes', color: 'white' }],
|
||||
name: 'Storage Size',
|
||||
chartPrefix: 'Max ',
|
||||
unit: 'bytes',
|
||||
description:
|
||||
'Sum of all objects in your storage buckets.\nBilling is based on the average daily size in GB throughout your billing period.',
|
||||
chartDescription: 'The data refreshes every 24 hours.',
|
||||
links: [
|
||||
{
|
||||
name: 'Storage',
|
||||
url: 'https://supabase.com/docs/guides/storage',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: 'funcCount',
|
||||
key: 'func_count',
|
||||
attributes: [{ key: 'total_func_count', color: 'white' }],
|
||||
name: 'Edge Function Count',
|
||||
chartPrefix: 'Max ',
|
||||
unit: 'absolute',
|
||||
description:
|
||||
'Number of serverless functions in your project.\nBilling is based on the maximum amount of functions at any point in time throughout your billing period.',
|
||||
chartDescription: 'The data refreshes every 24 hours.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'activity',
|
||||
name: 'Activity',
|
||||
description: 'Usage statistics that reflect the activity of your project',
|
||||
attributes: [
|
||||
{
|
||||
anchor: 'mau',
|
||||
key: 'monthly_active_users',
|
||||
attributes: [{ key: 'total_auth_billing_period_mau', color: 'white' }],
|
||||
name: 'Monthly Active Users',
|
||||
unit: 'absolute',
|
||||
description:
|
||||
'Users who log in or refresh their token count towards MAU.\nBilling is based on the sum of distinct users requesting your API throughout the billing period. Resets every billing cycle.',
|
||||
chartDescription:
|
||||
'The data is refreshed over a period of 24 hours and resets at the beginning of every billing period.\nThe data points are relative to the beginning of your billing period.',
|
||||
links: [
|
||||
{
|
||||
name: 'Auth',
|
||||
url: 'https://supabase.com/docs/guides/auth',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: 'mauSso',
|
||||
key: 'monthly_active_sso_users',
|
||||
attributes: [{ key: 'total_auth_billing_period_sso_mau', color: 'white' }],
|
||||
name: 'Monthly Active SSO Users',
|
||||
unit: 'absolute',
|
||||
description:
|
||||
'SSO users who log in or refresh their token count towards SSO MAU.\nBilling is based on the sum of distinct Single Sign-On users requesting your API throughout the billing period. Resets every billing cycle.',
|
||||
chartDescription:
|
||||
'The data refreshes over a period of 24 hours and resets at the beginning of every billing period.\nThe data points are relative to the beginning of your billing period.',
|
||||
links: [
|
||||
{
|
||||
name: 'SSO with SAML 2.0',
|
||||
url: 'https://supabase.com/docs/guides/auth/sso/auth-sso-saml',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: 'storageImageTransformations',
|
||||
key: 'storage_image_render_count',
|
||||
attributes: [{ key: 'total_storage_image_render_count', color: 'white' }],
|
||||
name: 'Storage Image Transformations',
|
||||
unit: 'absolute',
|
||||
description:
|
||||
'We count all images that were transformed in the billing period, ignoring any transformations.\nUsage example: You transform one image with four different size transformations and another image with just a single transformation. It counts as two, as only two images were transformed.\nBilling is based on the count of (origin) images that used transformations throughout the billing period. Resets every billing cycle.',
|
||||
chartDescription:
|
||||
'The data refreshes every 24 hours.\nThe data points are relative to the beginning of your billing period.',
|
||||
links: [
|
||||
{
|
||||
name: 'Documentation',
|
||||
url: 'https://supabase.com/docs/guides/storage/image-transformations',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: 'functionInvocations',
|
||||
key: 'func_invocations',
|
||||
attributes: [{ key: 'total_func_invocations', color: 'white' }],
|
||||
name: 'Edge Function Invocations',
|
||||
unit: 'absolute',
|
||||
description:
|
||||
'Every serverless function invocation independent of response status is counted.\nBilling is based on the sum of all invocations throughout your billing period.',
|
||||
chartDescription: 'The data refreshes every 24 hours.',
|
||||
links: [
|
||||
{
|
||||
name: 'Edge Functions',
|
||||
url: 'https://supabase.com/docs/guides/functions',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: 'realtimeMessageCount',
|
||||
key: 'realtime_message_count',
|
||||
attributes: [{ key: 'total_realtime_message_count', color: 'white' }],
|
||||
name: 'Realtime Message Count',
|
||||
unit: 'absolute',
|
||||
description:
|
||||
"Count of messages going through Realtime.\nUsage example: If you do a database change and 5 clients listen to that change via Realtime, that's 5 messages. If you broadcast a message and 4 clients listen to that, that's 5 messages (1 message sent, 4 received).\nBilling is based on the total amount of messages throughout your billing period.",
|
||||
chartDescription: 'The data refreshes every 24 hours.',
|
||||
links: [
|
||||
{
|
||||
name: 'Realtime Quotas',
|
||||
url: 'https://supabase.com/docs/guides/realtime/quotas',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: 'realtimePeakConnection',
|
||||
key: 'realtime_peak_connection',
|
||||
attributes: [{ key: 'total_realtime_peak_connection', color: 'white' }],
|
||||
name: 'Realtime Peak Connections',
|
||||
chartPrefix: 'Max ',
|
||||
unit: 'absolute',
|
||||
description:
|
||||
'Total number of successful connections. Connections attempts are not counted towards usage.\nBilling is based on the maximum amount of concurrent peak connections throughout your billing period.',
|
||||
chartDescription: 'The data refreshes every 24 hours.',
|
||||
links: [
|
||||
{
|
||||
name: 'Realtime Quotas',
|
||||
url: 'https://supabase.com/docs/guides/realtime/quotas',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,127 @@
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Bandwidth from './Bandwidth'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
|
||||
import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants'
|
||||
import SizeAndCounts from './SizeAndCounts'
|
||||
import Activity from './Activity'
|
||||
import { IconLoader } from 'ui'
|
||||
import { ScaffoldContainer, ScaffoldDivider, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
|
||||
const Usage = () => {
|
||||
const { slug } = useParams()
|
||||
const [dateRange, setDateRange] = useState<any>()
|
||||
|
||||
const {
|
||||
data: subscription,
|
||||
isLoading: isLoadingSubscription,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useOrgSubscriptionQuery({ orgSlug: slug })
|
||||
|
||||
const selectedProjectRef = 'kojplftaeiavuneadgix'
|
||||
const billingCycleStart = dayjs.unix(subscription?.current_period_start ?? 0).utc()
|
||||
const billingCycleEnd = dayjs.unix(subscription?.current_period_end ?? 0).utc()
|
||||
|
||||
const currentBillingCycleSelected = useMemo(() => {
|
||||
// Selected by default
|
||||
if (!dateRange?.period_start || !dateRange?.period_end || !subscription) return true
|
||||
|
||||
const { current_period_start, current_period_end } = subscription
|
||||
|
||||
return (
|
||||
dayjs(dateRange.period_start.date).isSame(new Date(current_period_start * 1000)) &&
|
||||
dayjs(dateRange.period_end.date).isSame(new Date(current_period_end * 1000))
|
||||
)
|
||||
}, [dateRange, subscription])
|
||||
|
||||
const startDate = useMemo(() => {
|
||||
// If end date is in future, set end date to now
|
||||
if (!dateRange?.period_start?.date) {
|
||||
return undefined
|
||||
} else {
|
||||
// LF seems to have an issue with the milliseconds, causes infinite loading sometimes
|
||||
return new Date(dateRange?.period_start?.date).toISOString().slice(0, -5) + 'Z'
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dateRange, subscription])
|
||||
|
||||
const endDate = useMemo(() => {
|
||||
// If end date is in future, set end date to end of current day
|
||||
if (dateRange?.period_end?.date && dayjs(dateRange.period_end.date).isAfter(dayjs())) {
|
||||
// LF seems to have an issue with the milliseconds, causes infinite loading sometimes
|
||||
// In order to have full days from Prometheus metrics when using 1d interval,
|
||||
// the time needs to be greater or equal than the time of the start date
|
||||
return dayjs().endOf('day').toISOString().slice(0, -5) + 'Z'
|
||||
} else if (dateRange?.period_end?.date) {
|
||||
// LF seems to have an issue with the milliseconds, causes infinite loading sometimes
|
||||
return new Date(dateRange.period_end.date).toISOString().slice(0, -5) + 'Z'
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dateRange, subscription])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldContainer>
|
||||
<div className="py-4 flex items-center space-x-4">
|
||||
{!isLoadingSubscription && (
|
||||
<DateRangePicker
|
||||
id="billingCycle"
|
||||
name="billingCycle"
|
||||
onChange={setDateRange}
|
||||
value={TIME_PERIODS_BILLING[0].key}
|
||||
options={[...TIME_PERIODS_BILLING, ...TIME_PERIODS_REPORTS]}
|
||||
loading={isLoadingSubscription}
|
||||
currentBillingPeriodStart={subscription?.current_period_start}
|
||||
className="!w-[200px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingSubscription ? (
|
||||
<IconLoader className="animate-spin" size={14} />
|
||||
) : subscription !== undefined ? (
|
||||
<div className="flex flex-col xl:flex-row xl:gap-3">
|
||||
<p className={clsx('text-sm transition', isLoadingSubscription && 'opacity-50')}>
|
||||
Organization is on the {subscription.plan.name} plan
|
||||
</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
{billingCycleStart.format('DD MMM YYYY')} - {billingCycleEnd.format('DD MMM YYYY')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScaffoldContainer>
|
||||
|
||||
<ScaffoldDivider />
|
||||
|
||||
<Bandwidth
|
||||
projectRef={selectedProjectRef}
|
||||
subscription={subscription}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
currentBillingCycleSelected={currentBillingCycleSelected}
|
||||
/>
|
||||
|
||||
<SizeAndCounts
|
||||
projectRef={selectedProjectRef}
|
||||
subscription={subscription}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
currentBillingCycleSelected={currentBillingCycleSelected}
|
||||
/>
|
||||
<Activity
|
||||
projectRef={selectedProjectRef}
|
||||
subscription={subscription}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
currentBillingCycleSelected={currentBillingCycleSelected}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Usage
|
||||
@@ -0,0 +1,111 @@
|
||||
import { DataPoint } from 'data/analytics/constants'
|
||||
import { ProjectUsageResponse } from 'data/usage/project-usage-query'
|
||||
import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/BillingV2/Billing.constants'
|
||||
import { CategoryAttribute, USAGE_STATUS } from './Usage.constants'
|
||||
import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query'
|
||||
|
||||
// [Joshen] This is just for development to generate some test data for chart rendering
|
||||
export const generateUsageData = (attribute: string, days: number): DataPoint[] => {
|
||||
const tempArray = new Array(days).fill(0)
|
||||
return tempArray.map((x, idx) => {
|
||||
return {
|
||||
loopId: (idx + 1).toString(),
|
||||
period_start: `${idx + 1}`,
|
||||
[attribute]: Math.floor(Math.random() * 100).toString(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getUsageStatus = (attributes: CategoryAttribute[], usage?: ProjectUsageResponse) => {
|
||||
if (!usage) return USAGE_STATUS.NORMAL
|
||||
|
||||
const attributeStatuses = attributes.map((attribute) => {
|
||||
const usageMeta = usage?.[attribute.key as keyof ProjectUsageResponse]
|
||||
const usageRatio =
|
||||
typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0
|
||||
if (usageRatio >= 1) return USAGE_STATUS.EXCEEDED
|
||||
else if (usageRatio >= USAGE_APPROACHING_THRESHOLD) return USAGE_STATUS.APPROACHING
|
||||
else return USAGE_STATUS.NORMAL
|
||||
})
|
||||
|
||||
if (attributeStatuses.find((x) => x === USAGE_STATUS.EXCEEDED)) return USAGE_STATUS.EXCEEDED
|
||||
else if (attributeStatuses.find((x) => x === USAGE_STATUS.APPROACHING))
|
||||
return USAGE_STATUS.APPROACHING
|
||||
else return USAGE_STATUS.NORMAL
|
||||
}
|
||||
|
||||
export const getUpgradeUrl = (projectRef: string, subscription?: ProjectSubscriptionResponse) => {
|
||||
if (!subscription) {
|
||||
return `/project/${projectRef}/settings/billing/subscription`
|
||||
}
|
||||
|
||||
return subscription?.plan?.id === 'pro' && subscription?.usage_billing_enabled === false
|
||||
? `/project/${projectRef}/settings/billing/subscription#cost-control`
|
||||
: `/project/${projectRef}/settings/billing/subscription?panel=subscriptionPlan`
|
||||
}
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
})
|
||||
|
||||
/**
|
||||
* For the y-axis, we don't need to be as precise, to avoid showing 58.597MB.
|
||||
*/
|
||||
export const ChartYFormatterCompactNumber = (number: number | string, unit: string) => {
|
||||
if (typeof number === 'string') return number
|
||||
|
||||
if (unit === 'bytes') {
|
||||
const formattedBytes = formatBytesCompact(number).replace(/\s/g, '')
|
||||
|
||||
return formattedBytes === '0bytes' ? '0' : formattedBytes
|
||||
} else {
|
||||
return compactNumberFormatter.format(number)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For the chart tooltip, we want to be more precise and show more decimals.
|
||||
*/
|
||||
export const ChartTooltipValueFormatter = (number: number | string, unit: string) => {
|
||||
if (typeof number === 'string') return number
|
||||
|
||||
if (unit === 'bytes') {
|
||||
const formattedBytes = formatBytesPrecision(number).replace(/\s/g, '')
|
||||
|
||||
return formattedBytes === '0bytes' ? '0' : formattedBytes
|
||||
} else {
|
||||
return compactNumberFormatter.format(number)
|
||||
}
|
||||
}
|
||||
|
||||
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
export const formatBytesCompact = (bytes: number) => {
|
||||
if (bytes === 0 || bytes === undefined) return '0 bytes'
|
||||
|
||||
const k = 1024
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
const unit = sizes[i]
|
||||
|
||||
let dm = 2
|
||||
if (['bytes', 'KB', 'MB'].includes(unit)) {
|
||||
dm = 0
|
||||
} else if (['GB'].includes(unit)) {
|
||||
dm = 1
|
||||
}
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + unit
|
||||
}
|
||||
|
||||
export const formatBytesPrecision = (bytes: any) => {
|
||||
if (bytes === 0 || bytes === undefined) return '0 bytes'
|
||||
|
||||
const k = 1024
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
const unit = sizes[i]
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(3)) + ' ' + unit
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { DataPoint } from 'data/analytics/constants'
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import dayjs from 'dayjs'
|
||||
import clsx from 'clsx'
|
||||
import { COLOR_MAP, Attribute } from './Usage.constants'
|
||||
import { MultiAttributeTooltipContent, SingleAttributeTooltipContent } from './UsageChartTooltips'
|
||||
|
||||
// [Joshen] This BarChart is specifically for usage, hence not a reusable component, and not
|
||||
// replacing the existing BarChart in ui/Charts
|
||||
|
||||
export interface UsageBarChartProps {
|
||||
data: DataPoint[]
|
||||
name: string // Used within the tooltip
|
||||
attributes: Attribute[]
|
||||
unit: 'bytes' | 'absolute' | 'percentage'
|
||||
yLimit?: number
|
||||
yLeftMargin?: number
|
||||
yFormatter?: (value: number | string) => string
|
||||
tooltipFormatter?: (value: number | string) => string
|
||||
}
|
||||
|
||||
const UsageBarChart = ({
|
||||
data,
|
||||
name,
|
||||
attributes,
|
||||
unit,
|
||||
yLimit,
|
||||
yLeftMargin = 10,
|
||||
yFormatter,
|
||||
tooltipFormatter,
|
||||
}: UsageBarChartProps) => {
|
||||
const yMin = 0 // We can consider passing this as a prop if there's a use case in the future
|
||||
const yDomain = [yMin, yLimit ?? 0]
|
||||
|
||||
return (
|
||||
<div className="w-full h-[200px]">
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<ComposedChart data={data} margin={{ top: 0, right: 0, left: yLeftMargin, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-scale-800" />
|
||||
<XAxis dataKey="periodStartFormatted" />
|
||||
<YAxis
|
||||
width={40}
|
||||
axisLine={false}
|
||||
tickLine={{ stroke: 'none' }}
|
||||
domain={yDomain}
|
||||
tickFormatter={yFormatter}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(props) => {
|
||||
const { active, payload } = props
|
||||
if (active && payload && payload.length) {
|
||||
const dataPeriod = dayjs(payload[0].payload.period_start)
|
||||
const isAfterToday = dataPeriod.startOf('day').isAfter(dayjs().startOf('day'))
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'border bg-scale-300 rounded-md px-2 py-2',
|
||||
attributes.length > 1 && !isAfterToday ? 'w-[250px]' : 'w-[170px]'
|
||||
)}
|
||||
>
|
||||
{attributes.length > 1 ? (
|
||||
<MultiAttributeTooltipContent
|
||||
attributes={attributes}
|
||||
values={payload}
|
||||
isAfterToday={isAfterToday}
|
||||
tooltipFormatter={tooltipFormatter}
|
||||
/>
|
||||
) : (
|
||||
<SingleAttributeTooltipContent
|
||||
name={name}
|
||||
unit={unit}
|
||||
value={payload[0].value}
|
||||
tooltipFormatter={tooltipFormatter}
|
||||
isAfterToday={isAfterToday}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-scale-1100 mt-1">
|
||||
{dataPeriod.format('DD MMM YYYY')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
} else return null
|
||||
}}
|
||||
/>
|
||||
{attributes?.map((attr) => (
|
||||
<Bar key={attr.key} dataKey={attr.key} stackId="a">
|
||||
{data.map((entry) => {
|
||||
return <Cell key={`${entry.period_start}`} className={COLOR_MAP[attr.color].bar} />
|
||||
})}
|
||||
</Bar>
|
||||
))}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsageBarChart
|
||||
@@ -0,0 +1,80 @@
|
||||
import clsx from 'clsx'
|
||||
import { Payload, ValueType } from 'recharts/types/component/DefaultTooltipContent'
|
||||
import { Attribute, COLOR_MAP } from './Usage.constants'
|
||||
|
||||
export interface SingleAttributeTooltipContentProps {
|
||||
name: string
|
||||
unit: 'bytes' | 'percentage' | 'absolute'
|
||||
value: any
|
||||
isAfterToday: boolean
|
||||
tooltipFormatter?: (value: any) => any
|
||||
}
|
||||
|
||||
export const SingleAttributeTooltipContent = ({
|
||||
name,
|
||||
unit,
|
||||
value,
|
||||
isAfterToday,
|
||||
tooltipFormatter,
|
||||
}: SingleAttributeTooltipContentProps) => {
|
||||
const formattedValue = unit === 'percentage' ? Number(value).toFixed(2) : Number(value)
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs text-scale-1000">{name}</p>
|
||||
{isAfterToday ? (
|
||||
<p className="text-scale-1000 text-lg">No data yet</p>
|
||||
) : (
|
||||
<p className="text-xl">
|
||||
{tooltipFormatter !== undefined ? tooltipFormatter(formattedValue) : formattedValue}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export interface MultiAttributeTooltipContentProps {
|
||||
attributes: Attribute[]
|
||||
values: Payload<ValueType, string | number>[]
|
||||
isAfterToday: boolean
|
||||
tooltipFormatter?: (value: any) => any
|
||||
}
|
||||
|
||||
export const MultiAttributeTooltipContent = ({
|
||||
attributes,
|
||||
values,
|
||||
isAfterToday,
|
||||
tooltipFormatter,
|
||||
}: MultiAttributeTooltipContentProps) => {
|
||||
return (
|
||||
<>
|
||||
{isAfterToday ? (
|
||||
<p className="text-scale-1000 text-lg">No data yet</p>
|
||||
) : (
|
||||
<div className="space-y-1 pb-1">
|
||||
{attributes.map((attr) => {
|
||||
const attrMeta = values.find((x) => x.dataKey === attr.key)
|
||||
const attrValue = Number(attrMeta?.value ?? 0)
|
||||
const sumValue = values.reduce((a, b) => a + Number(b.value), 0)
|
||||
const percentageContribution = ((attrValue / sumValue) * 100).toFixed(1)
|
||||
|
||||
return (
|
||||
<div key={attr.name} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 w-[200px]">
|
||||
<div
|
||||
className={clsx('w-3 h-3 rounded-full border', COLOR_MAP[attr.color].marker)}
|
||||
/>
|
||||
<p className="text-sm prose">
|
||||
{attr.name} ({percentageContribution}%):{' '}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm tabular-nums">
|
||||
{tooltipFormatter !== undefined ? tooltipFormatter(attrValue) : attrValue}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/BillingV2/Billing.constants'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import SparkBar from 'components/ui/SparkBar'
|
||||
import { DataPoint } from 'data/analytics/constants'
|
||||
import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query'
|
||||
import {
|
||||
ProjectUsageResponse,
|
||||
UsageMetric,
|
||||
useProjectUsageQuery,
|
||||
} from 'data/usage/project-usage-query'
|
||||
import { formatBytes } from 'lib/helpers'
|
||||
import { Button, IconAlertTriangle, IconBarChart2 } from 'ui'
|
||||
import SectionContent from './SectionContent'
|
||||
import SectionHeader from './SectionHeader'
|
||||
import { CategoryMetaKey, USAGE_CATEGORIES } from './Usage.constants'
|
||||
import {
|
||||
ChartTooltipValueFormatter,
|
||||
ChartYFormatterCompactNumber,
|
||||
getUpgradeUrl,
|
||||
} from './Usage.utils'
|
||||
import UsageBarChart from './UsageBarChart'
|
||||
import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold'
|
||||
|
||||
interface UsageSectionProps {
|
||||
projectRef: string
|
||||
categoryKey: CategoryMetaKey
|
||||
subscription: ProjectSubscriptionResponse | undefined
|
||||
chartMeta: {
|
||||
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean }
|
||||
}
|
||||
currentBillingCycleSelected: boolean
|
||||
}
|
||||
|
||||
// [Joshen TODO] Needs to change to use org slug, and eventually call useOrganizationUsageQuery
|
||||
const UsageSection = ({
|
||||
projectRef,
|
||||
categoryKey,
|
||||
chartMeta,
|
||||
subscription,
|
||||
currentBillingCycleSelected,
|
||||
}: UsageSectionProps) => {
|
||||
const { data: usage } = useProjectUsageQuery({ projectRef })
|
||||
const categoryMeta = USAGE_CATEGORIES.find((category) => category.key === categoryKey)
|
||||
|
||||
if (!categoryMeta) return null
|
||||
|
||||
const usageBasedBilling = subscription?.usage_billing_enabled
|
||||
const exceededLimitStyle = !usageBasedBilling ? 'text-red-900' : 'text-amber-900'
|
||||
|
||||
const upgradeUrl = getUpgradeUrl(projectRef, subscription)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldContainer>
|
||||
<SectionHeader title={categoryMeta.name} description={categoryMeta.description} />
|
||||
</ScaffoldContainer>
|
||||
|
||||
<ScaffoldDivider />
|
||||
|
||||
{categoryMeta.attributes.map((attribute) => {
|
||||
const usageMeta = usage?.[attribute.key as keyof ProjectUsageResponse] as UsageMetric
|
||||
const usageRatio =
|
||||
typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0
|
||||
const usageExcess = (usageMeta?.usage ?? 0) - (usageMeta?.limit ?? 0)
|
||||
|
||||
const chartData = chartMeta[attribute.key]?.data ?? []
|
||||
|
||||
const notAllValuesZero =
|
||||
attribute.attributes
|
||||
?.map((attr) => {
|
||||
return chartData.some((dataPoint) => Number(dataPoint[attr.key]) !== 0)
|
||||
})
|
||||
.some((x) => !!x) ?? false
|
||||
|
||||
return (
|
||||
<div id={attribute.anchor} key={attribute.key}>
|
||||
<SectionContent section={attribute}>
|
||||
{usageMeta?.available_in_plan ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<p className="text-sm">{attribute.name} usage</p>
|
||||
{currentBillingCycleSelected &&
|
||||
usageBasedBilling === false &&
|
||||
usageRatio >= USAGE_APPROACHING_THRESHOLD && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
{!usageBasedBilling && usageRatio >= 1 ? (
|
||||
<div className="flex items-center space-x-2 min-w-[115px] cursor-help">
|
||||
<IconAlertTriangle
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
className={exceededLimitStyle}
|
||||
/>
|
||||
<p className={`text-sm ${exceededLimitStyle}`}>
|
||||
Exceeded limit
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
!usageBasedBilling &&
|
||||
usageRatio >= USAGE_APPROACHING_THRESHOLD && (
|
||||
<div className="flex items-center space-x-2 min-w-[115px] cursor-help">
|
||||
<IconAlertTriangle
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
className="text-amber-900"
|
||||
/>
|
||||
<p className="text-sm text-amber-900">Approaching limit</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className="text-xs text-scale-1200">
|
||||
Exceeding your plans included usage will lead to restrictions
|
||||
to your project.
|
||||
</p>
|
||||
<p className="text-xs text-scale-1200">
|
||||
Upgrade to a usage-based plan or disable the spend cap to
|
||||
avoid restrictions.
|
||||
</p>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentBillingCycleSelected &&
|
||||
!usageBasedBilling &&
|
||||
usageRatio >= USAGE_APPROACHING_THRESHOLD && (
|
||||
<Link href={upgradeUrl}>
|
||||
<a className="pb-1">
|
||||
<Button type="default" size="tiny">
|
||||
{subscription?.plan?.id === 'free'
|
||||
? 'Upgrade plan'
|
||||
: 'Change spend cap'}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{currentBillingCycleSelected && usageMeta?.limit > 0 && (
|
||||
<SparkBar
|
||||
type="horizontal"
|
||||
barClass={clsx(
|
||||
usageRatio >= 1
|
||||
? usageBasedBilling
|
||||
? 'bg-scale-1100'
|
||||
: 'bg-red-900'
|
||||
: usageBasedBilling === false &&
|
||||
usageRatio >= USAGE_APPROACHING_THRESHOLD
|
||||
? 'bg-amber-900'
|
||||
: 'bg-scale-1100'
|
||||
)}
|
||||
bgClass="bg-gray-300 dark:bg-gray-600"
|
||||
value={usageMeta?.usage ?? 0}
|
||||
max={usageMeta?.limit || 1}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b py-1">
|
||||
<p className="text-xs text-scale-1000">
|
||||
Included in {subscription?.plan?.name.toLowerCase()} plan
|
||||
</p>
|
||||
{usageMeta?.limit === -1 ? (
|
||||
<p className="text-xs">None</p>
|
||||
) : usageMeta?.limit === 0 ? (
|
||||
<p className="text-xs">Unlimited</p>
|
||||
) : (
|
||||
<p className="text-xs">
|
||||
{attribute.unit === 'bytes'
|
||||
? formatBytes(usageMeta?.limit ?? 0)
|
||||
: (usageMeta?.limit ?? 0).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{currentBillingCycleSelected && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<p className="text-xs text-scale-1000">
|
||||
{attribute.chartPrefix || 'Used '}in period
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{attribute.unit === 'bytes'
|
||||
? formatBytes(usageMeta?.usage ?? 0)
|
||||
: (usageMeta?.usage ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{currentBillingCycleSelected && usageMeta?.limit > 0 && (
|
||||
<div className="flex items-center justify-between border-t py-1">
|
||||
<p className="text-xs text-scale-1000">Overage in period</p>
|
||||
<p className="text-xs">
|
||||
{(usageMeta?.limit ?? 0) === -1 || usageExcess < 0
|
||||
? 0
|
||||
: attribute.unit === 'bytes'
|
||||
? formatBytes(usageExcess)
|
||||
: usageExcess.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attribute.additionalInfo?.(projectRef, subscription, usage)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
{attribute.chartPrefix || ''}
|
||||
{attribute.name} per day
|
||||
</p>
|
||||
{attribute.chartDescription.split('\n').map((paragraph, idx) => (
|
||||
<p key={`para-${idx}`} className="text-sm text-scale-1000">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{chartMeta[attribute.key].isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
) : chartData.length > 0 && notAllValuesZero ? (
|
||||
<UsageBarChart
|
||||
name={`${attribute.chartPrefix || ''}${attribute.name}`}
|
||||
unit={attribute.unit}
|
||||
attributes={attribute.attributes}
|
||||
data={chartData}
|
||||
yLeftMargin={chartMeta[attribute.key].margin}
|
||||
yFormatter={(value) => ChartYFormatterCompactNumber(value, attribute.unit)}
|
||||
tooltipFormatter={(value) =>
|
||||
ChartTooltipValueFormatter(value, attribute.unit)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Panel>
|
||||
<Panel.Content>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<IconBarChart2 className="text-scale-1100 mb-2" />
|
||||
<p className="text-sm">No data in period</p>
|
||||
<p className="text-sm text-scale-1000">May take up to 24 hours to show</p>
|
||||
</div>
|
||||
</Panel.Content>
|
||||
</Panel>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Panel>
|
||||
<Panel.Content>
|
||||
<div className="flex w-full items-center flex-col justify-center space-y-2 md:flex-row md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">Not included in plan</p>
|
||||
<div>
|
||||
<p className="text-sm text-scale-1100">
|
||||
You need to be on a higher plan in order to use this feature.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/project/${projectRef}/settings/billing/subscription?panel=subscriptionPlan`}
|
||||
>
|
||||
<a>
|
||||
<Button type="primary">Upgrade plan</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</Panel.Content>
|
||||
</Panel>
|
||||
)}
|
||||
</SectionContent>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsageSection
|
||||
Whitespace-only changes.
@@ -1,14 +1,17 @@
|
||||
import BillingSettings from './BillingSettings/BillingSettings'
|
||||
import BillingSettingsV2 from './BillingSettingsV2/BillingSettings'
|
||||
import GeneralSettings from './GeneralSettings/GeneralSettings'
|
||||
import IntegrationSettings from './IntegrationSettings/IntegrationSettings'
|
||||
import InvoicesSettings from './InvoicesSettings/InvoicesSettings'
|
||||
import NewOrgForm from './NewOrg/NewOrgForm'
|
||||
import Usage from './Usage/Usage'
|
||||
import AuditLogs from './AuditLogs/AuditLogs'
|
||||
import OAuthApps from './OAuthApps/OAuthApps'
|
||||
import TeamSettings from './TeamSettings/TeamSettings'
|
||||
|
||||
export {
|
||||
BillingSettings,
|
||||
BillingSettingsV2,
|
||||
GeneralSettings,
|
||||
IntegrationSettings,
|
||||
InvoicesSettings,
|
||||
@@ -16,4 +19,5 @@ export {
|
||||
OAuthApps,
|
||||
AuditLogs,
|
||||
TeamSettings,
|
||||
Usage,
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
const showOAuthApps = useFlag('oauthApps')
|
||||
const showAuditLogs = useFlag('auditLogs')
|
||||
const showIntegrationsV2 = useFlag('integrationsV2')
|
||||
const isOrgBilling = !!selectedOrganization?.subscription_id
|
||||
|
||||
return (
|
||||
<AccountLayout
|
||||
@@ -41,6 +42,7 @@ const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
<Tabs.Panel id="integrations" label="Integrations" className="!my-0" />
|
||||
)}
|
||||
<Tabs.Panel id="billing" label="Billing" className="!my-0" />
|
||||
{isOrgBilling && <Tabs.Panel id="usage" label="Usage" />}
|
||||
<Tabs.Panel id="invoices" label="Invoices" className="!my-0" />
|
||||
{showOAuthApps && <Tabs.Panel id="apps" label="OAuth Apps" className="!my-0" />}
|
||||
{showAuditLogs && <Tabs.Panel id="audit" label="Audit Logs" className="!my-0" />}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Children } from 'react'
|
||||
|
||||
const FormSection = ({
|
||||
children,
|
||||
id,
|
||||
header,
|
||||
disabled,
|
||||
visible = true,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
id?: string
|
||||
header?: React.ReactNode
|
||||
disabled?: boolean
|
||||
visible?: boolean
|
||||
@@ -21,7 +23,7 @@ const FormSection = ({
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={classes.join(' ')}>
|
||||
<div id={id} className={classes.join(' ')}>
|
||||
{header}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -14,4 +14,5 @@ export const organizationKeys = {
|
||||
migrateBilling: (slug: string | undefined) => ['organizations', slug, 'migrate-billing'] as const,
|
||||
migrateBillingPreview: (slug: string | undefined) =>
|
||||
['organizations', slug, 'migrate-billing', 'preview'] as const,
|
||||
taxIds: (slug: string | undefined) => ['organizations', slug, 'tax-ids'] as const,
|
||||
}
|
||||
@@ -3,13 +3,21 @@ import { get } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { useCallback } from 'react'
|
||||
import { organizationKeys } from './keys'
|
||||
import { ResponseError } from 'types'
|
||||
|
||||
export type OrganizationCustomerProfileVariables = {
|
||||
slug?: string
|
||||
}
|
||||
|
||||
export type OrganizationCustomerProfileResponse = {
|
||||
address: string | null
|
||||
address: {
|
||||
city: string | null
|
||||
country: string | null
|
||||
line1: string | null
|
||||
line2: string | null
|
||||
postal_code: string | null
|
||||
state: string | null
|
||||
} | null
|
||||
balance: number
|
||||
email: string
|
||||
id: string
|
||||
@@ -33,7 +41,7 @@ export async function getOrganizationCustomerProfile(
|
||||
export type OrganizationCustomerProfileData = Awaited<
|
||||
ReturnType<typeof getOrganizationCustomerProfile>
|
||||
>
|
||||
export type OrganizationCustomerProfileError = unknown
|
||||
export type OrganizationCustomerProfileError = ResponseError
|
||||
|
||||
export const useOrganizationCustomerProfileQuery = <TData = OrganizationCustomerProfileData>(
|
||||
{ slug }: OrganizationCustomerProfileVariables,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { patch } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { organizationKeys } from './keys'
|
||||
|
||||
export type OrganizationCustomerProfileUpdateVariables = {
|
||||
slug: string
|
||||
address?: {
|
||||
city: string | null
|
||||
country: string | null
|
||||
line1: string | null
|
||||
line2: string | null
|
||||
postal_code: string | null
|
||||
state: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export async function updateOrganizationCustomerProfile({
|
||||
slug,
|
||||
address,
|
||||
}: OrganizationCustomerProfileUpdateVariables) {
|
||||
if (!slug) return console.error('Slug is required')
|
||||
|
||||
const response = await patch(`${API_URL}/organizations/${slug}/customer`, { address })
|
||||
if (response.error) throw response.error
|
||||
return response
|
||||
}
|
||||
|
||||
type OrganizationCustomerProfileUpdateData = Awaited<
|
||||
ReturnType<typeof updateOrganizationCustomerProfile>
|
||||
>
|
||||
|
||||
export const useOrganizationCustomerProfileUpdateMutation = ({
|
||||
onSuccess,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<
|
||||
OrganizationCustomerProfileUpdateData,
|
||||
unknown,
|
||||
OrganizationCustomerProfileUpdateVariables
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<
|
||||
OrganizationCustomerProfileUpdateData,
|
||||
unknown,
|
||||
OrganizationCustomerProfileUpdateVariables
|
||||
>((vars) => updateOrganizationCustomerProfile(vars), {
|
||||
async onSuccess(data, variables, context) {
|
||||
const { slug } = variables
|
||||
await queryClient.invalidateQueries(organizationKeys.customerProfile(slug))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { delete_ } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { organizationKeys } from './keys'
|
||||
import { ResponseError } from 'types'
|
||||
|
||||
export type OrganizationPaymentMethodDeleteVariables = {
|
||||
slug: string
|
||||
cardId: string
|
||||
}
|
||||
|
||||
export async function deleteOrganizationMember({
|
||||
slug,
|
||||
cardId,
|
||||
}: OrganizationPaymentMethodDeleteVariables) {
|
||||
const response = await delete_(`${API_URL}/organizations/${slug}/payments`, { card_id: cardId })
|
||||
if (response.error) throw response.error
|
||||
return response
|
||||
}
|
||||
|
||||
type OrganizationPaymentMethodDeleteData = Awaited<ReturnType<typeof deleteOrganizationMember>>
|
||||
|
||||
export const useOrganizationPaymentMethodDeleteMutation = ({
|
||||
onSuccess,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<
|
||||
OrganizationPaymentMethodDeleteData,
|
||||
unknown,
|
||||
OrganizationPaymentMethodDeleteVariables
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<
|
||||
OrganizationPaymentMethodDeleteData,
|
||||
unknown,
|
||||
OrganizationPaymentMethodDeleteVariables
|
||||
>((vars) => deleteOrganizationMember(vars), {
|
||||
async onSuccess(data, variables, context) {
|
||||
const { slug } = variables
|
||||
await queryClient.invalidateQueries(organizationKeys.paymentMethods(slug))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useCallback } from 'react'
|
||||
import { organizationKeys } from './keys'
|
||||
|
||||
export type OrganizationPaymentMethodsVariables = { slug?: string }
|
||||
export type OrganizationPaymentMethodsResponse = {
|
||||
export type OrganizationPaymentMethod = {
|
||||
id: string
|
||||
customer: string
|
||||
type: string
|
||||
@@ -29,7 +29,7 @@ export type OrganizationPaymentMethodsResponse = {
|
||||
name: string | null
|
||||
phone: string | null
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
export async function getOrganizations(
|
||||
{ slug }: OrganizationPaymentMethodsVariables,
|
||||
@@ -40,7 +40,7 @@ export async function getOrganizations(
|
||||
const response = await get(`${API_URL}/organizations/${slug}/payments`, { signal })
|
||||
if (response.error) throw response.error
|
||||
|
||||
return response.data as OrganizationPaymentMethodsResponse
|
||||
return response.data as OrganizationPaymentMethod[]
|
||||
}
|
||||
|
||||
export type OrganizationPaymentMethodsData = Awaited<ReturnType<typeof getOrganizations>>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { get } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { useCallback } from 'react'
|
||||
import { organizationKeys } from './keys'
|
||||
import { ResponseError } from 'types'
|
||||
|
||||
export type OrganizationTaxIDsVariables = {
|
||||
slug?: string
|
||||
}
|
||||
|
||||
export type TaxId = {
|
||||
country: string
|
||||
created: number
|
||||
customer: string
|
||||
id: string
|
||||
livemode: boolean
|
||||
object: 'tax_id'
|
||||
type: string
|
||||
value: string
|
||||
verification: {
|
||||
status: string
|
||||
verified_address: string | null
|
||||
verified_name: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrganizationTaxIDs(
|
||||
{ slug }: OrganizationTaxIDsVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!slug) throw new Error('slug is required')
|
||||
|
||||
const data = await get(`${API_URL}/organizations/${slug}/tax-ids`, { signal })
|
||||
if (data.error) throw data.error
|
||||
|
||||
return data.data as TaxId[]
|
||||
}
|
||||
|
||||
export type OrganizationTaxIDsData = Awaited<ReturnType<typeof getOrganizationTaxIDs>>
|
||||
export type OrganizationTaxIDsError = ResponseError
|
||||
|
||||
export const useOrganizationTaxIDsQuery = <TData = OrganizationTaxIDsData>(
|
||||
{ slug }: OrganizationTaxIDsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<OrganizationTaxIDsData, OrganizationTaxIDsError, TData> = {}
|
||||
) =>
|
||||
useQuery<OrganizationTaxIDsData, OrganizationTaxIDsError, TData>(
|
||||
organizationKeys.taxIds(slug),
|
||||
({ signal }) => getOrganizationTaxIDs({ slug }, signal),
|
||||
{
|
||||
enabled: enabled && typeof slug !== 'undefined',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
|
||||
export const useOrganizationTaxIDsPrefetch = ({ slug }: OrganizationTaxIDsVariables) => {
|
||||
const client = useQueryClient()
|
||||
|
||||
return useCallback(() => {
|
||||
if (slug) {
|
||||
client.prefetchQuery(organizationKeys.taxIds(slug), ({ signal }) =>
|
||||
getOrganizationTaxIDs({ slug }, signal)
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slug])
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { delete_, post } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { organizationKeys } from './keys'
|
||||
import { TaxId } from './organization-tax-ids-query'
|
||||
|
||||
export interface TaxIdValue {
|
||||
id: string
|
||||
type: string
|
||||
value: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type OrganizationTaxIDsUpdateVariables = {
|
||||
slug: string
|
||||
existingIds: TaxId[]
|
||||
newIds: TaxIdValue[]
|
||||
}
|
||||
|
||||
export async function updateOrganizationTaxIDs({
|
||||
slug,
|
||||
existingIds,
|
||||
newIds,
|
||||
}: OrganizationTaxIDsUpdateVariables) {
|
||||
if (!slug) {
|
||||
console.error('Slug is required')
|
||||
return {}
|
||||
}
|
||||
|
||||
// To make things simple we delete all existing ones and create new ones from this session
|
||||
if (existingIds.length > 0) {
|
||||
await Promise.all(
|
||||
existingIds.map(async (taxId) => {
|
||||
return await delete_(`${API_URL}/organizations/${slug}/tax-ids`, { id: taxId.id })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const createdIds = await Promise.all(
|
||||
newIds.map(async (taxId) => {
|
||||
const result = await post(`${API_URL}/organizations/${slug}/tax-ids`, {
|
||||
type: taxId.type,
|
||||
value: taxId.value,
|
||||
})
|
||||
return { id: taxId.id, result }
|
||||
})
|
||||
)
|
||||
|
||||
const taxIdsWithErrors = createdIds.filter((taxId) => {
|
||||
if (taxId.result.error) return taxId
|
||||
})
|
||||
|
||||
const idsCreated = createdIds
|
||||
.filter((taxId: any) => !taxId.result.error)
|
||||
.map((taxId: any) => taxId.result)
|
||||
|
||||
return { created: idsCreated, errors: taxIdsWithErrors }
|
||||
}
|
||||
|
||||
type OrganizationTaxIDsUpdateData = Awaited<ReturnType<typeof updateOrganizationTaxIDs>>
|
||||
|
||||
export const useOrganizationTaxIDsUpdateMutation = ({
|
||||
onSuccess,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<OrganizationTaxIDsUpdateData, unknown, OrganizationTaxIDsUpdateVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<OrganizationTaxIDsUpdateData, unknown, OrganizationTaxIDsUpdateVariables>(
|
||||
(vars) => updateOrganizationTaxIDs(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { slug } = variables
|
||||
await queryClient.invalidateQueries(organizationKeys.customerProfile(slug))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
// [Joshen TODO] To remove subscription V2 after v2 is launched and stable to deprecate old billing ui + endpoints
|
||||
|
||||
export const subscriptionKeys = {
|
||||
subscriptionV2: (projectRef: string | undefined) =>
|
||||
['projects', projectRef, 'subscriptionV2'] as const,
|
||||
orgSubscription: (orgSlug: string | undefined) =>
|
||||
['organizations', orgSlug, 'subscription'] as const,
|
||||
orgPlans: (orgSlug: string | undefined) => ['organizations', orgSlug, 'plans'] as const,
|
||||
|
||||
subscriptionV2: (projectRef: string | undefined) =>
|
||||
['projects', projectRef, 'subscriptionV2'] as const,
|
||||
addons: (projectRef: string | undefined) => ['projects', projectRef, 'addons'] as const,
|
||||
plans: (projectRef: string | undefined) => ['projects', projectRef, 'plans'] as const,
|
||||
preview: (projectRef: string | undefined) =>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { get } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { useCallback } from 'react'
|
||||
import { subscriptionKeys } from './keys'
|
||||
|
||||
export type OrgPlansVariables = {
|
||||
orgSlug?: string
|
||||
}
|
||||
|
||||
export type OrgPlansResponse = {
|
||||
id: 'free' | 'pro' | 'team' | 'enterprise'
|
||||
name: string
|
||||
price: number
|
||||
is_current: boolean
|
||||
change_type: 'downgrade' | 'upgrade' | 'none'
|
||||
effective_at: string
|
||||
}[]
|
||||
|
||||
export async function getOrgPlans({ orgSlug }: OrgPlansVariables, signal?: AbortSignal) {
|
||||
if (!orgSlug) throw new Error('orgSlug is required')
|
||||
|
||||
const response = await get(`${API_URL}/organizations/${orgSlug}/billing/plans`, { signal })
|
||||
if (response.error) throw response.error
|
||||
|
||||
return response.plans as OrgPlansResponse
|
||||
}
|
||||
|
||||
export type OrgPlansData = Awaited<ReturnType<typeof getOrgPlans>>
|
||||
export type OrgPlansError = unknown
|
||||
|
||||
export const useOrgPlansQuery = <TData = OrgPlansData>(
|
||||
{ orgSlug }: OrgPlansVariables,
|
||||
{ enabled = true, ...options }: UseQueryOptions<OrgPlansData, OrgPlansError, TData> = {}
|
||||
) =>
|
||||
useQuery<OrgPlansData, OrgPlansError, TData>(
|
||||
subscriptionKeys.orgPlans(orgSlug),
|
||||
({ signal }) => getOrgPlans({ orgSlug }, signal),
|
||||
{
|
||||
enabled: enabled && typeof orgSlug !== 'undefined',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
|
||||
export const useOrgPlansPrefetch = ({ orgSlug }: OrgPlansVariables) => {
|
||||
const client = useQueryClient()
|
||||
|
||||
return useCallback(() => {
|
||||
if (orgSlug) {
|
||||
client.prefetchQuery(subscriptionKeys.orgPlans(orgSlug), ({ signal }) =>
|
||||
getOrgPlans({ orgSlug }, signal)
|
||||
)
|
||||
}
|
||||
}, [orgSlug])
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { get } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { useCallback } from 'react'
|
||||
import { subscriptionKeys } from './keys'
|
||||
import { ResponseError } from 'types'
|
||||
|
||||
export type OrgSubscriptionVariables = {
|
||||
orgSlug?: string
|
||||
@@ -59,7 +60,7 @@ export async function getOrgSubscription(
|
||||
}
|
||||
|
||||
export type OrgSubscriptionData = Awaited<ReturnType<typeof getOrgSubscription>>
|
||||
export type OrgSubscriptionError = unknown
|
||||
export type OrgSubscriptionError = ResponseError
|
||||
|
||||
export const useOrgSubscriptionQuery = <TData = OrgSubscriptionData>(
|
||||
{ orgSlug }: OrgSubscriptionVariables,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { put } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { subscriptionKeys } from './keys'
|
||||
import { SupaResponseV2 } from 'types/base'
|
||||
|
||||
export type SubscriptionTier =
|
||||
| 'tier_free'
|
||||
| 'tier_pro'
|
||||
| 'tier_payg'
|
||||
| 'tier_team'
|
||||
| 'tier_enterprise'
|
||||
|
||||
export type OrgSubscriptionUpdateVariables = {
|
||||
slug: string
|
||||
paymentMethod?: string
|
||||
tier: SubscriptionTier
|
||||
}
|
||||
|
||||
export async function updateOrgSubscription({
|
||||
slug,
|
||||
tier,
|
||||
paymentMethod,
|
||||
}: OrgSubscriptionUpdateVariables) {
|
||||
if (!slug) throw new Error('slug is required')
|
||||
if (!tier) throw new Error('tier is required')
|
||||
|
||||
const payload: { tier: string; payment_method?: string } = { tier }
|
||||
if (paymentMethod !== undefined) payload.payment_method = paymentMethod
|
||||
|
||||
const response = (await put(
|
||||
`${API_URL}/organizations/${slug}/billing/subscription`,
|
||||
payload
|
||||
)) as SupaResponseV2<void>
|
||||
if (typeof response === 'object' && response !== null && 'error' in response) throw response.error
|
||||
return response
|
||||
}
|
||||
|
||||
type OrgSubscriptionUpdateData = Awaited<ReturnType<typeof updateOrgSubscription>>
|
||||
|
||||
export const useOrgSubscriptionUpdateMutation = ({
|
||||
onSuccess,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<OrgSubscriptionUpdateData, unknown, OrgSubscriptionUpdateVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<OrgSubscriptionUpdateData, unknown, OrgSubscriptionUpdateVariables>(
|
||||
(vars) => updateOrgSubscription(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { slug } = variables
|
||||
await queryClient.invalidateQueries(subscriptionKeys.orgSubscription(slug))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { SupaResponseV2 } from 'types'
|
||||
import { Integration } from 'data/integrations/integrations.types'
|
||||
import { ResponseFailure } from 'types'
|
||||
|
||||
type SupaResponse<T> = T | ResponseFailure
|
||||
|
||||
async function fetchGitHub<T = any>(url: string, responseJson = true): Promise<SupaResponse<T>> {
|
||||
async function fetchGitHub<T = any>(url: string, responseJson = true): Promise<SupaResponseV2<T>> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BillingSettings } from 'components/interfaces/Organization'
|
||||
import { BillingSettings, BillingSettingsV2 } from 'components/interfaces/Organization'
|
||||
import { OrganizationLayout } from 'components/layouts'
|
||||
import Loading from 'components/ui/Loading'
|
||||
import { usePermissionsQuery } from 'data/permissions/permissions-query'
|
||||
@@ -8,13 +8,22 @@ import { NextPageWithLayout } from 'types'
|
||||
const OrgBillingSettings: NextPageWithLayout = () => {
|
||||
const { isLoading: isLoadingPermissions } = usePermissionsQuery()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const isOrgBilling = !!selectedOrganization?.subscription_id
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedOrganization === undefined && isLoadingPermissions ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<BillingSettings />
|
||||
<>
|
||||
{isOrgBilling ? (
|
||||
<BillingSettingsV2 />
|
||||
) : (
|
||||
<div className="px-4">
|
||||
<BillingSettings />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Usage } from 'components/interfaces/Organization'
|
||||
import { OrganizationLayout } from 'components/layouts'
|
||||
import Loading from 'components/ui/Loading'
|
||||
import { usePermissionsQuery } from 'data/permissions/permissions-query'
|
||||
import { useSelectedOrganization } from 'hooks'
|
||||
import { NextPageWithLayout } from 'types'
|
||||
|
||||
const OrgUsage: NextPageWithLayout = () => {
|
||||
const { isLoading } = usePermissionsQuery()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
return <>{selectedOrganization === undefined && isLoading ? <Loading /> : <Usage />}</>
|
||||
}
|
||||
|
||||
OrgUsage.getLayout = (page) => <OrganizationLayout>{page}</OrganizationLayout>
|
||||
|
||||
export default OrgUsage
|
||||
@@ -0,0 +1,15 @@
|
||||
import { proxy, snapshot, useSnapshot } from 'valtio'
|
||||
|
||||
export type ORG_SETTINGS_PANEL_KEYS = undefined | 'subscriptionPlan' | 'costControl'
|
||||
|
||||
export const orgSettingsPageState = proxy({
|
||||
panelKey: undefined as ORG_SETTINGS_PANEL_KEYS,
|
||||
setPanelKey: (key: ORG_SETTINGS_PANEL_KEYS) => {
|
||||
orgSettingsPageState.panelKey = key
|
||||
},
|
||||
})
|
||||
|
||||
export const getOrgSettingsPageStateSnapshot = () => snapshot(orgSettingsPageState)
|
||||
|
||||
export const useOrgSettingsPageStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
|
||||
useSnapshot(orgSettingsPageState, options)
|
||||
@@ -83,7 +83,10 @@ export interface ResponseFailure {
|
||||
error: ResponseError
|
||||
}
|
||||
|
||||
// [Joshen] This should ideally be | but not changing now as its gonna cause a lot of build errors
|
||||
export type SupaResponse<T> = T & ResponseFailure
|
||||
// [Joshen] Use this for now, eventually deprecate SupaResponse<T> above
|
||||
export type SupaResponseV2<T> = T | ResponseFailure
|
||||
|
||||
export interface ResponseError {
|
||||
code: number
|
||||
|
||||
Reference in new issue
Block a user