Skip to content

Commit fc3734e

Browse files
talissoncostaclaude
andcommitted
feat(usage): show a paid organisation that it is over its plan limit
Adds the banner and the line under the meter. The day the limit was passed is worked out from the daily rows the page already holds, so nothing new is fetched. Restriction only ever applies to a free plan, so a paid organisation over its limit keeps serving flags and the page reports the overage alone. Whether that overage is charged or covered by the grace period is #8264, which needs the API to say so, so the wording hedges until then. The global quota banner is hidden on this route. It says the same thing without the figures, so on this page it would only repeat what the page already shows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 836ac8c commit fc3734e

9 files changed

Lines changed: 290 additions & 29 deletions

File tree

frontend/documentation/components/UsageDashboard.stories.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { UsageDashboard } from 'components/pages/usage'
44
import UsageBreakdown, {
55
useUsageBreakdown,
66
} from 'components/pages/usage/components/UsageBreakdown'
7+
import { overLimitNote, overLimitOf } from 'components/pages/usage/overLimit'
78
import {
89
allowanceWindow,
910
contributionNote,
@@ -99,10 +100,20 @@ const UsagePage: FC<HarnessProps> = ({
99100
scenarioFor(billingPeriod, !!empty, isFreePlan),
100101
share * scale,
101102
)
102-
const allowanceTotal = toUsageResponse(
103+
const allowance = toUsageResponse(
103104
scenarioFor(allowanceWindow(basis), !!empty, isFreePlan),
104105
scale,
105-
).totals.total
106+
)
107+
const allowanceTotal = allowance.totals.total
108+
const exceeded = overLimitOf(allowanceTotal, limit, allowance)
109+
110+
const contribution = showsContribution(
111+
basis,
112+
billingPeriod,
113+
filtered ? 1 : undefined,
114+
)
115+
? contributionNote(project, scoped.totals.total, allowanceTotal)
116+
: undefined
106117

107118
// The note needs the organisation over the period on screen, not over the
108119
// allowance window, or a project can read as more than all of it.
@@ -147,12 +158,11 @@ const UsagePage: FC<HarnessProps> = ({
147158
isError={isError}
148159
isLoading={isLoading}
149160
limit={limit}
150-
meterNote={
151-
showsContribution(basis, billingPeriod, filtered ? 1 : undefined)
152-
? contributionNote(project, scoped.totals.total, allowanceTotal)
153-
: undefined
154-
}
161+
meterNote={exceeded ? overLimitNote(exceeded) : contribution}
155162
onRetry={() => {}}
163+
overLimit={
164+
exceeded ? { basis, canUpgrade: true, over: exceeded } : undefined
165+
}
156166
periodLabel={periodLabel(periods, billingPeriod)}
157167
planCopy={planSectionCopy(basis, limit)}
158168
showPlanCeiling={showsPlanCeiling(
@@ -183,6 +193,9 @@ export const PaidApproachingTheLimit: Story = {
183193
args: { limit: 1400000, subscription: billed },
184194
}
185195

196+
// Over the limit on a paid plan. Flags keep being served, since restriction
197+
// only ever applies to a free plan, so the page reports the overage and says
198+
// the charge may follow.
186199
export const PaidOverTheLimit: Story = {
187200
args: { limit: 900000, subscription: billed },
188201
}

frontend/web/components/App.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -371,12 +371,17 @@ const App = class extends Component {
371371
/>
372372
{user && (
373373
<>
374-
<OrganisationLimit
375-
id={AccountStore.getOrganisation()?.id}
376-
organisationPlan={
377-
AccountStore.getOrganisation()?.subscription.plan
378-
}
379-
/>
374+
{/* The usage page says the same thing with the
375+
figures to back it up, so the global banner would
376+
only repeat it. */}
377+
{!isUsagePage(pathname) && (
378+
<OrganisationLimit
379+
id={AccountStore.getOrganisation()?.id}
380+
organisationPlan={
381+
AccountStore.getOrganisation()?.subscription.plan
382+
}
383+
/>
384+
)}
380385
<div className='container announcement-container'>
381386
<div>
382387
<Announcement />

frontend/web/components/pages/usage/UsageDashboard.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { FC, ReactNode } from 'react'
22
import { Res } from 'common/types/responses'
33
import { PlanLimit } from 'components/shared/UsageBar/utils'
44
import EmptyState from 'components/EmptyState'
5+
import OverLimitBanner, {
6+
OverLimitBannerProps,
7+
} from './components/OverLimitBanner'
58
import SectionHeading from './components/SectionHeading'
69
import UsageMeter from './components/UsageMeter'
710
import UsageOverTime from './components/UsageOverTime'
@@ -21,6 +24,8 @@ export type UsageDashboardProps = {
2124
onRetry?: () => void
2225
filters?: ReactNode
2326
breakdown?: ReactNode
27+
/** Set when the organisation has used more than its plan allows. */
28+
overLimit?: OverLimitBannerProps
2429
}
2530

2631
const UsageDashboard: FC<UsageDashboardProps> = ({
@@ -34,6 +39,7 @@ const UsageDashboard: FC<UsageDashboardProps> = ({
3439
limit,
3540
meterNote,
3641
onRetry,
42+
overLimit,
3743
periodLabel,
3844
planCopy,
3945
showPlanCeiling,
@@ -65,6 +71,8 @@ const UsageDashboard: FC<UsageDashboardProps> = ({
6571
} else {
6672
content = (
6773
<>
74+
{overLimit && <OverLimitBanner {...overLimit} />}
75+
6876
<SectionHeading title={planCopy.title} hint={planCopy.hint} />
6977

7078
<UsageMeter total={total} limit={limit} note={meterNote} />

frontend/web/components/pages/usage/UsageDashboardPage.tsx

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { PeriodOption } from 'common/types/requests'
88
import UsageBreakdown, { useUsageBreakdown } from './components/UsageBreakdown'
99
import UsageDashboard from './UsageDashboard'
1010
import { useUsageData } from './useUsageData'
11+
import { overLimitNote, overLimitOf } from './overLimit'
1112
import {
1213
isBilledOnAPeriod,
1314
isBillingPeriodSelected,
@@ -68,6 +69,10 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
6869
organisationId ? { id: organisationId } : skipToken,
6970
)
7071

72+
const limit = subscriptionMeta?.max_api_calls
73+
const allowanceTotal = usage.allowance?.totals?.total ?? 0
74+
const exceeded = overLimitOf(allowanceTotal, limit, usage.allowance)
75+
7176
const periods = periodsFor(planIsBilled)
7277

7378
const { setDimension, ...breakdown } = useUsageBreakdown({
@@ -81,29 +86,33 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
8186
.filter(Boolean)
8287
.join(' · ')
8388

89+
const contribution =
90+
showsContribution(basis, billingPeriod, selectedProjectId) && projectName
91+
? contributionNote(
92+
projectName,
93+
usage.scoped?.totals?.total ?? 0,
94+
allowanceTotal,
95+
)
96+
: undefined
97+
98+
// Being over the limit outranks the project's share of usage: the slot holds
99+
// one line and only one of them is urgent.
100+
const meterNote = exceeded ? overLimitNote(exceeded) : contribution
101+
84102
if (!organisationId) {
85103
return null
86104
}
87105

88106
return (
89107
<UsageDashboard
90108
data={usage.scoped}
91-
total={usage.allowanceTotal}
92-
limit={subscriptionMeta?.max_api_calls}
109+
total={allowanceTotal}
110+
limit={limit}
93111
hasBillingPeriod={isBillingPeriodSelected(billingPeriod)}
94-
planCopy={planSectionCopy(basis, subscriptionMeta?.max_api_calls)}
112+
planCopy={planSectionCopy(basis, limit)}
95113
periodLabel={periodLabel(periods, billingPeriod)}
96114
showPlanCeiling={showsPlanCeiling(billingPeriod, selectedProjectId)}
97-
meterNote={
98-
showsContribution(basis, billingPeriod, selectedProjectId) &&
99-
projectName
100-
? contributionNote(
101-
projectName,
102-
usage.scoped?.totals?.total ?? 0,
103-
usage.allowanceTotal,
104-
)
105-
: undefined
106-
}
115+
meterNote={meterNote}
107116
breakdown={
108117
<UsageBreakdown
109118
{...breakdown}
@@ -114,6 +123,17 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
114123
isError={organisationFailed || usage.failed || limitFailed}
115124
isLoading={loadingOrganisation || usage.isLoadingPlan || loadingLimit}
116125
isExploring={usage.isLoadingScoped}
126+
// Restriction only ever applies to a free plan, so a paid organisation
127+
// over its limit keeps serving flags and this only reports the overage.
128+
overLimit={
129+
exceeded
130+
? {
131+
basis,
132+
canUpgrade: Utils.getFlagsmithHasFeature('payments_enabled'),
133+
over: exceeded,
134+
}
135+
: undefined
136+
}
117137
onRetry={() => {
118138
refetchOrganisation()
119139
refetchLimit()
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
limitCrossedOn,
3+
OverLimit,
4+
overLimitBannerCopy,
5+
overLimitNote,
6+
overLimitOf,
7+
} from 'components/pages/usage/overLimit'
8+
import { UsageBasis } from 'components/pages/usage/utils'
9+
import { usageEvent, usageResponse } from './fixtures'
10+
11+
const billed: UsageBasis = { window: 'billing-period' }
12+
13+
const days = (perDay: number[]) =>
14+
usageResponse(
15+
perDay.map((flags, index) =>
16+
usageEvent({ day: `2026-08-${`${index + 1}`.padStart(2, '0')}`, flags }),
17+
),
18+
)
19+
20+
// overLimitOf is undefined below the limit; every copy test is above it.
21+
const exceeding = (
22+
total: number,
23+
limit: number,
24+
data?: ReturnType<typeof days>,
25+
) => overLimitOf(total, limit, data) as OverLimit
26+
27+
describe('overLimit', () => {
28+
describe('overLimitOf', () => {
29+
it('is nothing until usage passes the limit', () => {
30+
expect(overLimitOf(50000, 50000, days([50000]))).toBeUndefined()
31+
expect(overLimitOf(49999, 50000, days([49999]))).toBeUndefined()
32+
})
33+
34+
it('is nothing when the plan has no limit to pass', () => {
35+
expect(overLimitOf(9999999, null, days([9999999]))).toBeUndefined()
36+
})
37+
38+
it('reports how far over, and carries the limit it is over', () => {
39+
const over = overLimitOf(60000, 50000, days([60000]))
40+
41+
expect(over?.overBy).toBe(10000)
42+
expect(over?.limit).toBe(50000)
43+
})
44+
})
45+
46+
describe('limitCrossedOn', () => {
47+
it('names the day the running total reached the limit', () => {
48+
expect(limitCrossedOn(days([40, 40, 40, 40]), 100)).toBe('3 Aug')
49+
})
50+
51+
// The API returns a row per day per user agent, so a day only counts once
52+
// its rows are added together.
53+
it('adds up the rows a day is split across', () => {
54+
const data = usageResponse([
55+
usageEvent({ day: '2026-08-01', flags: 60 }),
56+
usageEvent({ day: '2026-08-01', identities: 60 }),
57+
usageEvent({ day: '2026-08-02', flags: 10 }),
58+
])
59+
60+
expect(limitCrossedOn(data, 100)).toBe('1 Aug')
61+
})
62+
63+
it('reads the days in order, whatever order they arrive in', () => {
64+
const data = usageResponse([
65+
usageEvent({ day: '2026-08-03', flags: 40 }),
66+
usageEvent({ day: '2026-08-01', flags: 40 }),
67+
usageEvent({ day: '2026-08-02', flags: 40 }),
68+
])
69+
70+
expect(limitCrossedOn(data, 100)).toBe('3 Aug')
71+
})
72+
73+
it('says nothing when the days never reach the limit', () => {
74+
expect(limitCrossedOn(days([10, 10]), 100)).toBeUndefined()
75+
expect(limitCrossedOn(undefined, 100)).toBeUndefined()
76+
})
77+
})
78+
79+
describe('copy', () => {
80+
it('names the day when the data shows it', () => {
81+
const over = exceeding(60000, 50000, days([40000, 20000]))
82+
83+
expect(overLimitBannerCopy(over, billed).body).toContain(
84+
'plan limit on 2 Aug',
85+
)
86+
})
87+
88+
// Defensive: the totals and the daily rows come from one response, so a
89+
// total over the limit normally has a crossing day somewhere in the rows.
90+
it('leaves the day out when the rows are missing', () => {
91+
const over = exceeding(60000, 50000)
92+
93+
expect(overLimitBannerCopy(over, billed).body).toContain(
94+
'plan limit. Overage',
95+
)
96+
})
97+
98+
it('measures the overage over the window the meter shows', () => {
99+
const over = exceeding(60000, 50000, days([60000]))
100+
101+
expect(overLimitBannerCopy(over, billed).body).toContain(
102+
'this billing period',
103+
)
104+
expect(
105+
overLimitBannerCopy(over, { window: 'rolling' } as UsageBasis).body,
106+
).toContain('the last 30 days')
107+
})
108+
109+
it('says how far over in the note under the meter', () => {
110+
const over = exceeding(60000, 50000, days([60000]))
111+
112+
expect(overLimitNote(over)).toBe('10K calls over your 50K limit.')
113+
})
114+
})
115+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { FC } from 'react'
2+
import Constants from 'common/constants'
3+
import { Button } from 'components/base/forms/Button'
4+
import Icon from 'components/icons/Icon'
5+
import {
6+
overLimitBannerCopy,
7+
OverLimit,
8+
} from 'components/pages/usage/overLimit'
9+
import { UsageBasis } from 'components/pages/usage/utils'
10+
11+
export type OverLimitBannerProps = {
12+
over: OverLimit
13+
basis: UsageBasis
14+
canUpgrade?: boolean
15+
}
16+
17+
const OverLimitBanner: FC<OverLimitBannerProps> = ({
18+
basis,
19+
canUpgrade,
20+
over,
21+
}) => {
22+
const { body, title } = overLimitBannerCopy(over, basis)
23+
24+
return (
25+
<div className='alert alert-danger d-flex align-items-start gap-3 mb-4'>
26+
<Icon name='close-circle' />
27+
<div className='flex-fill'>
28+
<strong className='d-block'>{title}</strong>
29+
{body}
30+
</div>
31+
{canUpgrade && (
32+
<Button
33+
className='flex-shrink-0'
34+
href={Constants.getUpgradeUrl('usage')}
35+
>
36+
Upgrade plan
37+
</Button>
38+
)}
39+
</div>
40+
)
41+
}
42+
43+
export default OverLimitBanner
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './OverLimitBanner'
2+
export type { OverLimitBannerProps } from './OverLimitBanner'

0 commit comments

Comments
 (0)