feat: revamped invoice breakdown (#34861)

- Read Replica and Branching Compute is now explicitly listed (previously Read Replica compute was not distinguishable)
- All usage items have a link to their respective docs
- Branching Compute is aggregated into a single line item to avoid listing dozens of branches (they are listed in the tooltip still)
- Displays basic discounts with a strike-through price
- For Replica+Compute, it specifically lists the projects as sub line items, whereas other line items are not broken down, it's shown on the tooltip
- Compute credits are a sub line item under compute, rather than having it separate 
- Pricing is displayed on the tooltip
- Breakdown focuses on costs, rather than mixing cost, usage and line item pricing descriptions
- Avoid confusing users in the breakdown with hourly usage (for add-ons)
- Fixed number formatting to properly work with negative number and 0 (use 2 decimals)
- Upcoming Invoice Section is hidden for Free Plan users
This commit is contained in:
Kevin Grüneberg authored and GitHub committed 2025-04-11 12:40:59 +08:00
1 parent 9f6faa65bb
commit 3ce7e400a1
10 files changed
+437 -353

No files matched your search

@@ -129,7 +129,7 @@ The Fair Use Policy is generally applied to all projects of the restricted organ
To remove restrictions, you will need to address the issue that caused the restriction. This could be reducing your usage, paying overdue invoices, updating your payment method, or any other issue that caused the restriction. Once the issue is resolved, the restriction will be lifted.
Restrictions due to usage limits are lifted with the next billing cycle as your quota refills at the beginning of each cycle. You can see when your current billing cycle ends on the [billing page](https://supabase.com/dashboard/org/_/usage) under "Billing Breakdown". If your organization is on the Free Plan, you can also lift restrictions immediately by [upgrading](https://supabase.com/dashboard/org/_/billing?panel=subscriptionPlan) to Pro.
Restrictions due to usage limits are lifted with the next billing cycle as your quota refills at the beginning of each cycle. You can see when your current billing cycle ends on the [billing page](https://supabase.com/dashboard/org/_/billing) under "Upcoming Invoice". If your organization is on the Free Plan, you can also lift restrictions immediately by [upgrading](https://supabase.com/dashboard/org/_/billing?panel=subscriptionPlan) to Pro.
## Reports and invoices
@@ -65,4 +65,4 @@ You can configure the Spend Cap when creating an organization on the Pro Plan or
## Keep track of your usage and costs
You can monitor your usage on the [organization's usage page](https://supabase.com/dashboard/org/_/usage). The Billing Breakdown section of the [organization's billing page](https://supabase.com/dashboard/org/_/billing) shows your current spending and provides an estimate of your total costs for the billing cycle based on your usage.
You can monitor your usage on the [organization's usage page](https://supabase.com/dashboard/org/_/usage). The Upcoming Invoice section of the [organization's billing page](https://supabase.com/dashboard/org/_/billing) shows your current spending and provides an estimate of your total costs for the billing cycle based on your usage.
@@ -15,6 +15,7 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import UpcomingInvoice from './UpcomingInvoice'
import SparkBar from 'components/ui/SparkBar'
const BillingBreakdown = () => {
const { slug: orgSlug } = useParams()
@@ -36,23 +37,45 @@ const BillingBreakdown = () => {
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')
return (
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="sticky space-y-2 top-12 pr-3">
<p className="text-foreground text-base m-0">Billing Breakdown</p>
<p className="text-sm text-foreground-light m-0">
Current billing cycle: {billingCycleStart.format('MMM DD')} -{' '}
{billingCycleEnd.format('MMM DD')}
<p className="text-foreground text-base m-0">Upcoming Invoice</p>
<div className="py-2">
<SparkBar
type="horizontal"
value={daysWithinCycle - daysToCycleEnd}
max={daysWithinCycle}
barClass="bg-foreground"
labelBottom={`${billingCycleStart.format('MMMM DD')} - ${billingCycleEnd.format('MMMM DD')}`}
bgClass="bg-surface-300"
labelBottomClass="!text-foreground-light p-1 m-0"
labelTop={
subscription
? `${daysToCycleEnd} ${daysToCycleEnd === 1 ? 'day' : 'days'} left`
: ''
}
labelTopClass="p-1 m-0"
/>
</div>
<p className="prose text-sm">
Your upcoming invoice (excluding credits) will continue to update until the end of your
billing cycle on {billingCycleEnd.format('MMMM DD')}. For a more detailed breakdown,
visit the <Link href={`/org/${orgSlug}/usage`}>usage page.</Link>
</p>
<p className="text-sm text-foreground-light m-0">
It may take up to an hour for addon changes or new projects to show up.
<br />
<p className="text-sm text-foreground-light mt-4">
Add-on changes or new projects may take up to an hour to appear.
</p>
</div>
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
{!canReadSubscriptions ? (
<NoPermission resourceText="view this organization's billing breakdown" />
<NoPermission resourceText="view this organization's upcoming invoice" />
) : (
<>
{isLoadingSubscription && (
@@ -67,19 +90,7 @@ const BillingBreakdown = () => {
<AlertError subject="Failed to retrieve subscription" error={subscriptionError} />
)}
{invoiceFeatureEnabled && (
<>
<p className="prose text-sm">
The table shows your upcoming invoice, excluding credits. This invoice will
continue updating until the end of your billing period on{' '}
{billingCycleEnd.format('MMMM DD')}. See{' '}
<Link href={`/org/${orgSlug}/usage`}>usage page</Link> for a more detailed usage
breakdown.
</p>
<UpcomingInvoice slug={orgSlug} />
</>
)}
{invoiceFeatureEnabled && <UpcomingInvoice slug={orgSlug} />}
</>
)}
</ScaffoldSectionContent>
@@ -1,31 +1,58 @@
import { ChevronRight, Info } from 'lucide-react'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import AlertError from 'components/ui/AlertError'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useOrgUpcomingInvoiceQuery } from 'data/invoices/org-invoice-upcoming-query'
import {
UpcomingInvoiceResponse,
useOrgUpcomingInvoiceQuery,
} from 'data/invoices/org-invoice-upcoming-query'
import { formatCurrency } from 'lib/helpers'
import { Button, cn, Collapsible, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { Table, TableBody, TableCell, TableFooter, TableRow } from 'ui'
import { billingMetricUnit, formatUsage } from '../helpers'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { PricingMetric } from 'data/analytics/org-daily-stats-query'
import _ from 'lodash'
import React from 'react'
export interface UpcomingInvoiceProps {
slug?: string
}
interface TooltipData {
identifier: string
text: string
linkRef?: string
}
const usageBillingDocsLink: { [K in PricingMetric]?: string } = {
[PricingMetric.MONTHLY_ACTIVE_USERS]:
'/docs/guides/platform/manage-your-usage/monthly-active-users',
[PricingMetric.MONTHLY_ACTIVE_SSO_USERS]:
'/docs/guides/platform/manage-your-usage/monthly-active-users-sso',
[PricingMetric.MONTHLY_ACTIVE_THIRD_PARTY_USERS]:
'/docs/guides/platform/manage-your-usage/monthly-active-users-third-party',
[PricingMetric.AUTH_MFA_PHONE]: '/docs/guides/platform/manage-your-usage/advanced-mfa-phone',
const feeTooltipData: TooltipData[] = [
{
identifier: 'COMPUTE',
text: 'Every project is a dedicated server and database. For every hour your project is active, it incurs compute costs based on the compute size of your project. Paused projects do not incur compute costs.',
linkRef: 'https://supabase.com/docs/guides/platform/manage-your-usage/compute',
},
]
[PricingMetric.EGRESS]: '/docs/guides/platform/manage-your-usage/egress',
[PricingMetric.FUNCTION_INVOCATIONS]:
'/docs/guides/platform/manage-your-usage/edge-function-invocations',
[PricingMetric.STORAGE_SIZE]: '/docs/guides/platform/manage-your-usage/storage-size',
[PricingMetric.STORAGE_IMAGES_TRANSFORMED]:
'/docs/guides/platform/manage-your-usage/storage-image-transformations',
[PricingMetric.REALTIME_MESSAGE_COUNT]:
'/docs/guides/platform/manage-your-usage/realtime-messages',
[PricingMetric.REALTIME_PEAK_CONNECTIONS]:
'/docs/guides/platform/manage-your-usage/realtime-peak-connections',
[PricingMetric.CUSTOM_DOMAIN]: '/docs/guides/platform/manage-your-usage/custom-domains',
[PricingMetric.IPV4]: '/docs/guides/platform/manage-your-usage/ipv4',
[PricingMetric.PITR_7]: '/docs/guides/platform/manage-your-usage/point-in-time-recovery',
[PricingMetric.PITR_14]: '/docs/guides/platform/manage-your-usage/point-in-time-recovery',
[PricingMetric.PITR_28]: '/docs/guides/platform/manage-your-usage/point-in-time-recovery',
[PricingMetric.DISK_SIZE_GB_HOURS_GP3]: '/docs/guides/platform/manage-your-usage/disk-size',
[PricingMetric.DISK_SIZE_GB_HOURS_IO2]: '/docs/guides/platform/manage-your-usage/disk-size',
[PricingMetric.DISK_IOPS_GP3]: '/docs/guides/platform/manage-your-usage/disk-iops',
[PricingMetric.DISK_IOPS_IO2]: '/docs/guides/platform/manage-your-usage/disk-iops',
[PricingMetric.DISK_THROUGHPUT_GP3]: '/docs/guides/platform/manage-your-usage/disk-throughput',
[PricingMetric.LOG_DRAIN]: '/docs/guides/platform/manage-your-usage/log-drains',
}
const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
const {
@@ -36,48 +63,33 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
isSuccess,
} = useOrgUpcomingInvoiceQuery({ orgSlug: slug })
const [usageFeesExpanded, setUsageFeesExpanded] = useState<string[]>([])
const computeItems =
upcomingInvoice?.lines?.filter(
(item) =>
item.description?.toLowerCase().includes('compute') &&
item.breakdown &&
item.breakdown?.length > 0
) || []
const computeCredits = useMemo(() => {
return (upcomingInvoice?.lines || []).find((item) =>
item.description.startsWith('Compute Credits')
)
}, [upcomingInvoice])
const computeCreditsItem =
upcomingInvoice?.lines?.find((item) => item.description.startsWith('Compute Credits')) ?? null
const fixedFees = useMemo(() => {
return (upcomingInvoice?.lines || [])
.filter((item) => item !== computeCredits && (!item.breakdown || !item.breakdown.length))
.sort((a, b) => {
// Prorations should be below regular usage fees
return Number(a.proration) - Number(b.proration)
})
}, [upcomingInvoice, computeCredits])
const planItem = upcomingInvoice?.lines?.find((item) =>
item.description?.toLowerCase().includes('plan')
)
const feesWithBreakdown = useMemo(() => {
return (upcomingInvoice?.lines || [])
.filter((item) => item.breakdown?.length)
.sort((a, b) => Number(a.usage_based) - Number(b.usage_based) || b.amount - a.amount)
}, [upcomingInvoice])
const regularComputeItems = computeItems.filter(
(it) => !it.metadata?.is_branch && !it.metadata?.is_read_replica
)
const branchingComputeItems = computeItems.filter((it) => it.metadata?.is_branch)
const replicaComputeItems = computeItems.filter((it) => it.metadata?.is_read_replica)
const expandUsageFee = (fee: string) => {
setUsageFeesExpanded([...usageFeesExpanded, fee])
}
const collapseUsageFee = (fee: string) => {
setUsageFeesExpanded(usageFeesExpanded.filter((item) => item !== fee))
}
const allFeesExpanded = useMemo(() => {
return feesWithBreakdown.length === usageFeesExpanded.length
}, [feesWithBreakdown, usageFeesExpanded])
const toggleAllFees = () => {
if (allFeesExpanded) {
setUsageFeesExpanded([])
} else {
setUsageFeesExpanded(feesWithBreakdown.map((item) => item.description))
}
}
const otherItems =
upcomingInvoice?.lines?.filter(
(item) =>
!item.description?.toLowerCase().includes('compute') &&
!item.description?.toLowerCase().includes('plan')
) || []
return (
<>
@@ -93,218 +105,306 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
{isSuccess && (
<div>
{feesWithBreakdown.length > 0 && (
<div className="mb-2">
<Button size="tiny" type="default" onClick={toggleAllFees}>
{allFeesExpanded ? 'Collapse All' : 'Expand All'}
</Button>
</div>
)}
<div>
<Table className="w-full text-sm">
<TableBody>
<TableRow>
<TableCell className="!py-2 px-0">{planItem?.description}</TableCell>
<TableCell className="text-right py-2 px-0">
{planItem == null ? (
'-'
) : (
<InvoiceLineItemAmount
amount={planItem.amount}
amountBeforeDiscount={planItem.amount_before_discount}
/>
)}
</TableCell>
</TableRow>
<table className="w-full">
<thead>
<tr className="border-b">
<th className="py-2 font-medium text-left text-sm text-foreground-light max-w-[200px]">
Item
</th>
<th className="py-2 font-medium text-right text-sm text-foreground-light pr-4">
Usage
</th>
<th className="py-2 pr-2 font-medium text-left text-sm text-foreground-light max-w-[200px]">
Unit price
</th>
<th className="py-2 font-medium text-right text-sm text-foreground-light">Cost</th>
</tr>
</thead>
<tbody>
{fixedFees.map((item) => (
<tr key={item.description} className="border-b">
<td className="py-2 text-sm max-w-[200px]" colSpan={item.proration ? 3 : 1}>
{item.description ?? 'Unknown'}
{item.usage_metric &&
billingMetricUnit(item.usage_metric) &&
` (${billingMetricUnit(item.usage_metric)})`}
</td>
{!item.proration && (
<td className="py-2 text-sm text-right pr-4">
{item.quantity?.toLocaleString()}
</td>
)}
{!item.proration && (
<td className="py-2 pr-2 text-sm max-w-[200px]">
{item.unit_price === 0
? 'FREE'
: item.unit_price
? formatCurrency(item.unit_price)
: `${item.unit_price_desc}`}
</td>
)}
<td className="py-2 text-sm text-right">{formatCurrency(item.amount)}</td>
</tr>
))}
</tbody>
{feesWithBreakdown.length > 0 &&
feesWithBreakdown.map((fee) => (
<Collapsible
asChild
open={usageFeesExpanded.includes(fee.description)}
onOpenChange={(open) =>
open ? expandUsageFee(fee.description) : collapseUsageFee(fee.description)
}
key={fee.description}
>
<tbody>
<Collapsible.Trigger asChild>
<tr
className={usageFeesExpanded.includes(fee.description) ? '' : 'border-b'}
key={fee.description}
style={{ WebkitAppearance: 'initial' }}
{/* Compute section */}
<ComputeLineItem
computeItems={regularComputeItems}
title="Compute"
computeCredits={computeCreditsItem}
tooltip={
<p className="prose text-xs">
The first project is covered by Compute Credits. Additional projects incur
compute costs starting at $10/month, independent of activity. See{' '}
<Link
href={'/docs/guides/platform/manage-your-usage/compute'}
target="_blank"
>
<td className="py-2 text-sm max-w-[200px]">
<Button
type="text"
className="!px-1"
icon={
<ChevronRight
className={cn(
'transition',
usageFeesExpanded.includes(fee.description) && 'rotate-90'
)}
/>
}
/>{' '}
<span className="mr-2">
{fee.description}
{fee.usage_metric &&
billingMetricUnit(fee.usage_metric) &&
` (${billingMetricUnit(fee.usage_metric)})`}
</span>
{(() => {
const matchingTooltipData = feeTooltipData.find((it) =>
fee.usage_metric?.startsWith(it.identifier)
)
return matchingTooltipData ? (
<InvoiceTooltip
text={matchingTooltipData.text}
linkRef={matchingTooltipData.linkRef}
/>
) : null
})()}
</td>
<td className="py-2 pr-4 text-sm text-right tabular-nums max-w-[100px]">
{fee.usage_original
? `${formatUsage(fee.usage_metric!, { usage: fee.usage_original })}`
: fee.quantity
? fee.quantity
: null}
</td>
<td className="py-2 text-sm">
{fee.unit_price_desc
? `${fee.unit_price_desc}`
: fee.unit_price
? formatCurrency(fee.unit_price)
: null}
</td>
<td className="py-2 text-sm text-right max-w-[70px]">
{formatCurrency(fee.amount) ?? formatCurrency(0)}
</td>
</tr>
</Collapsible.Trigger>
docs
</Link>
.
</p>
}
/>
<Collapsible.Content asChild>
<>
{fee.breakdown?.map((breakdown) => (
<tr
className="last:border-b cursor-pointer"
style={{ WebkitAppearance: 'initial' }}
key={breakdown.project_ref}
>
<td className="pb-1 text-xs pl-8 max-w-[200px]">
{breakdown.project_name}
</td>
<td className="pb-1 text-xs tabular-nums text-right pr-4">
{formatUsage(fee.usage_metric!, breakdown)}
</td>
<td />
<td />
</tr>
))}
</>
</Collapsible.Content>
</tbody>
</Collapsible>
))}
{/* Read Replica compute */}
<ComputeLineItem
title="Replica Compute"
computeItems={replicaComputeItems}
tooltip={
<p className="prose text-xs">
Each Read Replica is a dedicated database. You are charged for its resources:
Compute, Disk Size, provisioned Disk IOPS, provisioned Disk Throughput, and
IPv4. See{' '}
<Link
href={'/docs/guides/platform/manage-your-usage/read-replicas'}
target="_blank"
>
docs
</Link>
.
</p>
}
/>
{computeCredits && (
<tbody>
<tr className="border-b">
<td className="py-2 text-sm max-w-[200px]">
<span className="mr-2">{computeCredits.description}</span>
<InvoiceTooltip
text="Paid plans come with $10 in Compute Credits to cover one Micro instance or parts of any other instance. Compute Credits are given to you every month and do not stack up while you are on a paid plan."
linkRef="https://supabase.com/docs/guides/platform/manage-your-usage/compute#compute-credits"
/>
</td>
<td className="py-2 text-sm text-right" colSpan={3}>
{formatCurrency(computeCredits.amount)}
</td>
</tr>
</tbody>
)}
{/* Branching compute */}
{branchingComputeItems.length > 0 && (
<TableRow>
<TableCell className="py-2 px-0">
<div className="flex items-center gap-1">
<span>Branching</span>
<InfoTooltip className="max-w-sm">
<ul className="ml-6 list-disc">
{branchingComputeItems
.flatMap((it) => it.breakdown)
.map((breakdown) => (
<li key={`branching-breakdown-${breakdown!.project_ref}`}>
{breakdown!.project_name} ({breakdown!.usage} Hours)
</li>
))}
</ul>
<tfoot>
<tr>
<td className="py-4 text-sm font-medium">
<span className="mr-2">Current Costs</span>
<InvoiceTooltip text="Costs accumulated from the beginning of the billing cycle up to now." />
</td>
<td className="py-4 text-sm text-right font-medium" colSpan={3}>
{formatCurrency(upcomingInvoice?.amount_total) ?? '-'}
</td>
</tr>
<tr>
<td className="text-sm font-medium">
<span className="mr-2">Projected Costs</span>
<InvoiceTooltip text="Projected costs at the end of the billing cycle. Includes predictable costs for Compute Hours, IPv4, Custom Domain and Point-In-Time-Recovery, but no costs for metrics like MAU, storage or function invocations. Final amounts may vary depending on your usage." />
</td>
<td className="text-sm text-right font-medium" colSpan={3}>
{formatCurrency(upcomingInvoice?.amount_projected) ?? '-'}
</td>
</tr>
</tfoot>
</table>
<p className="mt-2">
See{' '}
<Link
className="underline"
href={
'https://supabase.com/docs/guides/platform/manage-your-usage/branching'
}
target="_blank"
>
docs
</Link>{' '}
on how billing for Branching works.
</p>
</InfoTooltip>
</div>
</TableCell>
<TableCell className="text-right py-2 px-0">
<InvoiceLineItemAmount
amount={branchingComputeItems.reduce((prev, cur) => prev + cur.amount, 0)}
amountBeforeDiscount={branchingComputeItems.reduce(
(prev, cur) => prev + (cur.amount_before_discount ?? 0),
0
)}
/>
</TableCell>
</TableRow>
)}
{/* Non-compute items */}
{otherItems.map((item) => (
<TableRow key={item.description}>
<TableCell className="py-2 px-0">
<div className="gap-1 flex items-center">
<span>{item.description ?? 'Unknown'}</span>
{((item.breakdown && item.breakdown.length > 0) ||
item.usage_metric != null) && (
<InfoTooltip className="max-w-sm">
{item.unit_price_desc && (
<p className="mb-2">Pricing: {item.unit_price_desc}</p>
)}
{item.breakdown && item.breakdown.length > 0 && (
<>
<p>Projects using {item.description}:</p>
<ul className="ml-6 list-disc">
{item.breakdown.map((breakdown) => (
<li
key={`${item.description}-breakdown-${breakdown.project_ref}`}
>
<Link
className="underline"
href={`/project/${breakdown.project_ref}`}
target="_blank"
>
{breakdown.project_name}
</Link>{' '}
{item.usage_metric && (
<span>
({formatUsage(item.usage_metric, breakdown)}{' '}
{billingMetricUnit(item.usage_metric)})
</span>
)}
</li>
))}
</ul>
</>
)}
{item.usage_metric &&
usageBillingDocsLink[item.usage_metric] != null && (
<p className="mt-2">
See{' '}
<Link
className="underline"
href={usageBillingDocsLink[item.usage_metric]!}
target="_blank"
>
docs
</Link>{' '}
on how billing for {item.description} works and{' '}
<Link className="underline" href={`/organization/${slug}/usage`}>
usage page
</Link>{' '}
for a detailed breakdown.
</p>
)}
</InfoTooltip>
)}
</div>
</TableCell>
<TableCell className="text-right py-2 px-0">
<InvoiceLineItemAmount
amount={item.amount}
amountBeforeDiscount={item.amount_before_discount}
/>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-medium py-2 px-0 flex items-center">
<span className="mr-2">Current Costs</span>
<InfoTooltip>
Costs accumulated from the beginning of the billing cycle up to now.
</InfoTooltip>
</TableCell>
<TableCell className="text-right font-medium py-2 px-0">
{formatCurrency(upcomingInvoice?.amount_total) ?? '-'}
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium py-2 px-0 flex items-center">
<span className="mr-2">Projected Costs</span>
<InfoTooltip className="max-w-xs">
Projected costs at the end of the billing cycle. Includes predictable costs
for Compute Hours, IPv4, Custom Domain and Point-In-Time-Recovery, but no
costs for metrics like MAU, storage or function invocations. Final amounts may
vary depending on your usage.
</InfoTooltip>
</TableCell>
<TableCell className="text-right font-medium py-2 px-0">
{formatCurrency(upcomingInvoice?.amount_projected) ?? '-'}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
)}
</>
)
}
const InvoiceTooltip = ({ text, linkRef }: { text: string; linkRef?: string }) => {
function InvoiceLineItemAmount({
amountBeforeDiscount,
amount,
}: {
amountBeforeDiscount?: number
amount: number
}) {
if (amountBeforeDiscount && amount < amountBeforeDiscount) {
return (
<div>
<span className="text-foreground-light line-through mr-2">
{formatCurrency(amountBeforeDiscount)}
</span>
<span>{formatCurrency(amount)}</span>
</div>
)
} else {
return formatCurrency(amount)
}
}
function ComputeLineItem({
computeItems,
tooltip,
title,
computeCredits,
}: {
title: string
tooltip: React.ReactElement
computeItems: UpcomingInvoiceResponse['lines']
computeCredits?: UpcomingInvoiceResponse['lines'][number] | null
}) {
const replicaProjects = computeItems
.flatMap((item) =>
item.breakdown!.map((project) => ({
...project,
computeType: item.description,
computeCosts: project.amount!,
}))
)
// descending by cost
.sort((a, b) => b.computeCosts - a.computeCosts)
const replicaComputeCosts =
Math.max(
0,
computeItems.reduce((prev, cur) => prev + cur.amount, 0)
) + (computeCredits?.amount ?? 0)
const discountedReplicaComputeCosts = computeItems.reduce(
(prev, cur) => prev + (cur.amount_before_discount ?? 0),
0
)
if (!computeItems.length) return null
return (
<Tooltip>
<TooltipTrigger>
<Info size={12} strokeWidth={2} />
</TooltipTrigger>
<TooltipContent
side="bottom"
className="min-w-[300px] max-w-[450px] max-h-[300px] overflow-y-auto"
>
<p>
{text}{' '}
{linkRef && (
<Link
href={linkRef}
target="_blank"
rel="noopener noreferrer"
className="transition text-brand hover:text-brand-600 underline"
>
Read more
</Link>
)}
</p>
</TooltipContent>
</Tooltip>
<>
<TableRow>
<TableCell className="!py-2 px-0 flex items-center gap-1">
<span>{title}</span>
<InfoTooltip className="max-w-sm">{tooltip}</InfoTooltip>
</TableCell>
<TableCell className="text-right py-2 px-0">
<InvoiceLineItemAmount
amount={discountedReplicaComputeCosts}
amountBeforeDiscount={replicaComputeCosts}
/>
</TableCell>
</TableRow>
{replicaProjects.map((project) => (
<TableRow key={project.project_ref} className="text-foreground-light text-xs">
<TableCell className="!py-2 px-0 pl-6">
{project.project_name} ({project.computeType} - {project.usage} Hours)
</TableCell>
<TableCell className="!py-2 px-0 text-right">
{formatCurrency(project.computeCosts)}
</TableCell>
</TableRow>
))}
{computeCredits && (
<TableRow className="text-foreground-light text-xs">
<TableCell className="!py-2 px-0 pl-6">Compute Credits</TableCell>
<TableCell className="!py-2 px-0 text-right">
{formatCurrency(computeCredits.amount)}
</TableCell>
</TableRow>
)}
</>
)
}
@@ -20,7 +20,7 @@ const BillingSettings = () => {
const { slug: orgSlug } = useParams()
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug })
const isNotOrgWithPartnerBilling = !subscription?.billing_via_partner ?? true
const isNotOrgWithPartnerBilling = !subscription?.billing_via_partner
const billingAccountDataEnabled =
isBillingAccountDataEnabledOnProfileLevel && isNotOrgWithPartnerBilling
@@ -41,9 +41,11 @@ const BillingSettings = () => {
<ScaffoldDivider />
<ScaffoldContainer id="breakdown">
<BillingBreakdown />
</ScaffoldContainer>
{subscription?.plan.id !== 'free' && (
<ScaffoldContainer id="breakdown">
<BillingBreakdown />
</ScaffoldContainer>
)}
{isBillingCreditsEnabledOnProfileLevel && (
<>
@@ -57,7 +57,14 @@ const CostControl = ({}: CostControlProps) => {
<div className="space-y-2">
<p className="text-foreground text-base m-0">Cost Control</p>
<p className="text-sm text-foreground-light m-0">
Control whether to use beyond your plans included quota
Allow scaling beyond your plan's{' '}
<Link
href={`/org/${slug}/usage`}
className="text-green-900 transition hover:text-green-1000"
>
included quota
</Link>
.
</p>
</div>
<div className="space-y-2">
@@ -114,16 +121,8 @@ const CostControl = ({}: CostControlProps) => {
</Alert>
) : (
<p className="text-sm text-foreground-light">
You can control whether your organization is charged for additional usage
beyond the{' '}
<Link
href={`/org/${slug}/usage`}
className="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.
If you need to go beyond the included quota, simply switch off your spend cap
to pay for additional usage.
</p>
)}
@@ -152,7 +151,7 @@ const CostControl = ({}: CostControlProps) => {
</p>
<p className="text-sm text-foreground-light">
{isUsageBillingEnabled ? (
<span>You will be charged for any usage above the included quota.</span>
<span>You will be charged for usage beyond the included quota.</span>
) : (
<span>
You won't be charged any extra for usage. However, your projects could
@@ -161,12 +160,6 @@ const CostControl = ({}: CostControlProps) => {
</span>
)}
</p>
{isUsageBillingEnabled && (
<p className="text-sm text-foreground-light mt-1">
Your projects will never become unresponsive. Only when your usage reaches
the quota limit will you be charged for any excess usage.
</p>
)}
<ProjectUpdateDisabledTooltip projectUpdateDisabled={projectUpdateDisabled}>
<Button
type="default"
@@ -43,10 +43,6 @@ const Subscription = () => {
const currentPlan = subscription?.plan
const planName = currentPlan?.name ?? 'Unknown'
const billingCycleStart = dayjs.unix(subscription?.current_period_start ?? 0).utc()
const billingCycleEnd = dayjs.unix(subscription?.current_period_end ?? 0).utc()
const daysToCycleEnd = billingCycleEnd.diff(dayjs(), 'days')
const daysWithinCycle = billingCycleEnd.diff(billingCycleStart, 'days')
const canChangeTier = !projectUpdateDisabled && !['enterprise'].includes(currentPlan?.id ?? '')
@@ -58,17 +54,12 @@ const Subscription = () => {
</div>
<ScaffoldSectionDetail>
<div className="sticky space-y-6 top-12">
<p className="text-foreground text-base m-0">Subscription Plan</p>
<div className="space-y-2">
<p className="text-sm text-foreground-light m-0">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 m-0">Pricing</p>
<ExternalLink size={16} strokeWidth={1.5} />
</div>
</Link>
</div>
<div className="space-y-2 mb-4">
<p className="text-foreground text-base m-0">Subscription Plan</p>
<p className="text-sm text-foreground-light m-0">
Each organization has it's own subscription plan, billing cycle, payment methods and
usage quotas.
</p>
</div>
</div>
</ScaffoldSectionDetail>
@@ -90,10 +81,7 @@ const Subscription = () => {
{isSuccess && (
<div className="space-y-6">
<div>
<p className="text-sm">This organization is currently on the plan:</p>
<p className="text-2xl text-brand uppercase">
{currentPlan?.name ?? 'Unknown'}
</p>
<p className="text-2xl text-brand">{currentPlan?.name ?? 'Unknown'} Plan</p>
</div>
<div>
@@ -149,37 +137,13 @@ const Subscription = () => {
<div className="[&>p]:!leading-normal prose text-sm">
Projects may become unresponsive when this organization exceeds its{' '}
<Link href={`/org/${slug}/usage`}>included usage quota</Link>. To scale
seamlessly and pay for over-usage,{' '}
seamlessly,{' '}
{currentPlan?.id === 'free'
? 'upgrade to a paid plan.'
: 'you can disable Spend Cap under the Cost Control settings.'}
</div>
<Button
type="default"
className="mt-1"
onClick={() =>
snap.setPanelKey(
currentPlan?.id === 'free' ? 'subscriptionPlan' : 'costControl'
)
}
>
{currentPlan?.id === 'free' ? 'Upgrade Plan' : 'Adjust Spend Cap'}
</Button>
</Admonition>
)}
<SparkBar
type="horizontal"
value={daysWithinCycle - daysToCycleEnd}
max={daysWithinCycle}
barClass="bg-foreground"
labelBottom={`Current billing cycle (${billingCycleStart.format(
'MMM DD'
)} - ${billingCycleEnd.format('MMM DD')})`}
bgClass="bg-surface-300"
labelBottomClass="!text-foreground-light pb-1"
labelTop={`${daysToCycleEnd} days remaining`}
/>
</div>
)}
</>
@@ -28,6 +28,7 @@ import Bandwidth from './Bandwidth'
import Compute from './Compute'
import SizeAndCounts from './SizeAndCounts'
import TotalUsage from './TotalUsage'
import Link from 'next/link'
const Usage = () => {
const newLayoutPreview = useNewLayout()
@@ -199,15 +200,21 @@ const Usage = () => {
<Admonition
type="default"
title="Usage filtered by project"
description={`You are currently viewing usage for the "${selectedProject?.name || selectedProjectRef}" project. Supabase uses organization-level billing and quotas. For billing purposes, we sum up usage from
all your projects. To view your usage quota, set the project filter above back to
"All Projects".`}
>
<DocsButton
abbrev={false}
href="https://supabase.com/docs/guides/platform/billing-on-supabase#organization-based-billing"
/>
</Admonition>
description={
<div>
You are currently viewing usage for the "$
{selectedProject?.name || selectedProjectRef}" project. Supabase uses{' '}
<Link
href="/docs/guides/platform/billing-on-supabase#organization-based-billing"
target="_blank"
>
organization-level billing
</Link>{' '}
and quotas. For billing purposes, we sum up usage from all your projects. To view
your usage quota, set the project filter above back to "All Projects".
</div>
}
/>
</ScaffoldContainer>
) : (
<ScaffoldContainer id="restriction" className="mt-5">
@@ -18,6 +18,7 @@ export type UpcomingInvoiceResponse = {
billing_cycle_start: string
lines: {
amount: number
amount_before_discount: number
description: string
proration: boolean
period: { start: string; end: string }
@@ -27,11 +28,16 @@ export type UpcomingInvoiceResponse = {
usage_based: boolean
usage_metric?: PricingMetric
usage_original?: number
breakdown: {
breakdown?: {
project_ref: string
project_name: string
usage: number
amount?: number
}[]
metadata?: {
is_branch: boolean
is_read_replica: boolean
}
}[]
}
@@ -50,6 +56,7 @@ export async function getUpcomingInvoice(
})
if (error) handleError(error)
return data as unknown as UpcomingInvoiceResponse
}
+1 -1
View File
@@ -292,7 +292,7 @@ const currencyFormatterSmallValues = Intl.NumberFormat('en-US', {
export const formatCurrency = (amount: number | undefined | null): string | null => {
if (amount === undefined || amount === null) {
return null
} else if (amount < 0.01) {
} else if (amount > 0 && amount < 0.01) {
return currencyFormatterSmallValues.format(amount)
} else {
return currencyFormatterDefault.format(amount)