chore: remove old billing code (#18610)

This commit is contained in:
Kevin Grüneberg authored and GitHub committed 2023-11-01 10:52:52 +01:00
1 parent 6416113857
commit 11914a55de
158 files changed
+894 -8600

No files matched your search

+1 -1
View File
@@ -253,7 +253,7 @@ export default function IndexPage() {
{plan.priceMonthly}
</p>
<p className="text-muted mb-1.5 ml-1 text-[13px] leading-4">
{plan.costUnitOrg}
{plan.costUnit}
</p>
</div>
+3 -11
View File
@@ -3,12 +3,10 @@ export interface PricingInformation {
name: string
nameBadge?: string
costUnit?: string
costUnitOrg?: string
href: string
priceLabel?: string
priceMonthly: number | string
warning?: string
warningLegacy?: string
description: string
preface: string
features: string[]
@@ -21,13 +19,11 @@ export const plans: PricingInformation[] = [
id: 'tier_free',
name: 'Free',
nameBadge: '',
costUnit: '/ month / project',
costUnitOrg: '/ month / org',
costUnit: '/ month / org',
href: 'https://supabase.com/dashboard/new?plan=free',
priceLabel: '',
priceMonthly: 0,
warning: 'Limit of 2 free organizations',
warningLegacy: 'Limit of 2 free projects',
description: 'Perfect for passion projects & simple websites.',
preface: 'Get started with:',
features: [
@@ -51,12 +47,10 @@ export const plans: PricingInformation[] = [
id: 'tier_pro',
name: 'Pro',
nameBadge: '',
costUnit: '/ month / project',
costUnitOrg: '/ month / org',
costUnit: '/ month / org',
href: 'https://supabase.com/dashboard/new?plan=pro',
priceLabel: 'From',
warning: 'Usage-based plan',
warningLegacy: 'Usage-based plan',
priceMonthly: 25,
description: 'For production applications with the option to scale.',
features: [
@@ -81,12 +75,10 @@ export const plans: PricingInformation[] = [
id: 'tier_team',
name: 'Team',
nameBadge: 'New',
costUnit: '/ month / project',
costUnitOrg: '/ month / org',
costUnit: '/ month / org',
href: 'https://supabase.com/dashboard/new?plan=team',
priceLabel: 'From',
warning: 'Usage-based plan',
warningLegacy: 'Usage-based plan',
priceMonthly: 599,
description: 'Collaborate with different permissions and access patterns.',
features: [
@@ -0,0 +1,7 @@
export const useOrgSubscriptionQuery = jest.fn().mockReturnValue({
data: {
plan: {
id: 'free',
},
},
})
@@ -1,7 +0,0 @@
export const useProjectSubscriptionV2Query = jest.fn().mockReturnValue({
data: {
plan: {
id: 'free',
},
},
})
@@ -1,6 +1,5 @@
import { OrgSubscription } from 'data/subscriptions/org-subscription-query'
import { ProjectSelectedAddon } from 'data/subscriptions/project-addons-query'
import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query'
export const getAddons = (selectedAddons: ProjectSelectedAddon[]) => {
const computeInstance = selectedAddons.find((addon) => addon.type === 'compute_instance')
@@ -10,9 +9,7 @@ export const getAddons = (selectedAddons: ProjectSelectedAddon[]) => {
return { computeInstance, pitr, customDomain }
}
export const subscriptionHasHipaaAddon = (
subscription: OrgSubscription | ProjectSubscriptionResponse | undefined
): boolean => {
export const subscriptionHasHipaaAddon = (subscription: OrgSubscription | undefined): boolean => {
return (
subscription !== undefined &&
subscription.addons.some((addon) => addon.supabase_prod_id === 'addon_security_hipaa')
@@ -14,11 +14,6 @@ import {
} from 'ui'
import ProjectUpdateDisabledTooltip from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip'
import {
ComputeInstanceSidePanel,
CustomDomainSidePanel,
PITRSidePanel,
} from 'components/interfaces/Settings/Addons'
import {
useIsProjectActive,
useProjectContext,
@@ -31,8 +26,11 @@ import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils'
import { BASE_PATH } from 'lib/constants'
import { getSemanticVersion } from 'lib/helpers'
import { useSubscriptionPageStateSnapshot } from 'state/subscription-page'
import { getAddons } from '../Subscription.utils'
import Image from 'next/image'
import { getAddons } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import ComputeInstanceSidePanel from 'components/interfaces/Settings/Addons/ComputeInstanceSidePanel'
import PITRSidePanel from 'components/interfaces/Settings/Addons/PITRSidePanel'
import CustomDomainSidePanel from 'components/interfaces/Settings/Addons/CustomDomainSidePanel'
const AddOns = () => {
const { resolvedTheme } = useTheme()
@@ -1,456 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import clsx from 'clsx'
import { useParams } from 'common'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import SparkBar from 'components/ui/SparkBar'
import { useUpcomingInvoiceQuery } from 'data/invoices/invoice-upcoming-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useProjectUsageQuery } from 'data/usage/project-usage-query'
import dayjs from 'dayjs'
import { USAGE_APPROACHING_THRESHOLD } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
import { partition } from 'lodash'
import Link from 'next/link'
import { useState } from 'react'
import { useSubscriptionPageStateSnapshot } from 'state/subscription-page'
import { Alert, Button, Collapsible, IconAlertTriangle, IconChevronRight, IconInfo } from 'ui'
import { BILLING_BREAKDOWN_METRICS } from './Subscription.constants'
export interface BillingBreakdownProps {}
const BillingBreakdown = ({}: BillingBreakdownProps) => {
const { ref: projectRef } = useParams()
const snap = useSubscriptionPageStateSnapshot()
const [showUsageFees, setShowUsageFees] = useState(false)
const { data: usage, isLoading: isLoadingUsage } = useProjectUsageQuery({ projectRef })
const { data: subscription, isLoading: isLoadingSubscription } = useProjectSubscriptionV2Query({
projectRef,
})
const {
data: upcomingInvoice,
error: upcomingInvoiceError,
isLoading: isLoadingUpcomingInvoice,
} = useUpcomingInvoiceQuery({
projectRef,
})
const billingCycleStart = dayjs.unix(subscription?.current_period_start ?? 0).utc()
const billingCycleEnd = dayjs.unix(subscription?.current_period_end ?? 0).utc()
const currentPlan = subscription?.plan
const isUsageBillingEnabled = subscription?.usage_billing_enabled
const [usageFees, fixedFees] = partition(upcomingInvoice?.lines ?? [], (item) => item.usage_based)
const totalUsageFees = Number(usageFees.reduce((a, b) => a + b.amount, 0).toFixed(2))
const largeNumberFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 })
const hasExceededAnyLimits =
isUsageBillingEnabled === false &&
Object.values(usage ?? {})
.map((metric) => {
if (typeof metric !== 'object') return false
if (metric.limit <= 0) return false
if (metric.usage > metric.limit) return true
})
.includes(true)
return (
<div className="grid grid-cols-12 gap-6" id="breakdown">
<div className="col-span-12 lg:col-span-5">
<div className="sticky top-16">
<p className="text-base">Billing breakdown</p>
<p className="text-sm text-foreground-light">
Current billing cycle: {billingCycleStart.format('MMM DD')} -{' '}
{billingCycleEnd.format('MMM DD')}
</p>
</div>
</div>
{isLoadingSubscription ? (
<div className="col-span-12 lg:col-span-7 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : (
<div className="col-span-12 lg:col-span-7 space-y-6">
<p className="text-sm">Included usage summary</p>
{isUsageBillingEnabled ? (
<p className="text-sm text-foreground-light">
Your plan includes a limited amount of usage. If the usage on your project exceeds
these quotas, your subscription will be charged for the overages. It may take up to 24
hours for usage stats to update.
</p>
) : (
<p className="text-sm text-foreground-light">
Your plan includes a limited amount of usage. If the usage on your project exceeds
these quotas, you may experience restrictions, as you are currently not billed for
overages. It may take up to 24 hours for usage stats to update.
</p>
)}
{hasExceededAnyLimits && (
<Alert
withIcon
variant="danger"
title="Your project's usage has exceeded its included quota"
actions={[
<Button
key="upgrade-button"
type="default"
className="ml-8"
onClick={() =>
snap.setPanelKey(
currentPlan?.id === 'free' ? 'subscriptionPlan' : 'costControl'
)
}
>
{currentPlan?.id === 'free' ? 'Upgrade plan' : 'Change spend cap'}
</Button>,
]}
>
Your project can become unresponsive or enter read only mode.{' '}
{currentPlan?.id === 'free'
? 'Please upgrade to the Pro plan to ensure that your project remains available.'
: 'Please disable spend cap to ensure that your project remains available.'}
</Alert>
)}
{isLoadingUsage ? (
<div className="col-span-7 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : (
<div className="grid grid-cols-12">
{BILLING_BREAKDOWN_METRICS.map((metric, i) => {
const usageMeta = usage?.[metric.key]
const usageRatio = (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0)
const hasLimit = (usageMeta?.limit ?? 0) > 0
const usageCurrentLabel =
metric.units === 'bytes'
? formatBytes(usageMeta?.usage)
: usageMeta?.usage?.toLocaleString()
const usageLimitLabel =
metric.units === 'bytes'
? formatBytes(usageMeta?.limit)
: usageMeta?.limit.toLocaleString()
const usageLabel = `${usageCurrentLabel} ${hasLimit ? `of ${usageLimitLabel}` : ''}`
const percentageLabel = `${(usageRatio * 100).toFixed(2)}%`
const isApproachingLimit = hasLimit && usageRatio >= USAGE_APPROACHING_THRESHOLD
const isExceededLimit = hasLimit && usageRatio >= 1
const usageFee = subscription?.usage_fees?.find(
(item) => item.metric === metric.metric
)
return (
<div
key={metric.key}
className={clsx(
'col-span-12 md:col-span-6 space-y-4 py-4 border-scale-400',
i % 2 === 0 ? 'md:border-r md:pr-4' : 'md:pl-4',
i < BILLING_BREAKDOWN_METRICS.length - 2 && 'border-b'
)}
>
<div className="flex items-center justify-between">
<Link href={`/project/${projectRef}/settings/billing/usage#${metric.anchor}`}>
<div className="group flex items-center space-x-2">
<p className="text-sm text-foreground-light group-hover:text-foreground transition cursor-pointer">
{metric.name}
</p>
<IconChevronRight
strokeWidth={1.5}
size={16}
className="transition opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0"
/>
</div>
</Link>
{isUsageBillingEnabled && hasLimit && usageFee && (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<div className="flex items-center">
<IconInfo
size={14}
strokeWidth={2}
className="hover:text-foreground-light"
/>
</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(' ')}
>
<div className="text-xs text-foreground">
{usageFee.pricingStrategy === 'UNIT' ? (
<div>
<p>
{largeNumberFormatter.format(
usageFee.pricingOptions.freeUnits
)}{' '}
{metric.unitName} included
</p>
<p>
then ${usageFee.pricingOptions.perUnitPrice} per{' '}
{metric.unitName}
</p>
</div>
) : usageFee.pricingStrategy === 'PACKAGE' ? (
<div>
<p>
{largeNumberFormatter.format(
usageFee.pricingOptions.freeUnits
)}{' '}
included
</p>
<p>
then ${usageFee.pricingOptions.packagePrice} per{' '}
{largeNumberFormatter.format(
usageFee.pricingOptions.packageSize!
)}
</p>
</div>
) : null}
</div>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)}
{isUsageBillingEnabled === false &&
usageRatio >= USAGE_APPROACHING_THRESHOLD && (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
{isExceededLimit && !isUsageBillingEnabled ? (
<div className="flex items-center space-x-2 min-w-[115px] cursor-help">
<IconAlertTriangle
size={14}
strokeWidth={2}
className="text-red-900"
/>
<p className="text-sm text-red-900">Exceeded limit</p>
</div>
) : isApproachingLimit && !isUsageBillingEnabled ? (
<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>
) : null}
</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-foreground">
Exceeding your plans included usage will lead to restrictions to
your project.
</p>
<p className="text-xs text-foreground">
Upgrade to a usage-based plan or disable the spend cap to avoid
restrictions.
</p>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)}
</div>
{usageMeta ? (
usageMeta.available_in_plan ? (
<SparkBar
type="horizontal"
// If the limit is 0, it means that the usage is unlimited and not billed
// By setting "1" as max, the bar is only filled if the metric has any usage
// This is only the case for Enterprise plans
max={usageMeta.limit || 1}
value={usageMeta.usage ?? 0}
barClass={
!hasLimit && usageMeta.usage > 0
? 'bg-scale-1100'
: isExceededLimit && !isUsageBillingEnabled
? 'bg-red-900'
: isApproachingLimit && !isUsageBillingEnabled
? 'bg-amber-900'
: 'bg-scale-1100'
}
bgClass="bg-gray-300 dark:bg-gray-600"
labelBottom={usageLabel}
labelBottomClass="!text-foreground-light"
labelTop={hasLimit ? percentageLabel : undefined}
labelTopClass={
!hasLimit
? ''
: isExceededLimit && !isUsageBillingEnabled
? '!text-red-900'
: isApproachingLimit && !isUsageBillingEnabled
? '!text-amber-900'
: ''
}
/>
) : (
// [Joshen] Needs a better CTA here
<div className="flex items-center justify-between flex-grow">
<p className="text-sm text-foreground-light">Unavailable in your plan</p>
<Button
type="default"
onClick={() => snap.setPanelKey('subscriptionPlan')}
>
Upgrade
</Button>
</div>
)
) : (
<div className="flex items-center justify-between flex-grow">
<p className="text-sm text-foreground-light">
The usage for this metric is missing.
</p>
</div>
)}
</div>
)
})}
</div>
)}
<p className="!mt-10 text-sm">Upcoming cost for next invoice</p>
<p className="text-sm text-foreground-light">
The following table shows your upcoming costs. Depending on your usage, the final amount
may vary. Next invoice on{' '}
<span className="text-foreground-light">{billingCycleEnd.format('MMM DD, YYYY')}</span>.
</p>
{isLoadingUpcomingInvoice ? (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : upcomingInvoiceError !== null ? (
<Alert
withIcon
variant="info"
title="Failed to retrieve upcoming invoice details"
actions={[
<Link
key="contact-support"
href={`/support/new?ref=${projectRef}&category=dashboard_bug&subject=Unable%20to%20view%20upcoming%20invoice%20details`}
>
<Button type="default" className="ml-4">
Contact support
</Button>
</Link>,
]}
>
Try refreshing your browser, but if the issue persists, please reach out to us via
support.
</Alert>
) : (
<table className="w-full">
<thead>
<tr className="border-b">
<th className="py-2 font-normal text-left text-sm text-foreground-light w-1/2">
Item
</th>
<th className="py-2 font-normal text-left text-sm text-foreground-light">
Count
</th>
<th className="py-2 font-normal text-left text-sm text-foreground-light">
Unit price
</th>
<th className="py-2 font-normal text-right text-sm text-foreground-light">
Price
</th>
</tr>
</thead>
<tbody>
{fixedFees.map((item) => (
<tr key={item.description} className="border-b">
<td className="py-2 text-sm">{item.description ?? 'Unknown'}</td>
<td className="py-2 text-sm">{item.quantity}</td>
<td className="py-2 text-sm">
{item.unit_price === 0 ? 'FREE' : `$${item.unit_price}`}
</td>
<td className="py-2 text-sm text-right">${item.amount}</td>
</tr>
))}
</tbody>
{usageFees.length > 0 && (
<Collapsible asChild open={showUsageFees} onOpenChange={setShowUsageFees}>
<tbody>
<Collapsible.Trigger asChild>
<tr
className="border-b cursor-pointer"
style={{ WebkitAppearance: 'initial' }}
>
<td className="py-2 text-sm flex items-center space-x-2">
<Button
type="text"
className="!px-1"
icon={
<IconChevronRight
className={clsx('transition', showUsageFees && 'rotate-90')}
/>
}
/>
<p>Usage items</p>
</td>
<td />
<td />
<td className="text-sm text-right">
{!showUsageFees ? `$${totalUsageFees}` : null}
</td>
</tr>
</Collapsible.Trigger>
<Collapsible.Content asChild>
<>
{usageFees.map((fee) => (
<tr className="border-b" key={fee.description}>
<td className="py-2 text-sm pl-8">{fee.description}</td>
<td className="py-2 text-sm"></td>
<td className="py-2 text-sm">
{fee.unit_price ? `$${fee.unit_price}` : null}
</td>
<td className="py-2 text-sm text-right">${fee.amount ?? 0}</td>
</tr>
))}
</>
</Collapsible.Content>
</tbody>
</Collapsible>
)}
<tbody>
<tr>
<td className="py-2 text-sm">Total</td>
<td className="py-2 text-sm" />
<td className="py-2 text-sm" />
<td className="py-2 text-sm text-right">${upcomingInvoice?.amount_total ?? 0}</td>
</tr>
</tbody>
</table>
)}
</div>
)}
</div>
)
}
export default BillingBreakdown
@@ -1,159 +0,0 @@
import { useParams } from 'common'
import { useTheme } from 'next-themes'
import ProjectUpdateDisabledTooltip from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useFlag } from 'hooks'
import { BASE_PATH } from 'lib/constants'
import Link from 'next/link'
import { useSubscriptionPageStateSnapshot } from 'state/subscription-page'
import { Alert, Button, IconExternalLink } from 'ui'
import SpendCapSidePanel from './SpendCapSidePanel'
import Image from 'next/image'
export interface CostControlProps {}
const CostControl = ({}: CostControlProps) => {
const { ref: projectRef } = useParams()
const snap = useSubscriptionPageStateSnapshot()
const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
const { resolvedTheme } = useTheme()
const { data: subscription, isLoading } = useProjectSubscriptionV2Query({ projectRef })
const currentPlan = subscription?.plan
const isUsageBillingEnabled = subscription?.usage_billing_enabled ?? false
const canChangeTier =
!projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id || '')
return (
<>
<div className="grid grid-cols-12 gap-6" id="cost-control">
<div className="col-span-12 lg:col-span-5">
<div className="sticky top-16">
<div className="space-y-6">
<div>
<p className="text-base">Cost Control</p>
<p className="text-sm text-foreground-light">
Control whether to use beyond your plans included quota
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-foreground-light">More information</p>
<div>
<Link
href="https://supabase.com/docs/guides/platform/spend-cap"
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>
</Link>
</div>
</div>
</div>
</div>
</div>
{isLoading ? (
<div className="col-span-12 lg:col-span-7 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : (
<div className="col-span-12 lg:col-span-7 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
project will never become unresponsive. Only when your{' '}
<Link
href="#breakdown"
className="text-sm text-green-900 transition hover:text-green-1000"
>
included usage
</Link>{' '}
is exceeded will you be charged for any additional usage.
</Alert>
) : (
<p className="text-sm text-foreground-light">
You can control whether your project is charged for additional usage beyond the{' '}
<Link
href="#breakdown"
className="text-sm text-green-900 transition hover:text-green-1000"
>
included quota
</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">
<Image
alt="Spend Cap"
width={160}
height={96}
src={
isUsageBillingEnabled
? `${BASE_PATH}/img/spend-cap-off${
resolvedTheme === 'dark' ? '' : '--light'
}.png?v=3`
: `${BASE_PATH}/img/spend-cap-on${
resolvedTheme === 'dark' ? '' : '--light'
}.png?v=3`
}
/>
</div>
</div>
<div>
<p className="mb-1">
Spend cap is {isUsageBillingEnabled ? 'disabled' : 'enabled'}
</p>
<p className="text-sm text-foreground-light">
{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 project could become
unresponsive or enter read only mode if you exceed the included quota.
</span>
)}
</p>
{isUsageBillingEnabled && (
<p className="text-sm text-foreground-light mt-1">
Your project 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 pointer-events-auto"
disabled={!canChangeTier}
onClick={() => snap.setPanelKey('costControl')}
>
Change spend cap
</Button>
</ProjectUpdateDisabledTooltip>
</div>
</div>
</div>
)}
</div>
<SpendCapSidePanel />
</>
)
}
export default CostControl
@@ -1,310 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import clsx from 'clsx'
import { useParams } from 'common'
import { useTheme } from 'next-themes'
import Table from 'components/to-be-cleaned/Table'
import { useProjectSubscriptionUpdateMutation } from 'data/subscriptions/project-subscription-update-mutation'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
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 { useSubscriptionPageStateSnapshot } from 'state/subscription-page'
import { Alert, Button, Collapsible, IconChevronRight, IconExternalLink, SidePanel } from 'ui'
import Image from 'next/image'
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 { ref: projectRef } = useParams()
const { resolvedTheme } = useTheme()
const [showUsageCosts, setShowUsageCosts] = useState(false)
const [selectedOption, setSelectedOption] = useState<'on' | 'off'>()
const canUpdateSpendCap = useCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.subscriptions'
)
const snap = useSubscriptionPageStateSnapshot()
const visible = snap.panelKey === 'costControl'
const onClose = () => snap.setPanelKey(undefined)
const { data: subscription, isLoading } = useProjectSubscriptionV2Query({ projectRef })
const isFreePlan = subscription?.plan?.id === 'free'
const isSpendCapOn = !subscription?.usage_billing_enabled
const isTurningOnCap = !isSpendCapOn && selectedOption === 'on'
const hasChanges = selectedOption !== (isSpendCapOn ? 'on' : 'off')
const { mutate: updateSubscriptionTier, isLoading: isUpdating } =
useProjectSubscriptionUpdateMutation({
onSuccess: () => {
ui.setNotification({
category: 'success',
message: `Successfully ${isTurningOnCap ? 'enabled' : 'disabled'} spend cap`,
})
onClose()
},
onError: (error) => {
ui.setNotification({
error,
category: 'error',
message: `Unable to toggle spend cap: ${error.message}`,
})
},
})
useEffect(() => {
if (visible && subscription !== undefined) {
setSelectedOption(isSpendCapOn ? 'on' : 'off')
Telemetry.sendActivity(
{
activity: 'Side Panel Viewed',
source: 'Dashboard',
data: {
title: 'Spend cap',
section: 'Cost Control',
},
projectRef,
},
router
)
}
}, [visible, isLoading, subscription, isSpendCapOn])
const onConfirm = async () => {
if (!projectRef) return console.error('Project ref is required')
const tier = (
selectedOption === 'on' ? PRICING_TIER_PRODUCT_IDS.PRO : PRICING_TIER_PRODUCT_IDS.PAYG
) as 'tier_pro' | 'tier_payg'
updateSubscriptionTier({ projectRef, tier })
}
const billingMetricCategories: (keyof typeof pricing)[] = [
'database',
'auth',
'storage',
'realtime',
'edge_functions',
]
return (
<SidePanel
size="large"
loading={isLoading || isUpdating}
disabled={isFreePlan || isLoading || !hasChanges || isUpdating || !canUpdateSpendCap}
visible={visible}
onCancel={onClose}
onConfirm={onConfirm}
header={
<div className="flex items-center justify-between">
<h4>Spend cap</h4>
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/spend-cap"
target="_blank"
rel="noreferrer"
>
About spend cap
</Link>
</Button>
</div>
}
tooltip={!canUpdateSpendCap ? 'You do not have permission to update spend cap' : undefined}
>
<SidePanel.Content>
<div className="py-6 space-y-4">
<p className="text-sm">
Use the spend cap to manage project usage and costs, and control whether the project can
exceed the included quota allowance of any billed line item in a billing cycle
</p>
<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-foreground-light">
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-foreground">{category.title}</p>
</Table.td>
<Table.td>{null}</Table.td>
</Table.tr>
{usageItems.map((item: any) => {
return (
<Table.tr key={item.title}>
<Table.td>
<p className="text-xs pl-4">{item.title}</p>
</Table.td>
<Table.td>
<p className="text-xs pl-4">{item.plans['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,
},
projectRef,
},
router
)
}}
>
<Image
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={resolvedTheme === 'dark' ? option.imageUrl : option.imageUrlLight}
/>
<p
className={clsx(
'text-sm transition',
!isFreePlan && 'group-hover:text-foreground',
isSelected ? 'text-foreground' : 'text-foreground-light'
)}
>
{option.name}
</p>
</div>
)
})}
</div>
</div>
{selectedOption === 'on' ? (
<Alert
withIcon
variant="warning"
title="Your project could become unresponsive or enter read only mode"
>
Exceeding the included quota allowance with spend cap enabled can cause your project
to become unresponsive or enter read only mode.
</Alert>
) : (
<Alert
withIcon
variant="info"
title="Charges apply for usage beyond included quota allowance"
>
Your project 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 project and you will no longer be charged any extra for usage.'
: 'Upon clicking confirm, spend cap will be disabled for your project and you will be charged for any usage beyong the included quota.'}
</p>
</>
)}
</div>
</SidePanel.Content>
</SidePanel>
)
}
export default SpendCapSidePanel
@@ -1,120 +0,0 @@
import { useRouter } from 'next/router'
import AddOns from './AddOns/AddOns'
import BillingBreakdown from './BillingBreakdown'
import CostControl from './CostControl/CostControl'
import SubscriptionTier from './Tier/SubscriptionTier'
import { SUBSCRIPTION_PANEL_KEYS, useSubscriptionPageStateSnapshot } from 'state/subscription-page'
import { useSelectedOrganization } from 'hooks'
import { useEffect } from 'react'
import InformationBox from 'components/ui/InformationBox'
import { Button, IconExternalLink, IconInfo } from 'ui'
import Link from 'next/link'
export interface SubscriptionProps {}
const Subscription = ({}: SubscriptionProps) => {
const router = useRouter()
const organization = useSelectedOrganization()
const snap = useSubscriptionPageStateSnapshot()
const allowedValues = [
'subscriptionPlan',
'costControl',
'computeInstance',
'pitr',
'customDomain',
]
const panel = router.query.panel
const isOrgBilling = !!organization?.subscription_id
useEffect(() => {
if (panel && typeof panel === 'string' && allowedValues.includes(panel)) {
snap.setPanelKey(panel as SUBSCRIPTION_PANEL_KEYS)
}
}, [panel])
// No need to bother rendering, we'll redirect anyway
if (isOrgBilling) return null
return (
<>
<div className="border-b">
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 lg:px-16 2xl:px-32 py-6">
<h3 className="text-foreground text-xl">Subscription</h3>
</div>
</div>
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 lg:px-16 2xl:px-32 pt-8">
<InformationBox
icon={<IconInfo size="large" strokeWidth={1.5} />}
defaultVisibility={true}
hideCollapse
title="We're upgrading our billing system"
description={
<div className="space-y-3">
<p className="text-sm leading-normal">
This organization uses the legacy project-based billing. We’ve recently made some
big improvements to our billing system. To migrate to the new organization-based
billing, head over to your{' '}
<Link
href={`/org/${organization?.slug ?? '_'}/billing`}
className="text-sm text-green-900 transition hover:text-green-1000"
>
organization billing settings
</Link>
.
</p>
<div className="space-x-3">
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/blog/organization-based-billing"
target="_blank"
rel="noreferrer"
>
Announcement
</Link>
</Button>
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/org-based-billing"
target="_blank"
rel="noreferrer"
>
Documentation
</Link>
</Button>
</div>
</div>
}
/>
</div>
<div className="border-b">
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 lg:px-16 2xl:px-32 py-16">
<SubscriptionTier />
</div>
</div>
<div className="border-b">
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 lg:px-16 2xl:px-32 py-16">
<CostControl />
</div>
</div>
<div className="border-b">
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 lg:px-16 2xl:px-32 py-16">
<AddOns />
</div>
</div>
<div className="border-b">
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 lg:px-16 2xl:px-32 py-16">
<BillingBreakdown />
</div>
</div>
</>
)
}
export default Subscription
@@ -1,227 +0,0 @@
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 ProjectUpdateDisabledTooltip from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip'
import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send'
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 { PROJECT_STATUS } from 'lib/constants'
import { Button, Input, Modal } from 'ui'
import { CANCELLATION_REASONS } from '../../Billing.constants'
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
const ExitSurveyModal = ({ visible, onClose }: ExitSurveyModalProps) => {
const queryClient = useQueryClient()
const { ui } = useStore()
const { ref: projectRef } = useParams()
const captchaRef = useRef<HCaptcha>(null)
const router = useRouter()
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: sendExitSurvey, isLoading: isSubmittingFeedback } =
useSendDowngradeFeedbackMutation()
const { mutateAsync: updateSubscriptionTier, isLoading: isUpdatingSubscription } =
useProjectSubscriptionUpdateMutation({
onError: (error) => {
return ui.setNotification({
error,
category: 'error',
message: `Failed to cancel subscription: ${error.message}`,
})
},
})
const isSubmitting = isUpdatingSubscription || isSubmittingFeedback
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',
})
}
let token = captchaToken
if (!token) {
const captchaResponse = await captchaRef.current?.execute({ async: true })
token = captchaResponse?.response ?? null
await downgradeProject()
}
}
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) {
return
}
try {
await sendExitSurvey({
projectRef,
reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''),
message,
exitAction: 'downgrade',
})
} 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' })
}
if (!visible) return null
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-foreground-light">
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-foreground 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-foreground-lighter">
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"
className="pointer-events-auto"
loading={isSubmitting}
disabled={projectUpdateDisabled || isSubmitting}
onClick={onSubmit}
>
Confirm downgrade
</Button>
</ProjectUpdateDisabledTooltip>
</div>
</div>
</Modal>
</>
)
}
export default ExitSurveyModal
@@ -1,57 +0,0 @@
import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query'
import { useSelectedOrganization } from 'hooks'
import { Button, Modal } from 'ui'
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-foreground-light">
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-foreground-light">
{(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-foreground-light">
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
@@ -1,181 +0,0 @@
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 { AddNewPaymentMethodModal } from 'components/interfaces/BillingV2'
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'
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-foreground-light">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-foreground-light">
<IconAlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm">No saved payment methods</p>
</div>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<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-foreground">
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-foreground-light group-hover:text-foreground">
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
@@ -1,226 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import { useParams } from 'common'
import Panel from 'components/ui/Panel'
import {
SubscriptionTier,
updateSubscriptionTier,
} from 'data/subscriptions/project-subscription-update-mutation'
import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query'
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
import { BASE_PATH } from 'lib/constants'
import Link from 'next/link'
import { Button, IconCreditCard, Modal } from 'ui'
import PaymentMethodSelection from './PaymentMethodSelection'
export interface SubscriptionTierProps {
subscription: ProjectSubscriptionResponse
onSubscriptionUpdated: () => void
}
const SubscriptionPaymentMethod = ({
subscription,
onSubscriptionUpdated,
}: SubscriptionTierProps) => {
const { ref: projectRef } = useParams()
const { ui } = useStore()
const selectedOrganization = useSelectedOrganization()
const currentOrgSlug = selectedOrganization?.slug
const canUpdatePaymentMethod = useCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.subscriptions'
)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showPaymentEditModal, setShowPaymentEditModal] = useState(false)
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>(
subscription.payment_method_id || ''
)
const onConfirmPaymentMethod = async () => {
setIsSubmitting(true)
if (selectedPaymentMethod) {
if (!projectRef) return console.error('Project ref is required')
if (!selectedPaymentMethod) {
return ui.setNotification({ category: 'error', message: 'Please select a payment method' })
}
const selectedTier =
subscription.plan.id === 'pro' && subscription.usage_billing_enabled
? 'tier_payg'
: `tier_${subscription.plan.id}`
try {
setIsSubmitting(true)
await updateSubscriptionTier({
projectRef,
tier: selectedTier as SubscriptionTier,
paymentMethod: selectedPaymentMethod,
})
setShowPaymentEditModal(false)
ui.setNotification({
category: 'success',
message: `Successfully updated payment method!`,
})
onSubscriptionUpdated()
} catch (error: any) {
ui.setNotification({
error,
category: 'error',
message: `Unable to update subscription: ${error.message}`,
})
} finally {
setIsSubmitting(false)
}
}
setIsSubmitting(false)
}
if (subscription.payment_method_type === 'none') return null
return (
<>
<Panel>
<Panel.Content className="flex justify-between lg:items-center flex-col md:flex-row space-y-6 md:space-y-0">
{subscription.payment_method_type === 'card' && (
<>
<div className="flex space-x-3 items-center font-mono tracking-wide text-foreground-light">
{subscription.payment_method_card_details?.brand ? (
<img
alt="Credit card brand"
src={`${BASE_PATH}/img/payment-methods/${subscription.payment_method_card_details?.brand
.replace(' ', '-')
.toLowerCase()}.png`}
width="24"
/>
) : (
<IconCreditCard />
)}
<span className="text-sm">
**** **** ****{' '}
{subscription?.payment_method_card_details?.last_4_digits || '****'}
</span>
</div>
<div className="flex flex-row space-x-3 text-foreground-light text-sm">
<span>
Expires {subscription?.payment_method_card_details?.expiry_month || '-'}/
{subscription?.payment_method_card_details?.expiry_year?.toString()?.slice(-2) ||
'-'}
</span>
</div>
</>
)}
{subscription?.payment_method_type === 'invoice' && (
<>
<div className="flex space-x-3">
<div>
<p className="text-foreground-light">Payment via invoice</p>
<p className="text-sm text-foreground-light">
You get a monthly invoice and payment link via email.
</p>
</div>
</div>
</>
)}
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<div>
<Button
type="default"
disabled={subscription?.payment_method_type !== 'card' || !canUpdatePaymentMethod}
onClick={() => setShowPaymentEditModal(true)}
>
Change
</Button>
</div>
</Tooltip.Trigger>
{subscription?.payment_method_type !== 'card' ? (
<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-foreground">
Please email support@supbase.io to change your payment method.
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
) : !canUpdatePaymentMethod ? (
<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-foreground">
You do not have permission to change the payment method
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
) : null}
</Tooltip.Root>
</Panel.Content>
</Panel>
<Modal
loading={isSubmitting}
alignFooter="right"
className="!w-[450px]"
visible={showPaymentEditModal}
onCancel={() => setShowPaymentEditModal(false)}
onConfirm={onConfirmPaymentMethod}
overlayClassName="pointer-events-none"
header={`Change payment method`}
>
<Modal.Content>
<div className="py-6 space-y-2">
<p className="text-sm">
Upon clicking confirm, all future charges will be deducted from the selected payment
method. There are no immediate charges. Changing the payment method for this project
does not affect the payment method for other projects.
</p>
<p className="text-sm text-foreground-light">
To remove unused or expired payment methods, head to your{' '}
<Link
href={`/org/${currentOrgSlug || '_'}/billing`}
target="_blank"
className="text-green-900 transition hover:text-green-1000"
>
organization's billing settings
</Link>
.
</p>
<div className="!mt-6">
<PaymentMethodSelection
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={setSelectedPaymentMethod}
/>
</div>
</div>
</Modal.Content>
</Modal>
</>
)
}
export default SubscriptionPaymentMethod
@@ -1,181 +0,0 @@
import dayjs from 'dayjs'
import Link from 'next/link'
import { useParams } from 'common'
import ProjectUpdateDisabledTooltip from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import SparkBar from 'components/ui/SparkBar'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useFlag, useSelectedOrganization } from 'hooks'
import { useSubscriptionPageStateSnapshot } from 'state/subscription-page'
import { Alert, Button, IconExternalLink } from 'ui'
import SubscriptionPaymentMethod from './SubscriptionPaymentMethod'
import TierUpdateSidePanel from './TierUpdateSidePanel'
export interface SubscriptionTierProps {}
const SubscriptionTier = ({}: SubscriptionTierProps) => {
const { ref: projectRef } = useParams()
const selectedOrganization = useSelectedOrganization()
const orgSlug = selectedOrganization?.slug ?? ''
const snap = useSubscriptionPageStateSnapshot()
const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
const { data: subscription, isLoading, refetch } = useProjectSubscriptionV2Query({ projectRef })
const currentPlan = subscription?.plan
const tierName = 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 (
<>
<div className="grid grid-cols-12 gap-6" id="plan">
<div className="col-span-12 lg:col-span-5 space-y-6">
<div className="sticky space-y-6 top-16">
<p className="text-base">Subscription plan</p>
<div className="text-sm text-foreground-light">
To manage your billing address, emails or Tax ID, head to your{' '}
<Link
href={`/org/${orgSlug}/billing`}
className="text-sm text-green-900 transition hover:text-green-1000"
>
organization settings
</Link>
.
</div>
<div className="space-y-2">
<p className="text-sm text-foreground-light">More information</p>
<div>
<Link href="https://supabase.com/pricing" 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>
</Link>
</div>
</div>
</div>
</div>
{isLoading ? (
<div className="col-span-12 lg:col-span-7 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : (
<div className="col-span-12 lg:col-span-7 space-y-6">
<div>
<p className="text-sm">This project is currently on the plan:</p>
<p className="text-2xl text-brand uppercase">{tierName}</p>
</div>
<div>
<ProjectUpdateDisabledTooltip projectUpdateDisabled={projectUpdateDisabled}>
<Button
type="default"
className="pointer-events-auto"
disabled={!canChangeTier}
onClick={() => snap.setPanelKey('subscriptionPlan')}
>
Change subscription plan
</Button>
</ProjectUpdateDisabledTooltip>
</div>
{!canChangeTier &&
(projectUpdateDisabled ? (
<Alert withIcon variant="info" title={`Unable to update plan from ${tierName}`}>
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 ${tierName}`}
actions={[
<div key="contact-support">
<Link
href={`/support/new?ref=${projectRef}&category=sales&subject=Change%20plan%20away%20from%20${tierName}`}
>
<Button type="default">Contact support</Button>
</Link>
</div>,
]}
>
Please contact us if you'd like to change your plan.
</Alert>
))}
{!subscription?.usage_billing_enabled && (
<Alert
withIcon
variant="info"
title="This project 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-foreground-light">
When this project exceeds its{' '}
<Link
href="#breakdown"
className="text-sm text-green-900 transition hover:text-green-1000"
>
included usage quotas
</Link>
, it may become unresponsive.{' '}
{currentPlan?.id === 'free' ? (
<p className="pr-4">
If you wish to exceed the included usage, you should upgrade to a paid plan.
</p>
) : (
<p className="pr-4">
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-foreground-light pb-1"
labelTop={`${daysToCycleEnd} days remaining`}
/>
{subscription && (
<SubscriptionPaymentMethod
subscription={subscription}
onSubscriptionUpdated={() => refetch()}
/>
)}
</div>
)}
</div>
<TierUpdateSidePanel />
</>
)
}
export default SubscriptionTier
@@ -1,420 +0,0 @@
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 { useParams } from 'common'
import InformationBox from 'components/ui/InformationBox'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useProjectPlansQuery } from 'data/subscriptions/project-plans-query'
import { useProjectSubscriptionUpdateMutation } from 'data/subscriptions/project-subscription-update-mutation'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
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 { useSubscriptionPageStateSnapshot } from 'state/subscription-page'
import { Alert, Button, IconCheck, IconExternalLink, IconInfo, Modal, SidePanel } from 'ui'
import EnterpriseCard from './EnterpriseCard'
import ExitSurveyModal from './ExitSurveyModal'
import MembersExceedLimitModal from './MembersExceedLimitModal'
import PaymentMethodSelection from './PaymentMethodSelection'
const TierUpdateSidePanel = () => {
const { ui } = useStore()
const router = useRouter()
const selectedOrganization = useSelectedOrganization()
const slug = selectedOrganization?.slug
const { ref: projectRef } = useParams()
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 = useSubscriptionPageStateSnapshot()
const visible = snap.panelKey === 'subscriptionPlan'
const onClose = () => snap.setPanelKey(undefined)
const { data: plans, isLoading: isLoadingPlans } = useProjectPlansQuery({ projectRef })
const { data: subscription } = useProjectSubscriptionV2Query({ projectRef })
const { data: addons } = useProjectAddonsQuery({ projectRef })
const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug })
const { mutate: updateSubscriptionTier, isLoading: isUpdating } =
useProjectSubscriptionUpdateMutation({
onSuccess: () => {
ui.setNotification({
category: 'success',
message: `Successfully updated subscription to ${selectedTierMeta?.name}!`,
})
setSelectedTier(undefined)
onClose()
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
},
})
const availablePlans = plans ?? []
const subscriptionAddons = addons?.selected_addons ?? []
const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0
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',
},
projectRef,
},
router
)
}
}, [visible])
const onConfirmDowngrade = () => {
setSelectedTier(undefined)
if (hasMembersExceedingFreeTierLimit) {
setShowDowngradeError(true)
} else {
setShowExitSurvey(true)
}
}
const onUpdateSubscription = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!selectedTier) return console.error('Selected plan is required')
if (!selectedPaymentMethod) {
return ui.setNotification({ category: 'error', message: 'Please select a payment method' })
}
updateSubscriptionTier({
projectRef,
tier: selectedTier,
paymentMethod: selectedPaymentMethod,
})
}
return (
<>
<SidePanel
hideFooter
size="xxlarge"
visible={visible}
onCancel={() => onClose()}
header={
<div className="flex items-center justify-between">
<h4>Change subscription plan</h4>
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link href="https://supabase.com/pricing" target="_blank" rel="noreferrer">
Pricing
</Link>
</Button>
</div>
}
>
<SidePanel.Content>
<InformationBox
icon={<IconInfo size="large" strokeWidth={1.5} />}
defaultVisibility={true}
hideCollapse
title="We're upgrading our billing system"
className="mt-4"
description={
<div className="space-y-3">
<p className="text-sm leading-normal">
This organization uses the legacy project-based billing. We’ve recently made some
big improvements to our billing system. To migrate to the new organization-based
billing, head over to your{' '}
<Link
href={`/org/${slug}/billing`}
className="text-sm text-green-900 transition hover:text-green-1000"
>
organization billing settings
</Link>
.
</p>
<div className="space-x-3">
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/blog/organization-based-billing"
target="_blank"
rel="noreferrer"
>
Announcement
</Link>
</Button>
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/org-based-billing"
target="_blank"
rel="noreferrer"
>
Documentation
</Link>
</Button>
</div>
</div>
}
/>
<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 text-sm uppercase')}>{plan.name}</p>
{isCurrentPlan ? (
<div className="text-xs bg-scale-500 text-foreground-light rounded px-2 py-0.5">
Current plan
</div>
) : plan.nameBadge ? (
<div className="text-xs bg-brand-400 text-brand 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-foreground-light text-sm">From</p>}
{isLoadingPlans ? (
<div className="h-[28px] flex items-center justify-center">
<ShimmeringLoader className="w-[30px] h-[24px]" />
</div>
) : (
<p className="text-foreground text-lg">${price}</p>
)}
<p className="text-foreground-light text-sm">{tierMeta?.costUnit}</p>
</div>
<div
className={clsx('flex mt-1 mb-4', !tierMeta?.warningLegacy && 'opacity-0')}
>
<div className="bg-scale-200 text-brand-600 border shadow-sm rounded-md bg-opacity-30 py-0.5 px-2 text-xs">
{tierMeta?.warningLegacy}
</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',
},
projectRef,
},
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-foreground">
You do not have permission to change the subscription plan.
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
) : null}
</Tooltip.Root>
) : (
<Button asChild block type="primary">
<Link href={plan.href} className="hidden md:block" target="_blank">
Contact Us
</Link>
</Button>
)}
<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 translate-y-[2.5px]"
aria-hidden="true"
strokeWidth={3}
/>
</div>
<p className="ml-3 text-xs text-foreground-light">{feature}</p>
</li>
))}
</ul>
</div>
{plan.footer && (
<div className="border-t pt-4 mt-4">
<p className="text-foreground-light 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={isUpdating}
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-foreground-light">
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 TierUpdateSidePanel
@@ -1,126 +0,0 @@
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
@@ -1,65 +0,0 @@
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'
export interface BandwidthProps {
projectRef: string
startDate: string | undefined
endDate: string | undefined
subscription: ProjectSubscriptionResponse | undefined
currentBillingCycleSelected: boolean
}
const Bandwidth = ({
projectRef,
subscription,
startDate,
endDate,
currentBillingCycleSelected,
}: BandwidthProps) => {
const { data: dbEgressData, isLoading: isLoadingDbEgressData } = useDailyStatsQuery({
projectRef,
attribute: 'total_egress_modified',
interval: '1d',
startDate,
endDate,
})
const { data: storageEgressData, isLoading: isLoadingStorageEgressData } = useDailyStatsQuery({
projectRef,
attribute: 'total_storage_egress',
interval: '1d',
startDate,
endDate,
})
const chartMeta: {
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean }
} = {
db_egress: {
data: dbEgressData?.data ?? [],
margin: 16,
isLoading: isLoadingDbEgressData,
hasNoData: dbEgressData?.hasNoData ?? false,
},
storage_egress: {
data: storageEgressData?.data ?? [],
margin: 14,
isLoading: isLoadingStorageEgressData,
hasNoData: storageEgressData?.hasNoData ?? false,
},
}
return (
<UsageSection
projectRef={projectRef}
categoryKey="bandwidth"
chartMeta={chartMeta}
subscription={subscription}
currentBillingCycleSelected={currentBillingCycleSelected}
/>
)
}
export default Bandwidth
@@ -1,281 +0,0 @@
import dayjs from 'dayjs'
import { IconBarChart2 } from 'ui'
import Panel from 'components/ui/Panel'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { DataPoint } from 'data/analytics/constants'
import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { getAddons } from '../Subscription/Subscription.utils'
import SectionContent from './SectionContent'
import SectionHeader from './SectionHeader'
import { USAGE_CATEGORIES } from './Usage.constants'
import { getUpgradeUrl } from './Usage.utils'
import UsageBarChart from './UsageBarChart'
import { CPUWarnings, DiskIOBandwidthWarnings, RAMWarnings } from './UsageWarningAlerts'
export interface InfrastructureProps {
projectRef: string
startDate?: string
endDate?: string
currentBillingCycleSelected: boolean
}
const Infrastructure = ({
projectRef,
startDate,
endDate,
currentBillingCycleSelected,
}: InfrastructureProps) => {
const { data: subscription } = useProjectSubscriptionV2Query({ projectRef })
const { data: resourceWarnings } = useResourceWarningsQuery()
const projectResourceWarnings = resourceWarnings?.find((x) => x.project === projectRef)
const categoryMeta = USAGE_CATEGORIES.find((category) => category.key === 'infra')
const upgradeUrl = getUpgradeUrl(projectRef, subscription)
const isFreePlan = subscription?.plan?.id === 'free'
const { data: addons, isLoading } = useProjectAddonsQuery({ projectRef })
const selectedAddons = addons?.selected_addons ?? []
const { computeInstance } = getAddons(selectedAddons)
const currentComputeInstanceSpecs = computeInstance?.variant?.meta ?? {
baseline_disk_io_mbs: 87,
max_disk_io_mbs: 2085,
cpu_cores: 2,
cpu_dedicated: true,
memory_gb: 1,
}
// Switch to hourly interval, if the timeframe is <48 hours
let interval: '1d' | '1h' = '1d'
let dateFormat = 'DD MMM'
if (startDate && endDate) {
const diffInHours = dayjs(endDate).diff(startDate, 'hours')
if (diffInHours <= 48) {
interval = '1h'
dateFormat = 'h a'
}
}
const { data: cpuUsageData, isLoading: isLoadingCpuUsageData } = useInfraMonitoringQuery({
projectRef,
attribute: 'max_cpu_usage',
interval,
startDate,
endDate,
dateFormat,
})
const { data: memoryUsageData, isLoading: isLoadingMemoryUsageData } = useInfraMonitoringQuery({
projectRef,
attribute: 'ram_usage',
interval,
startDate,
endDate,
dateFormat,
})
const { data: ioBudgetData, isLoading: isLoadingIoBudgetData } = useInfraMonitoringQuery({
projectRef,
attribute: 'disk_io_consumption',
interval,
startDate,
endDate,
dateFormat,
})
const hasLatest = dayjs(endDate!).isAfter(dayjs().startOf('day'))
const latestIoBudgetConsumption =
hasLatest && ioBudgetData?.data?.slice(-1)?.[0]
? Number(ioBudgetData.data.slice(-1)[0].disk_io_consumption)
: 0
const highestIoBudgetConsumption = Math.max(
...(ioBudgetData?.data || []).map((x) => Number(x.disk_io_consumption) ?? 0),
0
)
const chartMeta: { [key: string]: { data: DataPoint[]; isLoading: boolean } } = {
max_cpu_usage: {
isLoading: isLoadingCpuUsageData,
data: cpuUsageData?.data ?? [],
},
ram_usage: {
isLoading: isLoadingMemoryUsageData,
data: memoryUsageData?.data ?? [],
},
disk_io_consumption: {
isLoading: isLoadingIoBudgetData,
data: ioBudgetData?.data ?? [],
},
}
if (categoryMeta === undefined) return null
return (
<>
<SectionHeader title={categoryMeta.name} description={categoryMeta.description} />
{categoryMeta.attributes.map((attribute) => {
const chartData = chartMeta[attribute.key]?.data ?? []
return (
<div id={attribute.anchor} key={attribute.key}>
<SectionContent section={attribute}>
{attribute.key === 'disk_io_consumption' && (
<>
<DiskIOBandwidthWarnings
upgradeUrl={upgradeUrl}
isFreePlan={isFreePlan}
hasLatest={hasLatest}
currentBillingCycleSelected={currentBillingCycleSelected}
latestIoBudgetConsumption={latestIoBudgetConsumption}
highestIoBudgetConsumption={highestIoBudgetConsumption}
/>
<div className="space-y-1">
<p>Disk IO Bandwidth</p>
{currentComputeInstanceSpecs.baseline_disk_io_mbs ===
currentComputeInstanceSpecs.max_disk_io_mbs ? (
<p className="text-sm text-foreground-light">
Your current compute can has a baseline and maximum disk throughput of{' '}
{currentComputeInstanceSpecs.max_disk_io_mbs?.toLocaleString()} Mbps.
</p>
) : (
<p className="text-sm text-foreground-light">
Your current compute can burst up to{' '}
{currentComputeInstanceSpecs.max_disk_io_mbs?.toLocaleString()} Mbps for 30
minutes a day and reverts to the baseline performance of{' '}
{currentComputeInstanceSpecs.baseline_disk_io_mbs?.toLocaleString()} Mbps.
</p>
)}
</div>
<div>
<p className="text-sm mb-2">Overview</p>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-foreground-light">Current compute instance</p>
<p className="text-xs">{computeInstance?.variant?.name ?? 'Micro'}</p>
</div>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-foreground-light">
Maximum IO Bandwidth (burst limit)
</p>
<p className="text-xs">
{currentComputeInstanceSpecs.max_disk_io_mbs?.toLocaleString()} Mbps
</p>
</div>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-foreground-light">Baseline IO Bandwidth</p>
<p className="text-xs">
{currentComputeInstanceSpecs.baseline_disk_io_mbs?.toLocaleString()} Mbps
</p>
</div>
{currentComputeInstanceSpecs.max_disk_io_mbs !==
currentComputeInstanceSpecs?.baseline_disk_io_mbs && (
<div className="flex items-center justify-between py-1">
<p className="text-xs text-foreground-light">Daily burst time limit</p>
<p className="text-xs">30 mins</p>
</div>
)}
</div>
</>
)}
{attribute.key === 'max_cpu_usage' && (
<CPUWarnings
isFreePlan={isFreePlan}
upgradeUrl={upgradeUrl}
severity={projectResourceWarnings?.cpu_exhaustion}
/>
)}
{attribute.key === 'ram_usage' && (
<RAMWarnings
isFreePlan={isFreePlan}
upgradeUrl={upgradeUrl}
severity={projectResourceWarnings?.memory_and_swap_exhaustion}
/>
)}
<div className="space-y-1">
<div className="flex flex-row justify-between">
{attribute.key === 'disk_io_consumption' ? (
<p>Disk IO consumed per {interval === '1d' ? 'day' : 'hour'}</p>
) : (
<p>
Max{' '}
<span className={attribute.key === 'ram_usage' ? 'lowercase' : ''}>
{attribute.name}
</span>{' '}
utilization per {interval === '1d' ? 'day' : 'hour'}
</p>
)}
</div>
{attribute.key === 'ram_usage' && (
<div className="text-sm text-foreground-light">
<p>
Your compute instance has {currentComputeInstanceSpecs.memory_gb} GB of
memory.
</p>
{currentComputeInstanceSpecs.memory_gb === 1 && (
<p>
As your project is running on the smallest compute instance, it is not
unusual for your project to have a base memory usage of ~50%.
</p>
)}
</div>
)}
{attribute.key === 'max_cpu_usage' && (
<p className="text-sm text-foreground-light">
Your compute instance has {currentComputeInstanceSpecs.cpu_cores} CPU cores.
</p>
)}
{attribute.chartDescription.split('\n').map((paragraph, idx) => (
<p key={`para-${idx}`} className="text-sm text-foreground-light">
{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 ? (
<UsageBarChart
name={`${attribute.chartPrefix || ''}${attribute.name}`}
unit={attribute.unit}
attribute={attribute.attribute}
data={chartData}
yFormatter={(value) => `${Math.round(Number(value))}%`}
tooltipFormatter={(value) => `${value}%`}
yLimit={100}
/>
) : (
<Panel>
<Panel.Content>
<div className="flex flex-col items-center justify-center space-y-2">
<IconBarChart2 className="text-foreground-light mb-2" />
<p className="text-sm">No data in period</p>
<p className="text-sm text-foreground-light">
May take a few minutes to show
</p>
</div>
</Panel.Content>
</Panel>
)}
</SectionContent>
</div>
)
})}
</>
)
}
export default Infrastructure
@@ -1,63 +0,0 @@
import Link from 'next/link'
import { PropsWithChildren } from 'react'
import { Badge, IconExternalLink } from 'ui'
import { CategoryAttribute } from './Usage.constants'
export interface SectionContent {
section: CategoryAttribute
includedInPlan?: boolean
}
const SectionContent = ({
section,
includedInPlan,
children,
}: PropsWithChildren<SectionContent>) => {
const { name, description, links } = section
return (
<div className="border-b">
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 lg:px-16 2xl:px-32 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-foreground-light pr-8">
{value}
</p>
))}
</div>
</div>
{links && links.length && (
<div className="space-y-2">
<p className="text-sm text-foreground dark:text-foreground-light mb-2">
More information
</p>
{links.map((link) => (
<div key={link.url}>
<Link href={link.url} 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>
</Link>
</div>
))}
</div>
)}
</div>
</div>
<div className="col-span-12 lg:col-span-7 space-y-6">{children}</div>
</div>
</div>
</div>
)
}
export default SectionContent
@@ -1,19 +0,0 @@
export interface SectionHeaderProps {
title: string
description: string
}
const SectionHeader = ({ title, description }: SectionHeaderProps) => {
return (
<div className="border-b">
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 lg:px-16 2xl:px-32 py-16">
<div>
<p className="text-xl">{title}</p>
<p className="text-sm text-foreground-light">{description}</p>
</div>
</div>
</div>
)
}
export default SectionHeader
@@ -1,79 +0,0 @@
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
@@ -1,365 +0,0 @@
import { ProjectSubscriptionResponse } from 'data/subscriptions/project-subscription-v2-query'
import { ProjectUsageData } from 'data/usage/project-usage-query'
import Link from 'next/link'
import { Alert, Badge, Button, IconExternalLink } from 'ui'
import { USAGE_APPROACHING_THRESHOLD } from '../Billing.constants'
export const Y_DOMAIN_CEILING_MULTIPLIER = 4 / 3
export const USAGE_STATUS = {
NORMAL: 'NORMAL',
APPROACHING: 'APPROACHING',
EXCEEDED: 'EXCEEDED',
}
export interface CategoryAttribute {
anchor: string
key: string // Property from project usage
attribute: string // 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 = 'infra' | 'bandwidth' | 'sizeCount' | 'activity'
export interface CategoryMeta {
key: 'infra' | 'bandwidth' | 'sizeCount' | 'activity'
name: string
description: string
attributes: CategoryAttribute[]
}
export const USAGE_CATEGORIES: CategoryMeta[] = [
{
key: 'infra',
name: 'Infrastructure',
description: 'Usage statistics related to your server instance',
attributes: [
{
anchor: 'cpu',
key: 'max_cpu_usage',
attribute: 'max_cpu_usage',
name: 'CPU',
unit: 'percentage',
description: 'Max CPU usage of your server',
chartDescription: '',
links: [
{
name: 'Compute Add-Ons',
url: 'https://supabase.com/docs/guides/platform/compute-add-ons',
},
{ name: 'High CPU Usage', url: 'https://supabase.com/docs/guides/platform/exhaust-cpu' },
{
name: 'Metrics',
url: 'https://supabase.com/docs/guides/platform/metrics',
},
],
},
{
anchor: 'ram',
key: 'ram_usage',
attribute: 'ram_usage',
name: 'Memory',
unit: 'percentage',
description:
'Memory usage of your server.\nYou might observe elevated memory usage, even with little to no load. Besides Postgres, a wide range of services is running under the hood resulting in an elevated base memory usage.',
chartDescription: '',
links: [
{
name: 'Compute Add-Ons',
url: 'https://supabase.com/docs/guides/platform/compute-add-ons',
},
{ name: 'High RAM Usage', url: 'https://supabase.com/docs/guides/platform/exhaust-ram' },
{
name: 'Metrics',
url: 'https://supabase.com/docs/guides/platform/metrics',
},
],
},
{
anchor: 'disk_io',
key: 'disk_io_consumption',
attribute: 'disk_io_consumption',
name: 'Disk IO Bandwidth',
unit: 'percentage',
links: [
{
name: 'Disk Throughput and IOPS',
url: 'https://supabase.com/docs/guides/platform/compute-add-ons#disk-throughput-and-iops',
},
{
name: 'Metrics',
url: 'https://supabase.com/docs/guides/platform/metrics',
},
],
description:
'The disk performance of your workload is determined by the Disk IO bandwidth.\nSmaller compute instances (below 4XL) can burst up to their largest throughput and IOPS for 30 minutes in a day. Beyond that, the performance reverts to the baseline. Your disk budget gets replenished throughout the day.',
chartDescription: '',
},
],
},
{
key: 'bandwidth',
name: 'Bandwidth',
description: 'Amount of data transmitted over all network connections',
attributes: [
{
anchor: 'dbEgress',
key: 'db_egress',
attribute: 'total_egress_modified',
name: 'Database 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.',
},
{
anchor: 'storageEgress',
key: 'storage_egress',
attribute: 'total_storage_egress',
name: 'Storage Egress',
unit: 'bytes',
description:
'All requests to view and download your storage items go through our CDN. We sum up all outgoing traffic (egress) for storage related requests through our CDN. We do not differentiate between cache and no cache hits.\nBilling is based on the total amount 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',
attribute: 'total_db_size_bytes',
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>
<Button asChild size="tiny" type="default" icon={<IconExternalLink size={14} />}>
<Link href="https://supabase.com/docs/guides/platform/database-usage#disk-management">
What is disk size?
</Link>
</Button>
</div>
<p className="text-sm text-foreground-light 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
href={`/project/${ref}/settings/database#diskManagement`}
className="text-brand transition hover:text-brand-600"
>
preprovision
</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',
attribute: 'total_storage_size_bytes',
name: 'Storage Size',
chartPrefix: 'Average ',
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',
attribute: 'total_func_count',
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',
attribute: 'total_auth_billing_period_mau',
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',
attribute: 'total_auth_billing_period_sso_mau',
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',
attribute: 'total_storage_image_render_count',
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/serving/image-transformations',
},
],
},
{
anchor: 'functionInvocations',
key: 'func_invocations',
attribute: 'total_func_invocations',
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',
attribute: 'total_realtime_message_count',
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',
attribute: 'total_realtime_peak_connection',
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',
},
],
},
],
},
]
@@ -1,327 +0,0 @@
import clsx from 'clsx'
import { useParams } from 'common'
import dayjs from 'dayjs'
import { useMemo, useRef, useState } from 'react'
import { InView } from 'react-intersection-observer'
import { Button, cn, IconAlertCircle, IconExternalLink, IconInfo, IconLoader } from 'ui'
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
import InformationBox from 'components/ui/InformationBox'
import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useProjectUsageQuery } from 'data/usage/project-usage-query'
import { useSelectedOrganization } from 'hooks'
import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants'
import Link from 'next/link'
import Activity from './Activity'
import Bandwidth from './Bandwidth'
import Infrastructure from './Infrastructure'
import SizeAndCounts from './SizeAndCounts'
import { USAGE_CATEGORIES, USAGE_STATUS } from './Usage.constants'
import { getUsageStatus } from './Usage.utils'
type UsageSectionIds = 'infra' | 'bandwidth' | 'sizeCount' | 'activity'
const Usage = () => {
const { ref } = useParams()
const organization = useSelectedOrganization()
const isOrgBilling = !!organization?.subscription_id
// [Joshen] Using a state here for now as in the future, we'd have this usage page support showing
// stats for different projects. So we'll pass the selected ref as a prop into the individual components
const [selectedProjectRef] = useState<string>(ref as string)
const [activeTab, setActiveTab] = useState<UsageSectionIds>('infra')
const [dateRange, setDateRange] = useState<any>()
const infrastructureRef = useRef<HTMLDivElement>(null)
const bandwidthRef = useRef<HTMLDivElement>(null)
const sizeAndCountsRef = useRef<HTMLDivElement>(null)
const activityRef = useRef<HTMLDivElement>(null)
const { data: usage } = useProjectUsageQuery({ projectRef: ref })
const { data: subscription, isLoading: isLoadingSubscription } = useProjectSubscriptionV2Query({
projectRef: selectedProjectRef,
})
const usageBillingEnabled = subscription?.usage_billing_enabled
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'
}
}, [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'
}
}, [dateRange, subscription])
const { data: ioBudgetData } = useInfraMonitoringQuery({
projectRef: selectedProjectRef,
attribute: 'disk_io_budget',
interval: '1d',
startDate: dateRange?.period_start?.date,
endDate: dateRange?.period_end?.date,
})
const currentDayIoBudget = Number(
ioBudgetData?.data.find((x) => x.periodStartFormatted === dayjs().format('DD MMM'))?.[
'disk_io_budget'
] ?? 100
)
const scrollTo = (id: UsageSectionIds) => {
switch (id) {
case 'infra':
if (infrastructureRef.current) {
infrastructureRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start', // or 'end' to scroll to the bottom
inline: 'start', //
})
}
break
case 'bandwidth':
if (bandwidthRef.current) {
bandwidthRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start', // or 'end' to scroll to the bottom
inline: 'start', //
})
}
break
case 'sizeCount':
if (sizeAndCountsRef.current) {
sizeAndCountsRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start', // or 'end' to scroll to the bottom
inline: 'start', //
})
}
break
case 'activity':
if (activityRef.current) {
activityRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start', // or 'end' to scroll to the bottom
inline: 'start', //
})
}
break
}
}
return (
<>
<div>
<div className="1xl:px-28 mx-auto flex flex-col px-5 lg:px-16 2xl:px-32 pt-6 space-y-4">
<h3 className="text-foreground text-xl">{isOrgBilling ? 'Project ' : ''}Usage</h3>
</div>
</div>
<div>
<div className="sticky top-0 z-10 overflow-hidden bg-scale-200 border-b">
<div className="1xl:px-28 mx-auto px-5 lg:px-16 2xl:px-32 flex flex-col gap-2">
<div className="flex items-center mt-4 justify-between">
<div className="flex items-center space-x-4">
{!isLoadingSubscription && (
<DateRangePicker
onChange={setDateRange}
value={TIME_PERIODS_BILLING[0].key}
options={[...TIME_PERIODS_BILLING, ...TIME_PERIODS_REPORTS]}
loading={isLoadingSubscription}
currentBillingPeriodStart={subscription?.current_period_start}
currentBillingPeriodEnd={subscription?.current_period_end}
/>
)}
{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')}
>
{isOrgBilling ? 'Organization' : 'Project'} is on the {subscription.plan.name}{' '}
plan
</p>
<p className="text-sm text-foreground-light">
{billingCycleStart.format('DD MMM YYYY')} -{' '}
{billingCycleEnd.format('DD MMM YYYY')}
</p>
</div>
) : null}
</div>
</div>
<div className="flex gap-6">
{USAGE_CATEGORIES.map((category) => {
const infraStatus =
currentDayIoBudget <= 0
? USAGE_STATUS.EXCEEDED
: currentDayIoBudget <= 20
? USAGE_STATUS.APPROACHING
: USAGE_STATUS.NORMAL
const status =
category.key === 'infra'
? infraStatus
: getUsageStatus(category.attributes, usage)
return (
<button
role="tab"
key={category.key}
onClick={() => scrollTo(category.key)}
className={cn(
'flex items-center space-x-2 py-3 hover:opacity-100 transition cursor-pointer',
activeTab === category.key
? 'border-b border-scale-1200 text-foreground'
: 'opacity-50'
)}
>
{currentBillingCycleSelected &&
usageBillingEnabled === false &&
status === USAGE_STATUS.APPROACHING ? (
<IconAlertCircle size={15} strokeWidth={2} className="text-amber-900" />
) : currentBillingCycleSelected &&
usageBillingEnabled === false &&
status === USAGE_STATUS.EXCEEDED ? (
<IconAlertCircle size={15} strokeWidth={2} className="text-red-900" />
) : null}
<p className="text-sm">{category.name}</p>
</button>
)
})}
</div>
</div>
</div>
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 lg:px-16 2xl:px-32 pt-8">
<InformationBox
icon={<IconInfo size="large" strokeWidth={1.5} />}
defaultVisibility={true}
hideCollapse
title="We're upgrading our billing system"
description={
<div className="space-y-3">
<p className="text-sm leading-normal">
This organization uses the legacy project-based billing. We’ve recently made some
big improvements to our billing system. To migrate to the new organization-based
billing, head over to your{' '}
<Link
href={`/org/${organization?.slug}/billing`}
className="text-sm text-green-900 transition hover:text-green-1000"
>
organization billing settings
</Link>
.
</p>
<div className="space-x-3">
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/blog/organization-based-billing"
target="_blank"
rel="noreferrer"
>
Announcement
</Link>
</Button>
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/org-based-billing"
target="_blank"
rel="noreferrer"
>
Documentation
</Link>
</Button>
</div>
</div>
}
/>
</div>
<InView as="div" threshold={0.2} onChange={(inView) => inView && setActiveTab('infra')}>
<div id="infrastructure" ref={infrastructureRef} style={{ scrollMarginTop: '100px' }}>
<Infrastructure
projectRef={selectedProjectRef}
startDate={startDate}
endDate={endDate}
currentBillingCycleSelected={currentBillingCycleSelected}
/>
</div>
</InView>
<InView
as="div"
rootMargin="85% 0% -85% 0px"
onChange={(inView) => inView && setActiveTab('bandwidth')}
>
<div id="bandwidth" ref={bandwidthRef} style={{ scrollMarginTop: '100px' }}>
<Bandwidth
projectRef={selectedProjectRef}
subscription={subscription}
startDate={startDate}
endDate={endDate}
currentBillingCycleSelected={currentBillingCycleSelected}
/>
</div>
</InView>
<InView
as="div"
rootMargin="85% 0% -85% 0px"
onChange={(inView) => inView && setActiveTab('sizeCount')}
>
<div id="size_and_counts" ref={sizeAndCountsRef} style={{ scrollMarginTop: '100px' }}>
<SizeAndCounts
projectRef={selectedProjectRef}
subscription={subscription}
startDate={startDate}
endDate={endDate}
currentBillingCycleSelected={currentBillingCycleSelected}
/>
</div>
</InView>
<InView
as="div"
rootMargin="85% 0% -85% 0px"
onChange={(inView) => inView && setActiveTab('activity')}
>
<div id="activity" ref={activityRef} style={{ scrollMarginTop: '100px' }}>
<Activity
projectRef={selectedProjectRef}
subscription={subscription}
startDate={startDate}
endDate={endDate}
currentBillingCycleSelected={currentBillingCycleSelected}
/>
</div>
</InView>
</div>
</>
)
}
export default Usage
@@ -1,111 +0,0 @@
import { DataPoint } from 'data/analytics/constants'
import { ProjectUsageResponse } from 'data/usage/project-usage-query'
import { USAGE_APPROACHING_THRESHOLD } from '../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
}
@@ -1,96 +0,0 @@
import { DataPoint } from 'data/analytics/constants'
import dayjs from 'dayjs'
import {
Bar,
CartesianGrid,
Cell,
ComposedChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
// [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
attribute: string
unit: 'bytes' | 'absolute' | 'percentage'
yLimit?: number
yLeftMargin?: number
yFormatter?: (value: number | string) => string
tooltipFormatter?: (value: number | string) => string
}
const UsageBarChart = ({
data,
name,
attribute,
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 value =
unit === 'percentage'
? Number(payload[0].value).toFixed(2)
: Number(payload[0].value)
return (
<div className="w-[170px] border bg-scale-300 rounded-md px-2 py-2">
<p className="text-xs text-foreground-light">
{attribute === 'disk_io_budget' ? `Remaining IO budget:` : `${name}:`}
</p>
{dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) ? (
<p className="text-foreground-light text-lg">No data yet</p>
) : (
<p className="text-xl">
{tooltipFormatter !== undefined ? tooltipFormatter(value) : value}
</p>
)}
<p className="text-xs text-foreground-light mt-1">
{dataPeriod.format('DD MMM YYYY')}
</p>
</div>
)
} else return null
}}
/>
<Bar dataKey={attribute}>
{data.map((entry) => {
return <Cell key={`${entry.period_start}`} className="fill-scale-1200" />
})}
</Bar>
</ComposedChart>
</ResponsiveContainer>
</div>
)
}
export default UsageBarChart
@@ -1,286 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import clsx from 'clsx'
import Link from 'next/link'
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 { useIsFeatureEnabled } from 'hooks'
import { formatBytes } from 'lib/helpers'
import { Button, IconAlertTriangle, IconBarChart2 } from 'ui'
import { USAGE_APPROACHING_THRESHOLD } from '../Billing.constants'
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'
interface UsageSectionProps {
projectRef: string
categoryKey: CategoryMetaKey
subscription: ProjectSubscriptionResponse | undefined
chartMeta: {
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean }
}
currentBillingCycleSelected: boolean
}
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 (
<>
<SectionHeader title={categoryMeta.name} description={categoryMeta.description} />
{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 = chartData.some(
(dataPoint) => Number(dataPoint[attribute.attribute]) !== 0
)
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-foreground">
Exceeding your plans included usage will lead to restrictions
to your project.
</p>
<p className="text-xs text-foreground">
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 && (
<Button asChild type="default" size="tiny">
<Link href={upgradeUrl} className="pb-1">
{subscription?.plan?.id === 'free'
? 'Upgrade plan'
: 'Change spend cap'}
</Link>
</Button>
)}
</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-foreground-light">
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-foreground-light">
{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-foreground-light">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-foreground-light">
{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}
attribute={attribute.attribute}
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-foreground-light mb-2" />
<p className="text-sm">No data in period</p>
<p className="text-sm text-foreground-light">
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-foreground-light">
You need to be on a higher plan in order to use this feature.
</p>
</div>
</div>
<Button type="primary" asChild>
<Link
href={`/project/${projectRef}/settings/billing/subscription?panel=subscriptionPlan`}
>
Upgrade plan
</Link>
</Button>
</div>
</Panel.Content>
</Panel>
)}
</SectionContent>
</div>
)
})}
</>
)
}
export default UsageSection
@@ -1,6 +0,0 @@
import AddNewPaymentMethodModal from './AddNewPaymentMethodModal/AddNewPaymentMethodModal'
import Invoices from './Invoices'
import SpendCapModal from './SpendCapModal'
import InvoiceStatusBadge from './InvoiceStatusBadge'
export { AddNewPaymentMethodModal, Invoices, SpendCapModal, InvoiceStatusBadge }
@@ -14,7 +14,7 @@ import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useBackupRestoreMutation } from 'data/database/backup-restore-mutation'
import { DatabaseBackup, useBackupsQuery } from 'data/database/backups-query'
import { setProjectStatus } from 'data/projects/projects-query'
import { useCheckPermissions, useStore } from 'hooks'
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
import { PROJECT_STATUS } from 'lib/constants'
import BackupItem from './BackupItem'
import BackupsEmpty from './BackupsEmpty'
@@ -23,6 +23,7 @@ const BackupsList = () => {
const { ui } = useStore()
const router = useRouter()
const queryClient = useQueryClient()
const organization = useSelectedOrganization()
const { project: selectedProject } = useProjectContext()
const projectRef = selectedProject?.ref || 'default'
@@ -65,6 +66,7 @@ const BackupsList = () => {
icon={<IconClock size="large" />}
primaryText="Free Plan does not include project backups."
projectRef={projectRef}
organizationSlug={organization!.slug}
secondaryText="Upgrade to the Pro plan for up to 7 days of scheduled backups."
addon="pitr"
/>
@@ -1,7 +1,7 @@
import { groupBy } from 'lodash'
import { observer } from 'mobx-react-lite'
import Link from 'next/link'
import { Button, IconExternalLink, IconPlus, Modal } from 'ui'
import { Button, IconPlus } from 'ui'
import AlertError from 'components/ui/AlertError'
import {
@@ -14,7 +14,6 @@ import { useProjectsQuery } from 'data/projects/projects-query'
import { ResourceWarning, useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { IS_PLATFORM } from 'lib/constants'
import { makeRandomString } from 'lib/helpers'
import { useState } from 'react'
import { Organization, Project, ResponseError } from 'types'
import ProjectCard from './ProjectCard'
import ShimmeringCard from './ShimmeringCard'
@@ -111,8 +110,6 @@ const OrganizationProjects = ({
?.filter((integration) => integration.integration.name === 'Vercel')
.flatMap((integration) => integration.connections)
const [orgBillingMigrationModalVisible, setOrgBillingMigrationModalVisible] = useState(false)
return (
<div className="space-y-3" key={makeRandomString(5)}>
<div className="flex space-x-4 items-center">
@@ -125,14 +122,6 @@ const OrganizationProjects = ({
</Button>
</div>
)}
{!subscription_id && (
<div>
<Button onClick={() => setOrgBillingMigrationModalVisible(true)} type="warning">
Action Required
</Button>
</div>
)}
</div>
{isLoadingPermissions || isLoadingProjects ? (
@@ -178,58 +167,6 @@ const OrganizationProjects = ({
)}
</ul>
)}
<Modal
closable
hideFooter
size="small"
visible={orgBillingMigrationModalVisible}
onCancel={() => setOrgBillingMigrationModalVisible(false)}
header="We're upgrading our billing system"
>
<Modal.Content className="py-4 space-y-4">
<div className="space-y-3">
<p className="text-sm leading-normal">
The organization "{name}" still uses the legacy project-based billing. We've recently
made some big improvements to our billing system and require your action. To migrate
to the new organization-based billing, head over to your{' '}
<Link
href={`/org/${slug}/billing`}
className="text-sm text-green-900 transition hover:text-green-1000"
>
organization billing settings
</Link>
.
</p>
<p className="text-sm leading-normal">
Please do this until the <span className="font-medium">18th of October</span>, as
remaining organizations will be migrated.
</p>
<div className="space-x-3">
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/blog/organization-based-billing"
target="_blank"
rel="noreferrer"
>
Announcement
</Link>
</Button>
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/org-based-billing"
target="_blank"
rel="noreferrer"
>
Documentation
</Link>
</Button>
</div>
</div>
</Modal.Content>
</Modal>
</div>
)
}
@@ -2,9 +2,10 @@ import { useParams } from 'common'
import { observer } from 'mobx-react-lite'
import { IconAlertCircle, IconLoader } from 'ui'
import { NewProjectPanel, ProjectUsage } from 'components/interfaces/Home'
import { NewProjectPanel } from 'components/interfaces/Home'
import InformationBox from 'components/ui/InformationBox'
import { ProjectUsageResponseUsageKeys, useProjectUsageQuery } from 'data/usage/project-usage-query'
import ProjectUsage from './ProjectUsage'
const ProjectUsageSection = observer(() => {
const { ref: projectRef } = useParams()
+1 -2
View File
@@ -1,6 +1,5 @@
import ClientLibrary from './ClientLibrary'
import ExampleProject from './ExampleProject'
import ProjectUsage from './ProjectUsage'
import NewProjectPanel from './NewProjectPanel/NewProjectPanel'
export { ClientLibrary, ExampleProject, ProjectUsage, NewProjectPanel }
export { ClientLibrary, ExampleProject, NewProjectPanel }
@@ -1,50 +1,33 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { isEqual } from 'lodash'
import { useState } from 'react'
import { Button, Form, Input, Listbox } from 'ui'
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 Panel from 'components/ui/Panel'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
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, isLoading: isUpdating } =
useOrganizationCustomerProfileUpdateMutation()
const {
data: customer,
error: customerError,
isError: isErrorCustomer,
isLoading: isLoadingCustomer,
isSuccess: isSuccessCustomer,
} = useOrganizationCustomerProfileQuery({ slug })
const { city, country, line1, line2, postal_code, state } = customer?.address ?? {}
const [isDirty, setIsDirty] = useState(false)
const initialValues = { city, country, line1, line2, postal_code, state }
const { mutate: updateCustomer, isLoading: isUpdating } =
useOrganizationCustomerProfileUpdateMutation({
onSuccess: () => {
ui.setNotification({
category: 'success',
message: 'Successfully updated billing address',
})
setIsDirty(false)
},
onError: (error) => {
ui.setNotification({
category: 'error',
message: `Failed to update billing address: ${error.message}`,
})
},
})
const formId = 'billing-address-form'
const { city, country, line1, line2, postal_code, state } = data?.address ?? {}
const canReadBillingAddress = useCheckPermissions(
PermissionAction.BILLING_READ,
@@ -55,7 +38,9 @@ const BillingAddress = () => {
'stripe.customer'
)
const onValidate = (values: any) => {
const initialValues = { city, country, line1, line2, postal_code, state }
const validate = (values: any) => {
const errors = {} as any
if (
(values.line1 || values.line2 || values.postal_code || values.state || values.city) &&
@@ -63,145 +48,163 @@ const BillingAddress = () => {
) {
errors['country'] = 'Please select a country'
}
if (isEqual(values, initialValues)) {
setIsDirty(false)
} else {
setIsDirty(true)
}
return errors
}
const onSubmit = async (values: any) => {
const onSubmit = async (values: any, { resetForm }: any) => {
if (!slug) return console.error('Slug is required')
updateCustomer({ slug, address: values })
try {
await updateCustomerProfile({ slug, address: values })
ui.setNotification({
category: 'success',
message: 'Successfully updated billing address',
})
resetForm({ values, initialValues: values })
} catch (error) {}
}
return (
<div className="space-y-2">
<div>
<h4>Billing address</h4>
<p className="text-sm opacity-50">This will be reflected in every invoice</p>
</div>
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="sticky space-y-2 top-12">
<p className="text-base m-0">Billing Address</p>
<p className="text-sm text-foreground-light m-0">
This will be reflected in every upcoming invoice, past invoices are not affected
</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>
)}
{isLoadingCustomer && <GenericSkeletonLoader />}
{isError && (
<AlertError
subject="Failed to retrieve organization customer profile"
error={error as any}
/>
)}
{isErrorCustomer && (
<AlertError error={customerError} subject="Failed to retrieve customer profile" />
)}
{isSuccess && (
<Form
validateOnBlur
id={formId}
initialValues={initialValues}
validate={validate}
onSubmit={onSubmit}
>
{({ values, initialValues, handleReset, resetForm }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
{isSuccessCustomer && (
<Panel>
{!canReadBillingAddress ? (
<NoPermission resourceText="view this organization's billing address" />
) : (
<Form
validateOnBlur
initialValues={initialValues}
validate={onValidate}
onSubmit={onSubmit}
>
{({ handleReset }: any) => {
return (
<>
<Panel.Content className="w-3/5 space-y-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 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}
// 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={isUpdating}
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 space-x-2">
<Listbox
className="w-full"
id="country"
name="country"
placeholder="Country"
disabled={!canUpdateBillingAddress}
>
{country.name}
</Listbox.Option>
))}
</Listbox>
<Input
className="w-full"
id="postal_code"
name="postal_code"
placeholder="Postal code"
disabled={!canUpdateBillingAddress}
/>
</div>
<div className="flex 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>
</Panel.Content>
<div className="border-t border-scale-400" />
<Panel.Content className="flex justify-between">
{!canUpdateBillingAddress ? (
<p className="text-sm text-foreground-light">
You need additional permissions to update this organization's billing
address
</p>
) : (
<div />
)}
<div className="flex items-center space-x-2">
<Button
type="default"
htmlType="reset"
disabled={!isDirty || isUpdating || !canUpdateBillingAddress}
onClick={() => {
handleReset()
setIsDirty(false)
}}
>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
loading={isUpdating}
disabled={!isDirty || isUpdating || !canUpdateBillingAddress}
>
Save
</Button>
</div>
</Panel.Content>
</>
)
}}
</Form>
)}
</Panel>
)}
</div>
<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 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>
)
}
@@ -2,7 +2,7 @@ import * as Tooltip from '@radix-ui/react-tooltip'
import clsx from 'clsx'
import Link from 'next/link'
import { BILLING_BREAKDOWN_METRICS } from 'components/interfaces/BillingV2/Subscription/Subscription.constants'
import { BILLING_BREAKDOWN_METRICS } from 'components/interfaces/Billing/Subscription/Subscription.constants'
import SparkBar from 'components/ui/SparkBar'
import { OrgSubscription } from 'data/subscriptions/org-subscription-query'
import { OrgUsageResponse } from 'data/usage/org-usage-query'
@@ -2,15 +2,16 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useParams } from 'common/hooks'
import { useParams } from 'common'
import {
ScaffoldSection,
ScaffoldSectionContent,
ScaffoldSectionDetail,
} from 'components/layouts/Scaffold'
import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation'
import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query'
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
import { isResponseOk, patch } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { Organization } from 'types'
import { Form, Input } from 'ui'
const BillingEmail = () => {
@@ -21,94 +22,97 @@ const BillingEmail = () => {
const { name, billing_email } = selectedOrganization ?? {}
const formId = 'org-billing-email'
const initialValues = {
billing_email: billing_email ?? '',
}
const initialValues = { billing_email: billing_email ?? '' }
const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
const canReadBillingEmail = useCheckPermissions(PermissionAction.READ, 'organizations')
const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation()
const { mutateAsync: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation()
const onUpdateOrganization = async (values: any, { setSubmitting, resetForm }: any) => {
const onUpdateOrganizationEmail = async (values: any, { resetForm }: any) => {
if (!canUpdateOrganization) {
return ui.setNotification({
category: 'error',
message: 'You do not have the required permissions to update this organization',
})
}
if (!slug) return console.error('Slug is required')
if (!name) return console.error('Organization name is required')
updateOrganization(
{ slug, billing_email: values.billing_email },
{
onSuccess: ({ billing_email }) => {
resetForm({ values: { billing_email }, initialValues: { billing_email } })
invalidateOrganizationsQuery(queryClient)
ui.setNotification({ category: 'success', message: 'Successfully saved settings' })
},
}
)
try {
const { billing_email } = await updateOrganization({
slug,
name,
billing_email: values.billing_email,
})
resetForm({ values: { billing_email }, initialValues: { billing_email } })
invalidateOrganizationsQuery(queryClient)
ui.setNotification({
category: 'success',
message: 'Successfully saved settings',
})
} finally {
}
}
return (
<div className="container my-4">
<h4>Billing email</h4>
<p className="text-sm opacity-50">All billing correspondence will go to this email</p>
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="sticky space-y-2 top-12">
<p className="text-base m-0">Email Recipient</p>
<p className="text-sm text-foreground-light m-0">
All billing correspondence will go to this email
</p>
</div>
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
<Form id={formId} initialValues={initialValues} onSubmit={onUpdateOrganizationEmail}>
{({ handleReset, values, initialValues, resetForm }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
<div className="mt-3">
{selectedOrganization === undefined ? (
<GenericSkeletonLoader />
) : (
<Form id={formId} initialValues={initialValues} onSubmit={onUpdateOrganization}>
{({ 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])
// [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={isUpdating}
hasChanges={hasChanges}
handleReset={handleReset}
helper={
!canUpdateOrganization
? "You need additional permissions to manage this organization's settings"
: undefined
}
/>
</div>
}
>
<FormSection className="-mx-2">
<FormSectionContent loading={false}>
<Input
id="billing_email"
size="small"
label="Email address"
type={canReadBillingEmail ? 'text' : 'password'}
disabled={!canUpdateOrganization}
/>
</FormSectionContent>
</FormSection>
</FormPanel>
)
}}
</Form>
)}
</div>
</div>
return (
<FormPanel
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdating}
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>
)
}
@@ -1,56 +1,81 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useProjectsQuery } from 'data/projects/projects-query'
import { useCheckPermissions, useFlag, useSelectedOrganization } from 'hooks'
import OrganizationBillingMigrationPanel from '../GeneralSettings/OrganizationBillingMigrationPanel'
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'
import ProjectsSummary from './ProjectsSummary'
import PaymentMethods from './PaymentMethods/PaymentMethods'
import Subscription from './Subscription/Subscription'
import TaxID from './TaxID/TaxID'
import BillingBreakdown from './BillingBreakdown/BillingBreakdown'
import { useIsFeatureEnabled } from 'hooks'
const BillingSettings = () => {
const { slug } = useParams()
const organization = useSelectedOrganization()
const { data: allProjects } = useProjectsQuery()
const projects =
allProjects?.filter((project) => project.organization_id === organization?.id) ?? []
const { data: customer } = useOrganizationCustomerProfileQuery({ slug })
const customerBalance = customer && customer.balance ? customer.balance / 100 : 0
const isCredit = customerBalance < 0
const isDebt = customerBalance > 0
const balance =
isCredit && customerBalance !== 0
? customerBalance.toString().replace('-', '')
: customerBalance
const orgBillingMigrationEnabled = useFlag('orgBillingMigration')
const selectedOrganization = useSelectedOrganization()
const { subscription_id } = selectedOrganization ?? {}
const {
billingAccountData: billingAccountDataEnabled,
billingPaymentMethods: billingPaymentMethodsEnabled,
billingCredits: billingCreditsEnabled,
} = useIsFeatureEnabled(['billing:account_data', 'billing:payment_methods', 'billing:credits'])
return (
<ScaffoldContainerLegacy>
{orgBillingMigrationEnabled && !subscription_id && <OrganizationBillingMigrationPanel />}
<>
<ScaffoldContainer id="subscription">
<Subscription />
</ScaffoldContainer>
<ProjectsSummary projects={projects} />
<ScaffoldDivider />
<CreditBalance balance={balance} isCredit={isCredit} isDebt={isDebt} />
<ScaffoldContainer id="cost-control">
<CostControl />
</ScaffoldContainer>
<PaymentMethods />
<ScaffoldDivider />
<BillingEmail />
<ScaffoldContainer id="breakdown">
<BillingBreakdown />
</ScaffoldContainer>
<BillingAddress />
{billingCreditsEnabled && (
<>
<ScaffoldDivider />
<TaxID />
</ScaffoldContainerLegacy>
<ScaffoldContainer id="credits-balance">
<CreditBalance />
</ScaffoldContainer>
</>
)}
{billingPaymentMethodsEnabled && (
<>
<ScaffoldDivider />
<ScaffoldContainer id="payment-methods">
<PaymentMethods />
</ScaffoldContainer>
</>
)}
{billingAccountDataEnabled && (
<>
<ScaffoldDivider />
<ScaffoldContainer id="email">
<BillingEmail />
</ScaffoldContainer>
<ScaffoldDivider />
<ScaffoldContainer id="address">
<BillingAddress />
</ScaffoldContainer>
<ScaffoldDivider />
<ScaffoldContainer id="taxId">
<TaxID />
</ScaffoldContainer>
</>
)}
</>
)
}
@@ -22,7 +22,7 @@ import {
IconCalendar,
IconExternalLink,
} from 'ui'
import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip'
import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip'
import SpendCapSidePanel from './SpendCapSidePanel'
import Image from 'next/image'
import dayjs from 'dayjs'
@@ -1,29 +1,65 @@
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'
interface CreditBalanceProps {
balance: string | number
isCredit: boolean
isDebt: boolean
}
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)
const CreditBalance = ({ balance, isCredit, isDebt }: CreditBalanceProps) => {
return (
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<h4>Credit balance</h4>
{isCredit && <Badge>You have credits available</Badge>}
{isDebt && <Badge color="red">Outstanding payments</Badge>}
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="sticky space-y-2 top-12">
<div className="flex items-center space-x-2">
<p className="text-base m-0">Credit balance</p>
{isCredit && <Badge>You have credits available</Badge>}
{isDebt && <Badge color="red">Outstanding payments</Badge>}
</div>
<p className="text-sm text-foreground-light m-0">
Charges will be deducted from your balance first
</p>
</div>
<p className="text-sm opacity-50">Charges will be deducted from your balance first</p>
</div>
<div className="flex items-end space-x-1">
{isDebt && <h4 className="opacity-50">-</h4>}
<h4 className="opacity-50">$</h4>
<h2 className="text-4xl">{balance}</h2>
{isCredit && <h4 className="opacity-50">/credits</h4>}
</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 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>
)
}
@@ -18,7 +18,6 @@ import {
Modal,
} from 'ui'
import { AddNewPaymentMethodModal } from 'components/interfaces/BillingV2'
import AlertError from 'components/ui/AlertError'
import NoPermission from 'components/ui/NoPermission'
import Panel from 'components/ui/Panel'
@@ -30,6 +29,7 @@ import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organizat
import { useCheckPermissions, useStore } from 'hooks'
import { BASE_PATH } from 'lib/constants'
import { getURL } from 'lib/helpers'
import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal'
const PaymentMethods = () => {
const { ui } = useStore()
@@ -3,7 +3,6 @@ import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useParams } from 'common'
import { AddNewPaymentMethodModal } from 'components/interfaces/BillingV2'
import {
ScaffoldSection,
ScaffoldSectionContent,
@@ -35,6 +34,7 @@ import {
} from 'ui'
import ChangePaymentMethodModal from './ChangePaymentMethodModal'
import DeletePaymentMethodModal from './DeletePaymentMethodModal'
import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal'
const PaymentMethods = () => {
const { ui } = useStore()
@@ -1,105 +0,0 @@
import dayjs from 'dayjs'
import Link from 'next/link'
import { useEffect } from 'react'
import { IconChevronRight, IconLoader } from 'ui'
import Panel from 'components/ui/Panel'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useStore } from 'hooks'
interface ProjectSummaryProps {
project: any
}
const ProjectSummary = ({ project }: ProjectSummaryProps) => {
const { ui } = useStore()
const {
data: subscription,
isLoading: loading,
error,
} = useProjectSubscriptionV2Query({ projectRef: project.ref })
const currentPeriodStart = subscription?.current_period_start ?? 0
const currentPeriodEnd = subscription?.current_period_end ?? 0
useEffect(() => {
if (error) {
ui.setNotification({
category: 'error',
message: `Failed to get project subscription: ${(error as any)?.message ?? 'unknown'}`,
})
}
}, [error])
return (
<div className="flex items-center w-full px-6 py-3">
<div className="w-[25%]">
<p className="text-sm">{project.name}</p>
</div>
{loading ? (
<div className="flex w-[75%] items-center justify-center">
<IconLoader size={14} className="animate-spin" />
</div>
) : (
<>
<div className="w-[20%]">
<p className="text-sm">{subscription?.plan.name ?? ''}</p>
</div>
<div className="flex w-[40%] items-center space-x-2">
<p className="text-sm">{dayjs.unix(currentPeriodStart).utc().format('MMM D, YYYY')}</p>
<p className="text-sm">-</p>
<p className="text-sm">{dayjs.unix(currentPeriodEnd).utc().format('MMM D, YYYY')}</p>
</div>
<div className="flex w-[15%] items-center justify-end">
<Link
href={`/project/${project.ref}/settings/billing/subscription`}
className="flex items-center space-x-2 group"
>
<p className="text-xs transition opacity-0 group-hover:opacity-100">View details</p>
<IconChevronRight strokeWidth={1.5} />
</Link>
</div>
</>
)}
</div>
)
}
interface ProjectsSummaryProps {
projects: any
}
const ProjectsSummary = ({ projects }: ProjectsSummaryProps) => {
return (
<div className="space-y-2">
<h4>Projects at a glance</h4>
<Panel
title={
<div className="flex items-center w-full">
<div className="w-[25%]">
<p className="text-sm opacity-50">Name</p>
</div>
<div className="w-[20%]">
<p className="text-sm opacity-50">Plan</p>
</div>
<div className="w-[40%]">
<p className="text-sm opacity-50">Billing cycle</p>
</div>
<div className="w-[15%]" />
</div>
}
>
{projects.map((project: any) => (
<ProjectSummary key={project.ref} project={project} />
))}
{projects.length === 0 && (
<Panel.Content>
<p className="text-sm text-foreground-light">No projects created yet</p>
</Panel.Content>
)}
</Panel>
</div>
)
}
export default ProjectsSummary
@@ -8,7 +8,7 @@ import { OrgSubscription } from 'data/subscriptions/org-subscription-query'
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
import { useFlag, useStore } from 'hooks'
import { Alert, Button, Input, Modal } from 'ui'
import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip'
import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip'
import { CANCELLATION_REASONS } from '../BillingSettings.constants'
export interface ExitSurveyModalProps {
@@ -3,13 +3,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'common'
import { AddNewPaymentMethodModal } from 'components/interfaces/BillingV2'
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/Payment/AddNewPaymentMethodModal'
export interface PaymentMethodSelectionProps {
selectedPaymentMethod?: string
@@ -199,7 +199,7 @@ const PlanUpdateSidePanel = () => {
) : (
<p className="text-foreground text-lg">${price}</p>
)}
<p className="text-foreground-light text-sm">{tierMeta?.costUnitOrg}</p>
<p className="text-foreground-light 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-600 border shadow-sm rounded-md bg-opacity-30 py-0.5 px-2 text-xs">
@@ -22,7 +22,7 @@ import {
IconCalendar,
IconExternalLink,
} from 'ui'
import ProjectUpdateDisabledTooltip from '../../BillingSettings/ProjectUpdateDisabledTooltip'
import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip'
import PlanUpdateSidePanel from './PlanUpdateSidePanel'
import { useOrganizationBillingSubscriptionCancelSchedule } from 'data/subscriptions/org-subscription-cancel-schedule-mutation'
@@ -11,14 +11,6 @@ export interface TaxId {
vatPrefix?: string
}
export interface StripeTaxId {
id: string
type: string
value: string
name: string
country?: string
}
export const TAX_IDS: TaxId[] = [
{
name: 'AE TRN',
@@ -1,39 +1,37 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { isEqual } from 'lodash'
import { useEffect, useState } from 'react'
import { Button, IconPlus, IconX, Input, Listbox } from 'ui'
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 Panel from 'components/ui/Panel'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useOrganizationTaxIDsQuery } from 'data/organizations/organization-tax-ids-query'
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 { StripeTaxId, TAX_IDS } from './TaxID.constants'
import { sanitizeTaxID } from './TaxID.utils'
// Stripe recommends to delete tax ids and create new ones to update
// https://stripe.com/docs/billing/customer/tax-ids
import { Button, Form, IconPlus, IconX, Input, Listbox } from 'ui'
import { TAX_IDS } from './TaxID.constants'
import { checkTaxIdsEqual, sanitizeTaxID } from './TaxID.utilts'
const TaxID = () => {
const { ui } = useStore()
const { slug } = useParams()
const {
data,
error: taxIdsError,
isLoading: isLoadingTaxIds,
isError: isErrorTaxIds,
isSuccess: isSuccessTaxIds,
data: taxIds,
error,
isLoading,
isSuccess,
isError,
} = useOrganizationTaxIDsQuery({ slug })
const taxIds = data || []
const { mutate: updateTaxIDs, isLoading: isUpdating } = useOrganizationTaxIDsUpdateMutation({
onSuccess: (res) => {
const { created, errors } = res
@@ -64,20 +62,27 @@ const TaxID = () => {
const [errors, setErrors] = useState<string[]>([])
const [taxIdValues, setTaxIdValues] = useState<TaxIdValue[]>([])
const formattedTaxIds: StripeTaxId[] = taxIds.map((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 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 = !checkTaxIdsEqual(taxIdValues, formattedTaxIds)
const canReadTaxIds = useCheckPermissions(PermissionAction.BILLING_READ, 'stripe.tax_ids')
const canUpdateTaxIds = useCheckPermissions(PermissionAction.BILLING_WRITE, 'stripe.tax_ids')
useEffect(() => {
if (isSuccessTaxIds) {
if (isSuccess) {
if (taxIdValues.length === 0) {
setTaxIdValues(formattedTaxIds)
} else {
@@ -85,11 +90,8 @@ const TaxID = () => {
setTaxIdValues(formattedTaxIds.concat(erroredTaxIds))
}
}
}, [isSuccessTaxIds])
const hasChanges = !isEqual(taxIdValues, formattedTaxIds)
const canReadTaxIds = useCheckPermissions(PermissionAction.BILLING_READ, 'stripe.tax_ids')
const canUpdateTaxIds = useCheckPermissions(PermissionAction.BILLING_WRITE, 'stripe.tax_ids')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess])
const onUpdateTaxId = (id: string, key: string, value: string) => {
const updatedTaxIds = taxIdValues.map((taxId: any) => {
@@ -113,9 +115,7 @@ const TaxID = () => {
}
const onRemoveTaxId = (id: string) => {
const updatedTaxIds = taxIdValues.filter((taxId: StripeTaxId) => {
return taxId.id !== id
})
const updatedTaxIds = taxIdValues.filter((taxId) => taxId.id !== id)
setTaxIdValues(updatedTaxIds)
}
@@ -123,7 +123,7 @@ const TaxID = () => {
setTaxIdValues(formattedTaxIds)
}
const onSaveTaxIds = async () => {
const onSaveTaxIds = async (values: any) => {
if (!slug) return console.error('Slug is required')
if (taxIds === undefined) return console.error('Tax IDs are required')
@@ -132,123 +132,129 @@ const TaxID = () => {
}
return (
<div className="space-y-2">
<div>
<h4>Tax ID</h4>
<p className="text-sm opacity-50">
If you would like to include specific tax ID(s) to your invoices. <br />
Make sure the tax ID looks exactly like the placeholder text.
</p>
</div>
{isLoadingTaxIds && <GenericSkeletonLoader />}
{!isLoadingTaxIds && !canReadTaxIds && (
<Panel>
<NoPermission resourceText="to view this organization's tax IDs" />
</Panel>
)}
{isErrorTaxIds && <AlertError error={taxIdsError} subject="Failed to retrieve tax IDs" />}
{isSuccessTaxIds && (
<Panel
footer={
!isLoadingTaxIds && (
<div className="flex w-full justify-between">
{!canUpdateTaxIds ? (
<p className="text-sm text-foreground-light">
You need additional permissions to update this organization's tax IDs
</p>
) : (
<div />
)}
<div className="flex items-center space-x-2">
<Button
type="default"
htmlType="reset"
disabled={!hasChanges || isUpdating}
onClick={() => onResetTaxIds()}
>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
loading={isUpdating}
disabled={!hasChanges || isUpdating}
onClick={() => onSaveTaxIds()}
>
Save
</Button>
</div>
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="sticky space-y-2 top-12">
<p className="text-base m-0">Tax ID</p>
<p className="text-sm text-foreground-light pr-4 m-0">
Add tax ID(s) to have them appear in your invoices. Old invoices are not affected.
</p>
<p className="text-sm text-foreground-light m-0">
Make sure the tax ID looks exactly like the placeholder text.
</p>
</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>
)
}
>
<Panel.Content className="w-8/12 space-y-4">
{taxIdValues.length >= 1 ? (
<div className="w-full space-y-2">
{taxIdValues.map((taxId: StripeTaxId, idx: number) => {
const selectedTaxId = TAX_IDS.find((option) => option.name === taxId.name)
)}
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"
icon={<IconX />}
onClick={() => onRemoveTaxId(taxId.id)}
{isError && (
<AlertError error={error} subject="Failed to retrieve organization tax IDs" />
)}
{isSuccess && (
<Form validateOnBlur id={formId} initialValues={{}} onSubmit={onSaveTaxIds}>
{() => (
<FormPanel
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdating}
hasChanges={hasChanges}
handleReset={() => onResetTaxIds()}
helper={
!canUpdateTaxIds
? "You need additional permissions to manage this organization's tax IDs"
: undefined
}
/>
)}
</div>
)
})}
</div>
) : (
<div>
<p className="flex items-center space-x-2 text-sm text-foreground-lighter">
No tax IDs
</p>
</div>
</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.name === taxId.name
)
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-foreground-lighter">
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>
)}
{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>
)}
</Panel.Content>
</Panel>
)}
</div>
</>
)}
</ScaffoldSectionContent>
</ScaffoldSection>
)
}
@@ -1,22 +0,0 @@
import { StripeTaxId, TAX_IDS } from './TaxID.constants'
/**
* 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: StripeTaxId) => {
let newID = taxId
const selectedTaxId = TAX_IDS.find((option) => option.name === taxId.name)
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
}
@@ -1,248 +0,0 @@
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' },
]
@@ -1,211 +0,0 @@
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, isLoading: isUpdating } =
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 validate = (values: any) => {
const errors = {} as any
if (
(values.line1 || values.line2 || values.postal_code || values.state || values.city) &&
!values.country
) {
errors['country'] = 'Please select a country'
}
return errors
}
const onSubmit = async (values: any, { resetForm }: any) => {
if (!slug) return console.error('Slug is required')
try {
await updateCustomerProfile({ slug, address: values })
ui.setNotification({
category: 'success',
message: 'Successfully updated billing address',
})
resetForm({ values, initialValues: values })
} catch (error) {}
}
return (
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="sticky space-y-2 top-12">
<p className="text-base m-0">Billing Address</p>
<p className="text-sm text-foreground-light m-0">
This will be reflected in every upcoming invoice, past invoices are not affected
</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="Failed to retrieve organization customer profile"
error={error as any}
/>
)}
{isSuccess && (
<Form
validateOnBlur
id={formId}
initialValues={initialValues}
validate={validate}
onSubmit={onSubmit}
>
{({ 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={isUpdating}
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 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 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
@@ -1,119 +0,0 @@
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 { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation'
import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query'
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
import { Form, Input } from 'ui'
const BillingEmail = () => {
const { ui } = useStore()
const { slug } = useParams()
const queryClient = useQueryClient()
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 { mutateAsync: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation()
const onUpdateOrganizationEmail = async (values: any, { resetForm }: any) => {
if (!canUpdateOrganization) {
return ui.setNotification({
category: 'error',
message: 'You do not have the required permissions to update this organization',
})
}
if (!slug) return console.error('Slug is required')
if (!name) return console.error('Organization name is required')
try {
const { billing_email } = await updateOrganization({
slug,
name,
billing_email: values.billing_email,
})
resetForm({ values: { billing_email }, initialValues: { billing_email } })
invalidateOrganizationsQuery(queryClient)
ui.setNotification({
category: 'success',
message: 'Successfully saved settings',
})
} finally {
}
}
return (
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="sticky space-y-2 top-12">
<p className="text-base m-0">Email Recipient</p>
<p className="text-sm text-foreground-light m-0">
All billing correspondence will go to this email
</p>
</div>
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
<Form id={formId} initialValues={initialValues} onSubmit={onUpdateOrganizationEmail}>
{({ 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={isUpdating}
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
@@ -1,82 +0,0 @@
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'
import BillingBreakdown from './BillingBreakdown/BillingBreakdown'
import { useIsFeatureEnabled } from 'hooks'
const BillingSettings = () => {
const {
billingAccountData: billingAccountDataEnabled,
billingPaymentMethods: billingPaymentMethodsEnabled,
billingCredits: billingCreditsEnabled,
} = useIsFeatureEnabled(['billing:account_data', 'billing:payment_methods', 'billing:credits'])
return (
<>
<ScaffoldContainer id="subscription">
<Subscription />
</ScaffoldContainer>
<ScaffoldDivider />
<ScaffoldContainer id="cost-control">
<CostControl />
</ScaffoldContainer>
<ScaffoldDivider />
<ScaffoldContainer id="breakdown">
<BillingBreakdown />
</ScaffoldContainer>
{billingCreditsEnabled && (
<>
<ScaffoldDivider />
<ScaffoldContainer id="credits-balance">
<CreditBalance />
</ScaffoldContainer>
</>
)}
{billingPaymentMethodsEnabled && (
<>
<ScaffoldDivider />
<ScaffoldContainer id="payment-methods">
<PaymentMethods />
</ScaffoldContainer>
</>
)}
{billingAccountDataEnabled && (
<>
<ScaffoldDivider />
<ScaffoldContainer id="email">
<BillingEmail />
</ScaffoldContainer>
<ScaffoldDivider />
<ScaffoldContainer id="address">
<BillingAddress />
</ScaffoldContainer>
<ScaffoldDivider />
<ScaffoldContainer id="taxId">
<TaxID />
</ScaffoldContainer>
</>
)}
</>
)
}
export default BillingSettings
@@ -1,66 +0,0 @@
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 space-y-2 top-12">
<div className="flex items-center space-x-2">
<p className="text-base m-0">Credit balance</p>
{isCredit && <Badge>You have credits available</Badge>}
{isDebt && <Badge color="red">Outstanding payments</Badge>}
</div>
<p className="text-sm text-foreground-light m-0">
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="Failed 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
@@ -1,65 +0,0 @@
import clsx from 'clsx'
import { PricingInformation } from 'shared-data'
import { Button, IconCheck } from 'ui'
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 text-sm uppercase')}>{plan.name}</p>
{isCurrentPlan ? (
<div className="text-xs bg-scale-500 text-foreground-light rounded px-2 py-0.5">
Current plan
</div>
) : plan.nameBadge ? (
<div className="text-xs bg-brand-400 text-brand 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-foreground-light 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 h-4 w-4" aria-hidden="true" strokeWidth={3} />
<span className="dark:text-foreground 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
@@ -1,459 +0,0 @@
// 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',
},
]
@@ -1,262 +0,0 @@
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 { Button, Form, IconPlus, IconX, Input, Listbox } from 'ui'
import { TAX_IDS } from './TaxID.constants'
import { checkTaxIdsEqual, sanitizeTaxID } from './TaxID.utilts'
const TaxID = () => {
const { ui } = useStore()
const { slug } = useParams()
const {
data: taxIds,
error,
isLoading,
isSuccess,
isError,
} = useOrganizationTaxIDsQuery({ slug })
const { mutate: updateTaxIDs, isLoading: isUpdating } = useOrganizationTaxIDsUpdateMutation({
onSuccess: (res) => {
const { created, errors } = res
setErrors(errors?.map((taxId) => taxId.id) ?? [])
const updatedTaxIds =
created?.map((x) => {
return {
id: x.id,
type: x.type,
value: x.value,
name:
x.type === 'eu_vat'
? `${x.country} VAT`
: TAX_IDS.find((option) => option.code === x.type)?.name ?? '',
}
}) ?? []
setTaxIdValues(updatedTaxIds)
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' })
}
},
})
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 = !checkTaxIdsEqual(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) => {
if (!slug) return console.error('Slug is required')
if (taxIds === undefined) return console.error('Tax IDs are required')
const newIds = taxIdValues.map((x) => sanitizeTaxID(x))
updateTaxIDs({ slug, existingIds: taxIds, newIds })
}
return (
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="sticky space-y-2 top-12">
<p className="text-base m-0">Tax ID</p>
<p className="text-sm text-foreground-light pr-4 m-0">
If you would like to include specific tax ID(s) to your invoices. Old invoices are not
affected.
</p>
<p className="text-sm text-foreground-light m-0">
Make sure the tax ID looks exactly like the placeholder text.
</p>
</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="Failed to retrieve organization tax IDs" />
)}
{isSuccess && (
<Form validateOnBlur id={formId} initialValues={{}} onSubmit={onSaveTaxIds}>
{() => (
<FormPanel
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdating}
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.name === taxId.name
)
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-foreground-lighter">
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
@@ -7,7 +7,7 @@ import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Collapsible, Form, IconChevronRight, Input, Toggle } from 'ui'
import NoProjectsOnPaidOrgInfo from 'components/interfaces/BillingV2/NoProjectsOnPaidOrgInfo'
import NoProjectsOnPaidOrgInfo from 'components/interfaces/Billing/NoProjectsOnPaidOrgInfo'
import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold'
import {
FormActions,
@@ -1,508 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { observer } from 'mobx-react-lite'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
IconAlertCircle,
IconAlertTriangle,
IconHelpCircle,
Listbox,
Loading,
Modal,
Toggle,
} from 'ui'
import { SpendCapModal } from 'components/interfaces/BillingV2'
import InformationBox from 'components/ui/InformationBox'
import { useOrganizationBillingMigrationMutation } from 'data/organizations/organization-migrate-billing-mutation'
import { useOrganizationBillingMigrationPreview } from 'data/organizations/organization-migrate-billing-preview-query'
import { useCheckPermissions, useFlag, useSelectedOrganization, useStore } from 'hooks'
import { PRICING_TIER_LABELS_ORG } from 'lib/constants'
import PaymentMethodSelection from '../BillingSettingsV2/Subscription/PaymentMethodSelection'
const MigrateOrganizationBillingButton = observer(() => {
const { ui } = useStore()
const router = useRouter()
const organization = useSelectedOrganization()
const disableOrgBillingMigration = useFlag('disableOrgBillingMigration')
const canMigrateOrg = useCheckPermissions(PermissionAction.BILLING_WRITE, 'stripe.subscriptions')
const [isOpen, setIsOpen] = useState(false)
const [tier, setTier] = useState('')
const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false)
const [isSpendCapEnabled, setIsSpendCapEnabled] = useState(false)
const [paymentMethodId, setPaymentMethodId] = useState('')
const dbTier = useMemo(() => {
if (tier === '') return ''
if (tier === 'PRO' && !isSpendCapEnabled) {
return `tier_payg`
} else {
return `tier_` + tier.toLocaleLowerCase()
}
}, [tier, isSpendCapEnabled])
const {
error: migrationError,
mutate: migrateBilling,
isLoading: isMigrating,
} = useOrganizationBillingMigrationMutation({
onSuccess: () => {
ui.setNotification({
message: 'Successfully migrated to organization-based billing',
category: 'success',
duration: 5000,
})
router.push('/projects')
setIsOpen(false)
},
})
const {
data: migrationPreviewData,
error: migrationPreviewError,
isLoading: migrationPreviewIsLoading,
isSuccess: migrationPreviewIsSuccess,
remove,
refetch: previewMigration,
} = useOrganizationBillingMigrationPreview(
{ tier: dbTier, organizationSlug: organization?.slug },
{ enabled: false, refetchOnWindowFocus: false }
)
useEffect(() => {
if (dbTier) {
previewMigration()
}
}, [dbTier])
useEffect(() => {
if (isOpen) {
// reset state
setTier('')
} else {
// Invalidate cache
remove()
}
}, [isOpen])
const selectedLimitedUsage = useMemo(
() => tier === 'PRO' && isSpendCapEnabled,
[tier, isSpendCapEnabled]
)
const downgradingToLimitedUsage = useMemo(() => {
if (migrationPreviewData) {
const hadUsageBillingEnabled = migrationPreviewData.old_tiers.some((it) =>
['tier_payg', 'tier_team', 'tier_enterprise'].includes(it)
)
return hadUsageBillingEnabled && selectedLimitedUsage
} else {
return false
}
}, [migrationPreviewData, selectedLimitedUsage])
const toggle = () => {
setIsOpen(!isOpen)
}
const onConfirmMigrate = async () => {
if (!tier) return
if (!canMigrateOrg) {
return ui.setNotification({
category: 'error',
message: 'You do not have the required permissions to migrate this organization',
})
}
migrateBilling({ organizationSlug: organization?.slug, tier: dbTier, paymentMethodId })
}
return (
<>
<div>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
loading={!organization?.slug}
onClick={toggle}
type="primary"
disabled={disableOrgBillingMigration}
>
Migrate organization
</Button>
</Tooltip.Trigger>
{(!canMigrateOrg || disableOrgBillingMigration) && (
<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
'border border-scale-200 ', //border
].join(' ')}
>
<span className="text-xs text-foreground">
{!canMigrateOrg
? 'You need additional permissions to migrate this organization'
: 'Migrations are temporarily disabled, please try again later.'}
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
</div>
<Modal
closable
hideFooter
size="xlarge"
visible={isOpen}
onCancel={toggle}
header={
<div className="flex items-baseline gap-2">
<h5 className="text-sm text-foreground">Migrate organization</h5>
</div>
}
>
<div className="space-y-4 py-3">
{migrationPreviewData?.addons_to_be_removed &&
migrationPreviewData.addons_to_be_removed.length > 0 && (
<>
<Modal.Content>
<div className="space-y-2">
<Alert_Shadcn_ variant="warning">
<IconAlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>Project addons will be removed</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<div>
The following project addons will be removed when downgrading
<ul className="list-disc list-inside pl-4">
{migrationPreviewData.addons_to_be_removed.map((addon) =>
addon.addons.map((variant) => (
<li key={`${addon.projectRef}-${variant.variant}`}>
{variant.type === 'pitr'
? 'PITR - '
: variant.type === 'compute_instance'
? 'Compute Instance - '
: ''}
{variant.name} for project {addon.projectName || addon.projectRef}
</li>
))
)}
</ul>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
</Modal.Content>
<Modal.Separator />
</>
)}
<Modal.Content>
<div className="text-foreground-light text-sm space-y-2">
<p>
With organization-based billing, you'll have a single subscription for your entire
organization. For a detailed breakdown of changes, see{' '}
<Link
href="https://supabase.com/docs/guides/platform/org-based-billing"
target="_blank"
rel="noreferrer"
className="underline"
>
Billing Migration Docs
</Link>
. To transfer projects to a different organization, visit{' '}
<Link
href="/project/_/settings/general"
target="_blank"
rel="noreferrer"
className="underline"
>
general project settings
</Link>
.
</p>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content>
<Listbox
id="tier"
label="Organization Pricing Plan"
layout="horizontal"
value={tier}
onChange={setTier}
className="flex items-center"
>
<Listbox.Option label="Select plan" value="" disabled className="hidden">
Select Plan
</Listbox.Option>
{Object.entries(PRICING_TIER_LABELS_ORG).map(([k, v]) => {
return (
<Listbox.Option key={k} label={v} value={k}>
{v}
</Listbox.Option>
)
})}
</Listbox>
<p className="text-sm text-foreground-light mt-4">
The pricing plan, along with included usage limits will apply to your entire
organization. See{' '}
<a
className="underline"
target="_blank"
rel="noreferrer"
href="https://supabase.com/pricing"
>
Pricing
</a>{' '}
for more details.
</p>
{tier !== '' && tier !== 'FREE' && (
<div className="my-2 space-y-1 pb-4">
<p className="text-sm text-foreground-light">
Paid plans come with one compute instance included. Additional projects will at
least cost the compute instance hours used (min $10/month). See{' '}
<Link
href="https://supabase.com/docs/guides/platform/org-based-billing#usage-based-billing-for-compute"
target="_blank"
rel="noreferrer"
className="underline"
>
Compute Instance Usage Billing
</Link>{' '}
for more details.
</p>
</div>
)}
</Modal.Content>
<Modal.Separator />
{tier === 'PRO' && (
<>
<Modal.Content>
<div className="mt-4 grid grid-cols-8 gap-x-8 gap-y-2">
<div className="space-y-2 col-span-4">
<p className="text-sm flex items-center gap-4">
Enable spend cap{' '}
<IconHelpCircle
size={16}
strokeWidth={1.5}
className="transition opacity-50 cursor-pointer hover:opacity-100"
onClick={() => setShowSpendCapHelperModal(true)}
/>
</p>
</div>
<div className="col-span-8">
<Toggle
id="isSpendCapEnabled"
layout="vertical"
checked={isSpendCapEnabled}
onChange={() => setIsSpendCapEnabled(!isSpendCapEnabled)}
/>
</div>
<div className="col-span-12">
<p className="text-sm text-foreground-light">
When enabled, usage is limited to the plan's quota, with restrictions when
limits are exceeded. When disabled, you scale beyond Pro limits without
restrictions and pay for over-usage beyond the quota.
</p>
</div>
<SpendCapModal
visible={showSpendCapHelperModal}
onHide={() => setShowSpendCapHelperModal(false)}
/>
</div>
</Modal.Content>
<Modal.Separator />
</>
)}
{tier && tier !== 'FREE' && (
<>
<Modal.Content>
<PaymentMethodSelection
layout="horizontal"
onSelectPaymentMethod={(pm) => setPaymentMethodId(pm)}
/>
</Modal.Content>
<Modal.Separator />
</>
)}
<Modal.Content>
<Loading active={tier !== '' && migrationPreviewIsLoading}>
<div className="space-y-3">
{migrationPreviewError && (
<Alert_Shadcn_ variant="destructive">
<IconAlertCircle strokeWidth={2} />
<AlertTitle_Shadcn_>Organization cannot be migrated</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{migrationPreviewError.message}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{(downgradingToLimitedUsage ||
(!downgradingToLimitedUsage && selectedLimitedUsage)) && (
<Alert_Shadcn_ variant="warning">
<IconAlertCircle strokeWidth={2} />
<AlertTitle_Shadcn_>Spend Cap is enabled</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<div className="space-y-2">
{downgradingToLimitedUsage ? (
<p>
You previously had the spend cap disabled to scale seamlessly beyond the
included quota. You will run into restrictions in case you exceed the
included quota.
</p>
) : (
<p>
With the Spend Cap enabled, your projects will be restricted once you
exhaust the included quota. To scale seamlessly beyond the included
quota, disable the Spend Cap.
</p>
)}
<Button type="outline" onClick={() => setIsSpendCapEnabled(false)}>
Disable Spend Cap
</Button>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
</div>
</Loading>
{migrationError && (
<Alert_Shadcn_ variant="destructive">
<IconAlertCircle strokeWidth={2} />
<AlertTitle_Shadcn_>Organization cannot be migrated</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{migrationError.message}</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
</Modal.Content>
{migrationPreviewIsSuccess && dbTier !== 'tier_free' && (
<Modal.Content>
<InformationBox
defaultVisibility={true}
title={
<span>
Estimated monthly price is $
{migrationPreviewData.monthly_invoice_breakdown.reduce(
(prev, cur) => prev + cur.total_price,
0
)}{' '}
+ usage
</span>
}
hideCollapse={false}
description={
<div>
<table className="w-full">
<thead>
<tr className="border-b">
<th className="py-2 font-normal text-left text-sm text-foreground-light w-1/2">
Item
</th>
<th className="py-2 font-normal text-left text-sm text-foreground-light">
Count
</th>
<th className="py-2 font-normal text-left text-sm text-foreground-light">
Unit price
</th>
<th className="py-2 font-normal text-right text-sm text-foreground-light">
Price
</th>
</tr>
</thead>
<tbody>
{migrationPreviewData.monthly_invoice_breakdown.map((item) => (
<tr key={item.description} className="border-b">
<td className="py-2 text-sm">{item.description ?? 'Unknown'}</td>
<td className="py-2 text-sm">{item.quantity}</td>
<td className="py-2 text-sm">
{item.unit_price === 0 ? 'FREE' : `$${item.unit_price}`}
</td>
<td className="py-2 text-sm text-right">${item.total_price}</td>
</tr>
))}
</tbody>
<tbody>
<tr>
<td className="py-2 text-sm font-medium">Total</td>
<td className="py-2 text-sm" />
<td className="py-2 text-sm" />
<td className="py-2 text-sm text-right font-medium">
$
{migrationPreviewData.monthly_invoice_breakdown.reduce(
(prev, cur) => prev + cur.total_price,
0
) ?? 0}
</td>
</tr>
</tbody>
</table>
{migrationPreviewData.old_tiers.some((it) => it === 'tier_free') && (
<div className="mt-2">
<p>
While paused projects do not incur compute costs, the breakdown assumes
that none of your projects are paused. Unpausing projects is self-serve
and pausing projects on paid plans is currently only possible via support
due to infrastructure restrictions.
</p>
</div>
)}
</div>
}
/>
</Modal.Content>
)}
<Modal.Content>
<p className="mb-4 text-sm text-foreground-light">
The migration can take up to 30 seconds, please do not cancel the request or close
your browser.
</p>
<Button
block
size="small"
type="primary"
htmlType="submit"
loading={isMigrating}
disabled={
migrationPreviewData === undefined ||
isMigrating ||
!tier ||
disableOrgBillingMigration
}
onClick={() => onConfirmMigrate()}
>
I understand, migrate this organization
</Button>
</Modal.Content>
</div>
</Modal>
</>
)
})
export default MigrateOrganizationBillingButton
@@ -1,48 +0,0 @@
import { observer } from 'mobx-react-lite'
import Link from 'next/link'
import { Badge, Button, IconExternalLink, IconTruck } from 'ui'
import Panel from 'components/ui/Panel'
import MigrateOrganizationBillingButton from './MigrateOrganizationBillingButton'
const OrganizationBillingMigrationPanel = observer(() => {
return (
<div className="space-y-2">
<div className="flex gap-4 items-center">
<h4>Organization-based billing</h4>
<Badge color="green">
<span className="flex gap-2 items-center text-xs uppercase">
<IconTruck width={12} /> New
</span>
</Badge>
</div>
<p className="text-sm opacity-50">Migrate this project to organization-based billing</p>
<Panel>
<Panel.Content>
<div className="flex justify-between gap-12">
<div className="space-y-2">
<p className="text-sm text-foreground-light">
You will have a single subscription for all projects inside the organization, rather
than individual subscriptions per project.
</p>
</div>
<div className="flex items-center gap-4 ml-12">
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/org-based-billing"
target="_blank"
rel="noreferrer"
>
Documentation
</Link>
</Button>
<MigrateOrganizationBillingButton />
</div>
</div>
</Panel.Content>
</Panel>
</div>
)
})
export default OrganizationBillingMigrationPanel
@@ -2,8 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { InvoiceStatusBadge } from 'components/interfaces/BillingV2'
import { InvoiceStatus } from 'components/interfaces/BillingV2/Invoices.types'
import { InvoiceStatus } from 'components/interfaces/Billing/Invoices.types'
import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
@@ -14,6 +13,7 @@ import { useInvoicesCountQuery } from 'data/invoices/invoices-count-query'
import { useInvoicesQuery } from 'data/invoices/invoices-query'
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
import { Button, IconChevronLeft, IconChevronRight, IconDownload, IconFileText } from 'ui'
import InvoiceStatusBadge from 'components/interfaces/Billing/InvoiceStatusBadge'
const PAGE_LIMIT = 10
@@ -16,7 +16,7 @@ import {
} from 'ui'
import { useParams } from 'common'
import { SpendCapModal } from 'components/interfaces/BillingV2'
import SpendCapModal from 'components/interfaces/Billing/SpendCapModal'
import InformationBox from 'components/ui/InformationBox'
import Panel from 'components/ui/Panel'
import { useOrganizationCreateMutation } from 'data/organizations/organization-create-mutation'
@@ -1,10 +1,10 @@
import { useState } from 'react'
import { AddNewPaymentMethodModal } from 'components/interfaces/BillingV2'
import InformationBox from 'components/ui/InformationBox'
import { useSelectedOrganization } from 'hooks'
import { getURL } from 'lib/helpers'
import { Button, IconAlertCircle } from 'ui'
import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal'
const EmptyPaymentMethodWarning = ({
onPaymentMethodAdded,
@@ -1,4 +1,4 @@
import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/BillingV2/Billing.constants'
import { USAGE_APPROACHING_THRESHOLD } from 'components/interfaces/Billing/Billing.constants'
import { EgressType, PricingMetric } from 'data/analytics/org-daily-stats-query'
import { OrgSubscription } from 'data/subscriptions/org-subscription-query'
import { OrgUsageResponse } from 'data/usage/org-usage-query'
@@ -1,8 +1,5 @@
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'
import { OrgSubscription } from 'data/subscriptions/org-subscription-query'
// [Joshen] This is just for development to generate some test data for chart rendering
export const generateUsageData = (attribute: string, days: number): DataPoint[] => {
@@ -16,25 +13,7 @@ export const generateUsageData = (attribute: string, days: number): DataPoint[]
})
}
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 = (slug: string, subscription?: ProjectSubscriptionResponse) => {
export const getUpgradeUrl = (slug: string, subscription?: OrgSubscription) => {
if (!subscription) {
return `/org/${slug}/billing`
}
@@ -21,7 +21,6 @@ import {
} from '../Usage.utils'
import UsageBarChart from '../UsageBarChart'
import { ChartMeta } from './UsageSection'
import { useIsFeatureEnabled } from 'hooks'
export interface AttributeUsageProps {
slug: string
@@ -1,5 +1,4 @@
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'
@@ -12,7 +11,6 @@ import Documents from './Documents/Documents'
export {
BillingSettings,
BillingSettingsV2,
GeneralSettings,
IntegrationSettings,
InvoicesSettings,
@@ -3,12 +3,13 @@ import { useParams } from 'common'
import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation'
import { SqlSnippet } from 'data/content/sql-snippets-query'
import { isError } from 'data/utils/error-check'
import { useFlag, useStore } from 'hooks'
import { useFlag, useSelectedOrganization, useStore } from 'hooks'
import { useEffect, useState } from 'react'
import { useSqlEditorStateSnapshot } from 'state/sql-editor'
import { AiIconAnimation, Button, Form, Input, Modal } from 'ui'
import { subscriptionHasHipaaAddon } from '../BillingV2/Subscription/Subscription.utils'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
export interface RenameQueryModalProps {
snippet: SqlSnippet
visible: boolean
@@ -19,9 +20,10 @@ export interface RenameQueryModalProps {
const RenameQueryModal = ({ snippet, visible, onCancel, onComplete }: RenameQueryModalProps) => {
const { ui } = useStore()
const { ref } = useParams()
const organization = useSelectedOrganization()
const snap = useSqlEditorStateSnapshot()
const supabaseAIEnabled = useFlag('sqlEditorSupabaseAI')
const { data: subscription } = useProjectSubscriptionV2Query({ projectRef: ref })
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
// Customers on HIPAA plans should not have access to Supabase AI
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription)
@@ -31,7 +31,6 @@ import { SqlSnippet } from 'data/content/sql-snippets-query'
import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query'
import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { useFormatQueryMutation } from 'data/sql/format-sql-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { isError } from 'data/utils/error-check'
import {
useFlag,
@@ -46,7 +45,7 @@ import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import Telemetry from 'lib/telemetry'
import { getSqlEditorStateSnapshot, useSqlEditorStateSnapshot } from 'state/sql-editor'
import { subscriptionHasHipaaAddon } from '../BillingV2/Subscription/Subscription.utils'
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
import AISchemaSuggestionPopover from './AISchemaSuggestionPopover'
import AISettingsModal from './AISettingsModal'
import { sqlAiDisclaimerComment, untitledSnippetTitle } from './SQLEditor.constants'
@@ -64,6 +63,7 @@ import {
getDiffTypeDropdownLabel,
} from './SQLEditor.utils'
import UtilityPanel from './UtilityPanel/UtilityPanel'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
// Load the monaco editor client-side only (does not behave well server-side)
const MonacoEditor = dynamic(() => import('./MonacoEditor'), { ssr: false })
@@ -98,6 +98,7 @@ const SQLEditor = () => {
const { profile } = useProfile()
const project = useSelectedProject()
const organization = useSelectedOrganization()
const snap = useSqlEditorStateSnapshot()
const { mutate: formatQuery } = useFormatQueryMutation()
@@ -113,7 +114,7 @@ const SQLEditor = () => {
const inputRef = useRef<HTMLInputElement>(null)
const supabaseAIEnabled = useFlag('sqlEditorSupabaseAI')
const { data: subscription } = useProjectSubscriptionV2Query({ projectRef: project?.ref })
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
// Customers on HIPAA plans should not have access to Supabase AI
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription)
@@ -1,7 +1,6 @@
import { subscriptionHasHipaaAddon } from 'components/interfaces/BillingV2/Subscription/Subscription.utils'
import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation'
import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { isError } from 'data/utils/error-check'
import {
useFlag,
@@ -17,6 +16,7 @@ import { AiIconAnimation, Button } from 'ui'
import { useSqlEditor } from '../SQLEditor'
import { sqlAiDisclaimerComment } from '../SQLEditor.constants'
import Results from './Results'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
export type UtilityTabResultsProps = {
id: string
@@ -25,6 +25,7 @@ export type UtilityTabResultsProps = {
const UtilityTabResults = ({ id, isExecuting }: UtilityTabResultsProps) => {
const { ui } = useStore()
const organization = useSelectedOrganization()
const snap = useSqlEditorStateSnapshot()
const { mutateAsync: debugSql, isLoading: isDebugSqlLoading } = useSqlDebugMutation()
const { setDebugSolution, setAiInput, setSqlDiff, sqlDiff } = useSqlEditor()
@@ -50,7 +51,7 @@ const UtilityTabResults = ({ id, isExecuting }: UtilityTabResultsProps) => {
const isUtilityPanelCollapsed = (snippet?.splitSizes?.[1] ?? 0) === 0
const supabaseAIEnabled = useFlag('sqlEditorSupabaseAI')
const { data: subscription } = useProjectSubscriptionV2Query({ projectRef: selectedProject?.ref })
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
// Customers on HIPAA plans should not have access to Supabase AI
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription)
@@ -4,7 +4,7 @@ import { useMemo } from 'react'
import { useParams } from 'common'
import { useTheme } from 'next-themes'
import { getAddons } from 'components/interfaces/BillingV2/Subscription/Subscription.utils'
import { getAddons } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import ProjectUpdateDisabledTooltip from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip'
import {
useIsProjectActive,
@@ -36,8 +36,10 @@ import {
IconChevronRight,
IconExternalLink,
} from 'ui'
import { ComputeInstanceSidePanel, CustomDomainSidePanel, PITRSidePanel } from './'
import Image from 'next/image'
import ComputeInstanceSidePanel from './ComputeInstanceSidePanel'
import PITRSidePanel from './PITRSidePanel'
import CustomDomainSidePanel from './CustomDomainSidePanel'
const Addons = () => {
const { resolvedTheme } = useTheme()
@@ -12,7 +12,6 @@ import { setProjectStatus } from 'data/projects/projects-query'
import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation'
import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils'
import { BASE_PATH, PROJECT_STATUS } from 'lib/constants'
@@ -32,7 +31,7 @@ import {
} from 'ui'
import * as Tooltip from '@radix-ui/react-tooltip'
import Image from 'next/image'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
const COMPUTE_CATEGORY_OPTIONS: {
id: 'micro' | 'optimized'
@@ -62,7 +61,6 @@ const ComputeInstanceSidePanel = () => {
const { resolvedTheme } = useTheme()
const { project: selectedProject } = useProjectContext()
const organization = useSelectedOrganization()
const isOrgBilling = !!organization?.subscription_id
const [showConfirmationModal, setShowConfirmationModal] = useState(false)
const [selectedCategory, setSelectedCategory] = useState<'micro' | 'optimized'>('micro')
@@ -78,7 +76,7 @@ const ComputeInstanceSidePanel = () => {
const onClose = () => snap.setPanelKey(undefined)
const { data: addons, isLoading } = useProjectAddonsQuery({ projectRef })
const { data: subscription } = useProjectSubscriptionV2Query({ projectRef })
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const { mutate: updateAddon, isLoading: isUpdating } = useProjectAddonUpdateMutation({
onSuccess: () => {
ui.setNotification({
@@ -276,17 +274,11 @@ const ComputeInstanceSidePanel = () => {
variant="info"
title="Changing your compute size is only available on the Pro plan"
actions={
isOrgBilling ? (
<Button asChild type="default">
<Link href={`/org/${organization.slug}/billing?panel=subscriptionPlan`}>
View available plans
</Link>
</Button>
) : (
<Button type="default" onClick={() => snap.setPanelKey('subscriptionPlan')}>
<Button asChild type="default">
<Link href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}>
View available plans
</Button>
)
</Link>
</Button>
}
>
Upgrade your plan to change the compute size of your project
@@ -379,12 +371,7 @@ const ComputeInstanceSidePanel = () => {
)}
{hasChanges &&
(selectedCategory === 'micro' && !isOrgBilling ? (
<p className="text-sm text-foreground-light">
Upon clicking confirm, the amount of that's unused during the current billing
cycle will be returned as credits that can be used for subsequent billing cycles
</p>
) : selectedCategory !== 'micro' && selectedCompute?.price_interval === 'monthly' ? (
(selectedCategory !== 'micro' && selectedCompute?.price_interval === 'monthly' ? (
// Monthly payment with project-level subscription
<p className="text-sm text-foreground-light">
Upon clicking confirm, the amount of{' '}
@@ -8,7 +8,6 @@ import { useParams } from 'common'
import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation'
import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
import Telemetry from 'lib/telemetry'
import { useSubscriptionPageStateSnapshot } from 'state/subscription-page'
@@ -22,13 +21,13 @@ import {
Radio,
SidePanel,
} from 'ui'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
const CustomDomainSidePanel = () => {
const { ui } = useStore()
const router = useRouter()
const { ref: projectRef } = useParams()
const organization = useSelectedOrganization()
const isOrgBilling = !!organization?.subscription_id
const [selectedOption, setSelectedOption] = useState<string>('cd_none')
@@ -42,7 +41,7 @@ const CustomDomainSidePanel = () => {
const onClose = () => snap.setPanelKey(undefined)
const { data: addons, isLoading } = useProjectAddonsQuery({ projectRef })
const { data: subscription } = useProjectSubscriptionV2Query({ projectRef })
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const { mutate: updateAddon, isLoading: isUpdating } = useProjectAddonUpdateMutation({
onSuccess: () => {
ui.setNotification({
@@ -273,17 +272,11 @@ const CustomDomainSidePanel = () => {
variant="info"
title="Custom domains are unavailable on the free plan"
actions={
isOrgBilling ? (
<Button asChild type="default">
<Link href={`/org/${organization.slug}/billing?panel=subscriptionPlan`}>
View available plans
</Link>
</Button>
) : (
<Button type="default" onClick={() => snap.setPanelKey('subscriptionPlan')}>
<Button asChild type="default">
<Link href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}>
View available plans
</Button>
)
</Link>
</Button>
}
>
Upgrade your plan to add a custom domain to your project
@@ -9,13 +9,13 @@ import { useTheme } from 'next-themes'
import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation'
import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
import { BASE_PATH } from 'lib/constants'
import Telemetry from 'lib/telemetry'
import { useSubscriptionPageStateSnapshot } from 'state/subscription-page'
import { Alert, Button, IconExternalLink, Radio, SidePanel } from 'ui'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
const PITR_CATEGORY_OPTIONS: {
id: 'off' | 'on'
@@ -43,7 +43,6 @@ const PITRSidePanel = () => {
const { ref: projectRef } = useParams()
const { resolvedTheme } = useTheme()
const organization = useSelectedOrganization()
const isOrgBilling = !!organization?.subscription_id
const [selectedCategory, setSelectedCategory] = useState<'on' | 'off'>('off')
const [selectedOption, setSelectedOption] = useState<string>('pitr_0')
@@ -55,7 +54,7 @@ const PITRSidePanel = () => {
const onClose = () => snap.setPanelKey(undefined)
const { data: addons, isLoading } = useProjectAddonsQuery({ projectRef })
const { data: subscription } = useProjectSubscriptionV2Query({ projectRef })
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const { mutate: updateAddon, isLoading: isUpdating } = useProjectAddonUpdateMutation({
onSuccess: () => {
ui.setNotification({
@@ -235,17 +234,11 @@ const PITRSidePanel = () => {
className="mb-4"
title="Changing your Point-In-Time-Recovery is only available on the Pro plan"
actions={
isOrgBilling ? (
<Button asChild type="default">
<Link href={`/org/${organization.slug}/billing?panel=subscriptionPlan`}>
View available plans
</Link>
</Button>
) : (
<Button type="default" onClick={() => snap.setPanelKey('subscriptionPlan')}>
<Button asChild type="default">
<Link href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}>
View available plans
</Button>
)
</Link>
</Button>
}
>
Upgrade your plan to change PITR for your project
@@ -1,8 +0,0 @@
import ComputeInstanceSidePanel from './ComputeInstanceSidePanel'
import CustomDomainSidePanel from './CustomDomainSidePanel'
import PITRSidePanel from './PITRSidePanel'
import Addons from './Addons'
export { ComputeInstanceSidePanel, CustomDomainSidePanel, PITRSidePanel }
export default Addons
@@ -16,26 +16,26 @@ import {
import Panel from 'components/ui/Panel'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useProjectSettingsQuery } from 'data/config/project-settings-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { useSelectedOrganization } from 'hooks'
import { pluckObjectFields } from 'lib/helpers'
import Telemetry from 'lib/telemetry'
import ConfirmDisableReadOnlyModeModal from './ConfirmDisableReadOnlyModal'
import ResetDbPassword from './ResetDbPassword'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
const DatabaseSettings = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
const telemetryProps = useTelemetryProps()
const organization = useSelectedOrganization()
const selectedOrganization = useSelectedOrganization()
const [showConfirmationModal, setShowConfirmationModal] = useState(false)
const { data, isLoading, isError } = useProjectSettingsQuery({ projectRef })
const { data: subscription } = useProjectSubscriptionV2Query({ projectRef })
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const { data: resourceWarnings } = useResourceWarningsQuery()
const isOrgBilling = !!selectedOrganization?.subscription_id
const isReadOnlyMode =
(resourceWarnings ?? [])?.find((warning) => warning.project === projectRef)
?.is_readonly_mode_enabled ?? false
@@ -179,11 +179,7 @@ const DatabaseSettings = () => {
{subscription?.plan.id === 'free' ? (
<li>
<Link
href={
isOrgBilling
? `/org/${selectedOrganization.slug}/billing?panel=subscriptionPlan`
: `/project/${projectRef}/settings/billing/subscription?panel=subscriptionPlan`
}
href={`/org/${selectedOrganization?.slug}/billing?panel=subscriptionPlan`}
>
<a className="text underline">Upgrade to the Pro plan</a>
</Link>{' '}
@@ -192,11 +188,7 @@ const DatabaseSettings = () => {
) : subscription?.plan.id === 'pro' && subscription?.usage_billing_enabled ? (
<li>
<Link
href={
isOrgBilling
? `/org/${selectedOrganization.slug}/billing?panel=subscriptionPlan`
: `/project/${projectRef}/settings/billing/subscription?panel=subscriptionPlan`
}
href={`/org/${selectedOrganization?.slug}/billing?panel=subscriptionPlan`}
>
<a className="text-foreground underline">Disable your Spend Cap</a>
</Link>{' '}
@@ -11,9 +11,9 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import { FormHeader } from 'components/ui/Forms'
import Panel from 'components/ui/Panel'
import { useProjectDiskResizeMutation } from 'data/config/project-disk-resize-mutation'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useProjectUsageQuery } from 'data/usage/project-usage-query'
import { useCheckPermissions, useStore } from 'hooks'
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
export interface DiskSizeConfigurationProps {
disabled?: boolean
@@ -22,6 +22,7 @@ export interface DiskSizeConfigurationProps {
const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps) => {
const { ui } = useStore()
const { project } = useProjectContext()
const organization = useSelectedOrganization()
const { ref: projectRef } = useParams()
const { lastDatabaseResizeAt } = project ?? {}
@@ -43,7 +44,7 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps)
})
const { data: projectUsage } = useProjectUsageQuery({ projectRef })
const { data: projectSubscriptionData } = useProjectSubscriptionV2Query({ projectRef })
const { data: projectSubscriptionData } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const { mutate: updateProjectUsage, isLoading: isUpdatingDiskSize } =
useProjectDiskResizeMutation({
onSuccess: (res, variables) => {
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more