Files
supabase/apps/studio/components/interfaces/Organization/Usage/TotalUsage.tsx
Sean Oliver b14ecb5b1e fix: align grace-period banner copy on org usage page (#45652)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix — copy + visibility logic on the org Usage page.

## What is the current behavior?

On `/org/<slug>/usage` during a grace period, customers see two banners
that read as contradictory:

1. *"Organization plan has exceeded its quota — grace period until
{date}."*
2. *"You have not exceeded your Pro Plan quota in this billing cycle."*

<img width="1680" height="372" alt="image"
src="https://github.com/user-attachments/assets/13826260-55dd-4b55-a3dc-5afc51e6436e"
/>

Both are individually correct. The first is sticky from the previous
cycle's overage (`org.restriction_status`); the second is a live scan
over the current cycle. Neither anchors to which cycle it's talking
about, so together they read like the dashboard contradicting itself.
Surfaced by support off SU-368527 and SU-368395.

## What is the new behavior?

- Top chrome banner copy: *"Organization exceeded its quota in the
previous billing cycle / You have a grace period until {date} to bring
usage back under quota."*
- Inline `<Restriction />` grace-period alert switches from "is over its
quota" to "went over its quota in the previous billing cycle." Same
temporal anchor.
- The "…in this billing cycle" summary line in `<TotalUsage>` is hidden
whenever `restriction_status` is set. Mirrors the precedence rule
`<Restriction />` already applies internally — backend status flag wins
over the live cycle scan.

<img width="1678" height="937" alt="CleanShot 2026-05-06 at 12 58 02"
src="https://github.com/user-attachments/assets/df55eaed-1029-4f39-bea0-df77bcc5151e"
/>


## Additional context

Left the `gracePeriodOver` copy alone on purpose — it doesn't make a
current-overage claim, so there's nothing to contradict, and adding
"previous cycle" would muddy which cycle "previous" refers to.

**Verified**
- Lint and typecheck pass on `apps/studio`.

**Before merge**
- [ ] Load a grace-period org locally: confirm new copy on top banner
and inline `<Restriction />`, and that the "not exceeded in this billing
cycle" line is gone.
- [ ] Copy review with support — happy to workshop wording.

GROWTH-823

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Updates**
* Updated grace period alert messaging to clarify organization quota
status
  * Refined date formatting in billing restriction notifications
* Modified usage display to conditionally hide certain information when
account restrictions are active

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-05-07 15:46:22 -07:00

255 lines
9.6 KiB
TypeScript

import { useBreakpoint } from 'common'
import { useMemo } from 'react'
import { cn } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { BILLING_BREAKDOWN_METRICS } from '../BillingSettings/BillingBreakdown/BillingBreakdown.constants'
import { BillingMetric } from '../BillingSettings/BillingBreakdown/BillingMetric'
import { ComputeMetric } from '../BillingSettings/BillingBreakdown/ComputeMetric'
import { SectionContent } from './SectionContent'
import AlertError from '@/components/ui/AlertError'
import {
ComputeUsageMetric,
computeUsageMetricLabel,
PricingMetric,
} from '@/data/analytics/org-daily-stats-query'
import type { OrgSubscription } from '@/data/subscriptions/types'
import { useOrgUsageQuery } from '@/data/usage/org-usage-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { DOCS_URL } from '@/lib/constants'
export interface ComputeProps {
orgSlug: string
projectRef?: string | null
startDate: string | undefined
endDate: string | undefined
subscription: OrgSubscription | undefined
currentBillingCycleSelected: boolean
}
const METRICS_TO_HIDE_WITH_NO_USAGE: PricingMetric[] = [
PricingMetric.DISK_IOPS_IO2,
PricingMetric.DISK_IOPS_GP3,
PricingMetric.DISK_SIZE_GB_HOURS_GP3,
PricingMetric.DISK_SIZE_GB_HOURS_IO2,
PricingMetric.DISK_THROUGHPUT_GP3,
PricingMetric.LOG_INGESTION,
PricingMetric.LOG_STORAGE,
PricingMetric.LOG_QUERYING,
PricingMetric.ACTIVE_COMPUTE_HOURS,
]
export const TotalUsage = ({
orgSlug,
projectRef,
subscription,
startDate,
endDate,
currentBillingCycleSelected,
}: ComputeProps) => {
const isMobile = useBreakpoint('md')
const isUsageBillingEnabled = subscription?.usage_billing_enabled
const { billingAll } = useIsFeatureEnabled(['billing:all'])
const { data: org } = useSelectedOrganizationQuery()
const hasActiveRestriction = Boolean(org?.restriction_status)
const {
data: usage,
error: usageError,
isPending: isLoadingUsage,
isError: isErrorUsage,
isSuccess: isSuccessUsage,
} = useOrgUsageQuery({
orgSlug,
projectRef,
start: !currentBillingCycleSelected && startDate ? new Date(startDate) : undefined,
end: !currentBillingCycleSelected && endDate ? new Date(endDate) : undefined,
})
// When the user filters by project ref or selects a custom timeframe, we only display usage+project breakdown, but no costs/limits
const showRelationToSubscription = currentBillingCycleSelected && !projectRef
const isOnHigherPlan = ['team', 'enterprise', 'platform'].includes(subscription?.plan.id ?? '')
const hasExceededAnyLimits =
showRelationToSubscription &&
Boolean(
usage?.usages.find(
(usageItem) =>
// Filter out compute as compute has no quota and is always being charged for
!usageItem.metric.startsWith('COMPUTE_') &&
!usageItem.unlimited &&
usageItem.usage > (usageItem?.pricing_free_units ?? 0)
)
)
const sortedBillingMetrics = useMemo(() => {
if (!usage) return []
const breakdownMetrics = BILLING_BREAKDOWN_METRICS.filter((metric) =>
usage.usages.some((usage) => usage.metric === metric.key)
).filter((metric) => {
if (!METRICS_TO_HIDE_WITH_NO_USAGE.includes(metric.key as PricingMetric)) return true
const metricUsage = usage.usages.find((it) => it.metric === metric.key)
return metricUsage && metricUsage.usage > 0
})
return breakdownMetrics.slice().sort((a, b) => {
const usageMetaA = usage.usages.find((x) => x.metric === a.key)
const usageRatioA =
typeof usageMetaA !== 'number'
? (usageMetaA?.usage ?? 0) / (usageMetaA?.pricing_free_units ?? 0)
: 0
const usageMetaB = usage.usages.find((x) => x.metric === b.key)
const usageRatioB =
typeof usageMetaB !== 'number'
? (usageMetaB?.usage ?? 0) / (usageMetaB?.pricing_free_units ?? 0)
: 0
return (
// Sort unavailable features to bottom
Number(usageMetaB?.available_in_plan) - Number(usageMetaA?.available_in_plan) ||
// Sort high-usage features to top
usageRatioB - usageRatioA
)
})
}, [usage])
const computeMetrics = (usage?.usages || [])
.filter((it) => it.metric.startsWith('COMPUTE'))
.map((it) => it.metric) as ComputeUsageMetric[]
return (
<div id="summary">
<SectionContent
section={{
name: 'Usage Summary',
description: isUsageBillingEnabled
? `Your plan includes a limited amount of usage. If exceeded, you will be charged for the overages. It may take up to 1 hour to refresh.`
: `Your plan includes a limited amount of usage. If exceeded, you may experience restrictions, as you are currently not billed for overages. It may take up to 1 hour to refresh.`,
links: billingAll
? [
{
name: 'How billing works',
url: `${DOCS_URL}/guides/platform/billing-on-supabase`,
},
{
name: 'Supabase Plans',
url: 'https://supabase.com/pricing',
},
]
: [],
}}
>
{isLoadingUsage && (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{isErrorUsage && <AlertError subject="Failed to retrieve usage data" error={usageError} />}
{isSuccessUsage && subscription && (
<div>
{showRelationToSubscription && !isOnHigherPlan && !hasActiveRestriction && (
<p className="text-sm">
{!hasExceededAnyLimits ? (
<span>
You have not exceeded your{' '}
<span className="font-medium">{subscription?.plan.name}</span> Plan quota in
this billing cycle.
</span>
) : hasExceededAnyLimits && subscription?.plan?.id === 'free' ? (
<span>
You have exceeded your{' '}
<span className="font-medium">{subscription?.plan.name}</span> Plan quota in
this billing cycle. Upgrade your plan to continue using Supabase without
restrictions.
</span>
) : hasExceededAnyLimits &&
subscription?.usage_billing_enabled === false &&
subscription?.plan?.id === 'pro' ? (
<span>
You have exceeded your{' '}
<span className="font-medium">{subscription?.plan.name}</span> Plan quota in
this billing cycle. Disable your spend cap to continue using Supabase without
restrictions.
</span>
) : hasExceededAnyLimits && subscription?.usage_billing_enabled === true ? (
<span>
You have exceeded your{' '}
<span className="font-medium">{subscription?.plan.name}</span> Plan quota in
this billing cycle and will be charged for over-usage.
</span>
) : (
<span>
You have not exceeded your{' '}
<span className="font-medium">{subscription?.plan.name}</span> Plan quota in
this billing cycle.
</span>
)}
</p>
)}
<div className="grid grid-cols-2 mt-3 gap-px bg-border">
{sortedBillingMetrics.map((metric, i) => {
return (
<div
key={metric.key}
className={cn('col-span-2 md:col-span-1 bg-sidebar space-y-4 py-4')}
>
<BillingMetric
idx={i}
slug={orgSlug}
metric={metric}
usage={usage}
subscription={subscription!}
relativeToSubscription={showRelationToSubscription}
className={cn(i % 2 === 0 ? 'md:pr-4' : 'md:pl-4')}
/>
</div>
)
})}
{computeMetrics.map((metric, i) => {
return (
<div
key={metric}
className={cn('col-span-2 md:col-span-1 bg-sidebar space-y-4 py-4')}
>
<ComputeMetric
slug={orgSlug}
metric={{
key: metric,
name: computeUsageMetricLabel(metric) + ' Compute Hours' || metric,
units: 'hours',
anchor: 'compute',
category: 'Compute',
unitName: 'GB',
}}
relativeToSubscription={showRelationToSubscription}
usage={usage}
className={cn(
(i + sortedBillingMetrics.length) % 2 === 0 ? 'md:pr-4' : 'md:pl-4'
)}
/>
</div>
)
})}
{!isMobile && (sortedBillingMetrics.length + computeMetrics.length) % 2 === 1 && (
<div className="col-span-2 md:col-span-1 bg-sidebar" />
)}
</div>
</div>
)}
</SectionContent>
</div>
)
}