mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
chore: remove old billing code (#18610)
This commit is contained in:
1 parent
6416113857
commit
11914a55de
158 files changed
+894
-8600
No files matched your search
@@ -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,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',
|
||||
},
|
||||
},
|
||||
})
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-4
@@ -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')
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
-310
@@ -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
|
||||
-226
@@ -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,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 }
|
||||
+166
-163
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -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'
|
||||
File renamed without changes.
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File renamed without changes.
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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'
|
||||
File renamed without changes.
@@ -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()
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -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 {
|
||||
File renamed without changes.
+1
-1
@@ -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
|
||||
+1
-1
@@ -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">
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
File renamed without changes.
-248
@@ -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' },
|
||||
]
|
||||
-211
@@ -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
|
||||
-65
@@ -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,
|
||||
|
||||
-508
@@ -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
|
||||
-48
@@ -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
|
||||
+5
-13
@@ -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
Reference in new issue
Block a user