From 55152a6c8940b7cb564182f079c1d653d2eede73 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 3 Sep 2026 15:32:12 -0300 Subject: [PATCH 1/8] fix(usage): let a restricted organisation reach the usage page A restricted organisation is sent to the blocked screen on every route except the organisations list, so the one page that would explain the restriction is the one page it cannot open. The blocked screen links there now too. block_access_to_admin is enforced only in App. No permission, middleware or view in the API acts on it, so the page loads its data as usual once the route is allowed, and every other route still blocks. The rule lives in web/routePaths rather than App, because web/routes imports App and reading a path back from it is a cycle. Keeping it out of that cycle is also what makes it testable. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/jest.config.js | 2 ++ frontend/web/__tests__/routePaths.test.ts | 29 ++++++++++++++++++++++ frontend/web/components/App.js | 7 +++--- frontend/web/components/BlockedOrgInfo.tsx | 5 ++++ frontend/web/routePaths.ts | 16 ++++++++++++ frontend/web/routes.js | 5 ++-- 6 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 frontend/web/__tests__/routePaths.test.ts create mode 100644 frontend/web/routePaths.ts diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 0d44ad00a7df..cf20584638f9 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -13,6 +13,8 @@ module.exports = { '^common/(.*)$': '/common/$1', '^components/(.*)$': '/web/components/$1', '^project/(.*)$': '/web/project/$1', + // webpack resolves this one too; without it jest cannot follow the app. + '^web/(.*)$': '/web/$1', }, preset: 'ts-jest', roots: [''], diff --git a/frontend/web/__tests__/routePaths.test.ts b/frontend/web/__tests__/routePaths.test.ts new file mode 100644 index 000000000000..a5cf3a29eb2f --- /dev/null +++ b/frontend/web/__tests__/routePaths.test.ts @@ -0,0 +1,29 @@ +import { isAllowedWhileBlocked } from 'web/routePaths' + +// App renders wherever this is false, so a wrong answer either +// locks a blocked organisation out of the page explaining the block, or lets +// it back into the app. +describe('isAllowedWhileBlocked', () => { + it.each` + pathname | allowed + ${'/organisation/7528/usage'} | ${true} + ${'/organisations'} | ${true} + ${'/organisation/7528/projects'} | ${false} + ${'/organisation/7528/settings'} | ${false} + ${'/organisation-settings'} | ${false} + ${'/project/1/environment/abc/features'} | ${false} + ${'/account'} | ${false} + `( + '$pathname is reachable while blocked: $allowed', + ({ allowed, pathname }) => { + expect(isAllowedWhileBlocked(pathname)).toBe(allowed) + }, + ) + + // Allowed by pattern, not by prefix. + it('does not open anything nested under the usage page', () => { + expect(isAllowedWhileBlocked('/organisation/7528/usage/breakdown')).toBe( + false, + ) + }) +}) diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index e43a9bf4d433..f7621f6c2a12 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -30,7 +30,9 @@ import Announcement from './Announcement' import { getBuildVersion } from 'common/services/useBuildVersion' import AccountProvider from 'common/providers/AccountProvider' import Nav from './navigation/Nav' +import { isAllowedWhileBlocked } from 'web/routePaths' import 'project/darkMode' + const App = class extends Component { static propTypes = { children: propTypes.element.isRequired, @@ -271,9 +273,8 @@ const App = class extends Component { const environmentId = this.getEnvironmentId(this.props) if ( - AccountStore.getOrganisation() && - AccountStore.getOrganisation().block_access_to_admin && - pathname !== '/organisations' + AccountStore.getOrganisation()?.block_access_to_admin && + !isAllowedWhileBlocked(pathname) ) { return } diff --git a/frontend/web/components/BlockedOrgInfo.tsx b/frontend/web/components/BlockedOrgInfo.tsx index 492a80ecf6c3..2a7623cebcab 100644 --- a/frontend/web/components/BlockedOrgInfo.tsx +++ b/frontend/web/components/BlockedOrgInfo.tsx @@ -7,6 +7,11 @@ export default function BlockedOrgInfo() {
Organisation name: {AccountStore.getOrganisation().name}
Organisation ID: {AccountStore.getOrganisation().id}
+ diff --git a/frontend/web/routePaths.ts b/frontend/web/routePaths.ts new file mode 100644 index 000000000000..b526a0612fd5 --- /dev/null +++ b/frontend/web/routePaths.ts @@ -0,0 +1,16 @@ +import { matchPath } from 'react-router-dom' + +// Kept out of web/routes, which imports App: reading a path from there in a +// component is a cycle, and it already left this map empty at module level +// once, taking the app down with it. +export const ORGANISATIONS = '/organisations' +export const ORGANISATION_USAGE = '/organisation/:organisationId/usage' + +// A blocked organisation keeps the organisations list, to switch away, and the +// usage page, which explains the block. +const ALLOWED_WHILE_BLOCKED = [ORGANISATIONS, ORGANISATION_USAGE] + +export const isAllowedWhileBlocked = (pathname: string): boolean => + ALLOWED_WHILE_BLOCKED.some((path) => + matchPath(pathname, { exact: true, path, strict: false }), + ) diff --git a/frontend/web/routes.js b/frontend/web/routes.js index 7486352dd8c5..50675c5d78b6 100644 --- a/frontend/web/routes.js +++ b/frontend/web/routes.js @@ -56,6 +56,7 @@ import DevViewPage from './components/pages/DevViewPage' import AdminDashboardPage from './components/pages/admin-dashboard/AdminDashboardPage' import CleanupPage from './components/pages/feature-lifecycle' import OAuthAuthorizePage from './components/pages/OAuthAuthorizePage' +import { ORGANISATION_USAGE, ORGANISATIONS } from './routePaths' import { Provider } from 'react-redux' import { getStore } from 'common/store' export const routes = { @@ -115,8 +116,8 @@ export const routes = { 'organisation-projects': '/organisation/:organisationId/projects', 'organisation-settings': '/organisation/:organisationId/settings', 'organisation-settings-redirect': '/organisation-settings', - 'organisation-usage': '/organisation/:organisationId/usage', - 'organisations': '/organisations', + 'organisation-usage': ORGANISATION_USAGE, + 'organisations': ORGANISATIONS, 'password-reset': '/password-reset/confirm/:uid/:token/', 'permissions': '/project/:projectId/permissions', 'project-redirect': '/project/:projectId', From 0b86ca59c2c7ad8fcf68e69a5ce2a02400cea253 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 3 Sep 2026 15:32:30 -0300 Subject: [PATCH 2/8] feat(usage): show the over-limit and restricted states An organisation over its limit gets a banner and a line under the meter saying how far over. The day it crossed is read off the same running total the chart draws, so the two cannot disagree, and nothing new is fetched. A restricted one gets different copy: what gets access back. Upgrading clears the block at once; otherwise it lifts 30 days after usage drops under the limit. That outlives the overage, so the banner keys off the block rather than off being over. Charges are only mentioned to plans that are actually billed for them. charge_for_api_call_count_overages matches Start-Up and Scale-Up and lets enterprise fall through, so an enterprise plan on a Chargebee term was being warned about a charge that cannot happen. The page composes itself from named parts rather than passing fifteen props into one component, and the free plan is deliberately promised no deadline: the seven-day window is spent after the first restriction and the API does not say which case an organisation is in (#8256). Co-Authored-By: Claude Opus 5 (1M context) --- .../components/UsageDashboard.stories.tsx | 169 +++++++++++++----- frontend/web/__tests__/routePaths.test.ts | 3 +- .../components/pages/usage/UsageDashboard.tsx | 106 ----------- .../pages/usage/UsageDashboardPage.scss | 3 - .../pages/usage/UsageDashboardPage.tsx | 157 ++++++++++------ .../pages/usage/__tests__/overLimit.test.ts | 155 ++++++++++++++++ .../pages/usage/__tests__/utils.test.ts | 27 +++ .../usage/components/OverLimitBanner.tsx | 61 +++++++ .../{SectionHeading => }/SectionHeading.tsx | 0 .../usage/components/SectionHeading/index.ts | 1 - .../components/UsageFilters/UsageFilters.scss | 3 + .../components/UsageFilters/UsageFilters.tsx | 47 +++++ .../usage/components/UsageFilters/index.ts | 2 + .../usage/components/UsagePageLayout.tsx | 55 ++++++ frontend/web/components/pages/usage/index.ts | 2 - .../web/components/pages/usage/overLimit.ts | 84 +++++++++ .../components/pages/usage/useUsageData.ts | 9 +- frontend/web/components/pages/usage/utils.ts | 14 ++ frontend/web/routePaths.ts | 3 +- 19 files changed, 675 insertions(+), 226 deletions(-) delete mode 100644 frontend/web/components/pages/usage/UsageDashboard.tsx delete mode 100644 frontend/web/components/pages/usage/UsageDashboardPage.scss create mode 100644 frontend/web/components/pages/usage/__tests__/overLimit.test.ts create mode 100644 frontend/web/components/pages/usage/components/OverLimitBanner.tsx rename frontend/web/components/pages/usage/components/{SectionHeading => }/SectionHeading.tsx (100%) delete mode 100644 frontend/web/components/pages/usage/components/SectionHeading/index.ts create mode 100644 frontend/web/components/pages/usage/components/UsageFilters/UsageFilters.scss create mode 100644 frontend/web/components/pages/usage/components/UsageFilters/UsageFilters.tsx create mode 100644 frontend/web/components/pages/usage/components/UsageFilters/index.ts create mode 100644 frontend/web/components/pages/usage/components/UsagePageLayout.tsx create mode 100644 frontend/web/components/pages/usage/overLimit.ts diff --git a/frontend/documentation/components/UsageDashboard.stories.tsx b/frontend/documentation/components/UsageDashboard.stories.tsx index 44f2074816b5..41f04b6b641a 100644 --- a/frontend/documentation/components/UsageDashboard.stories.tsx +++ b/frontend/documentation/components/UsageDashboard.stories.tsx @@ -1,14 +1,20 @@ import { FC, useState } from 'react' import type { Meta, StoryObj } from 'storybook' -import { UsageDashboard } from 'components/pages/usage' +import UsagePageLayout from 'components/pages/usage/components/UsagePageLayout' +import OverLimitBanner from 'components/pages/usage/components/OverLimitBanner' +import SectionHeading from 'components/pages/usage/components/SectionHeading' import UsageBreakdown, { useUsageBreakdown, } from 'components/pages/usage/components/UsageBreakdown' +import UsageMeter from 'components/pages/usage/components/UsageMeter' +import UsageOverTime from 'components/pages/usage/components/UsageOverTime' +import { overLimitNote, overLimitOf } from 'components/pages/usage/overLimit' import { allowanceWindow, contributionNote, isBilledOnAPeriod, isBillingPeriodSelected, + isChargedForOverages, periodLabel, periodsFor, PeriodSelection, @@ -22,6 +28,9 @@ import { BillingPeriod, PeriodOption } from 'common/types/requests' import { PlanLimit } from 'components/shared/UsageBar/utils' import { Subscription } from 'common/types/responses' import { toUsageResponse, USAGE_SCENARIOS } from './fixtures/usage' +// The harness fakes the project select, so it never renders UsageFilters and +// would otherwise miss the width its stylesheet sets. +import 'components/pages/usage/components/UsageFilters/UsageFilters.scss' const PROJECTS = [ 'All Projects', @@ -69,6 +78,7 @@ type HarnessProps = { empty?: boolean isLoading?: boolean isError?: boolean + isRestricted?: boolean } /** @@ -79,6 +89,7 @@ const UsagePage: FC = ({ empty, isError, isLoading, + isRestricted, limit, scale = 1, subscription, @@ -99,10 +110,20 @@ const UsagePage: FC = ({ scenarioFor(billingPeriod, !!empty, isFreePlan), share * scale, ) - const allowanceTotal = toUsageResponse( + const allowance = toUsageResponse( scenarioFor(allowanceWindow(basis), !!empty, isFreePlan), scale, - ).totals.total + ) + const allowanceTotal = allowance.totals.total + const exceeded = overLimitOf(allowanceTotal, limit, allowance) + + const contribution = showsContribution( + basis, + billingPeriod, + filtered ? 1 : undefined, + ) + ? contributionNote(project, scoped.totals.total, allowanceTotal) + : undefined // The note needs the organisation over the period on screen, not over the // allowance window, or a project can read as more than all of it. @@ -114,53 +135,79 @@ const UsagePage: FC = ({ )}` return ( - - } - data={scoped} - filters={ - -
- setProject(option.value)} - options={PROJECTS.map((name) => ({ label: name, value: name }))} - value={{ label: project, value: project }} - /> -
-
- } - hasBillingPeriod={isBillingPeriodSelected(billingPeriod)} + + ) } + // Nothing to refetch here; passed so FailedToLoad renders its button. onRetry={() => {}} - periodLabel={periodLabel(periods, billingPeriod)} - planCopy={planSectionCopy(basis, limit)} - showPlanCeiling={showsPlanCeiling( - billingPeriod, - filtered ? 1 : undefined, - )} - total={allowanceTotal} - /> + > + + + + + +
+ + setProject(option.value) + } + options={PROJECTS.map((name) => ({ label: name, value: name }))} + value={{ label: project, value: project }} + /> +
+ + } + /> + + + + +
) } @@ -183,10 +230,29 @@ export const PaidApproachingTheLimit: Story = { args: { limit: 1400000, subscription: billed }, } +// Billed on a term, so the banner mentions charges. export const PaidOverTheLimit: Story = { args: { limit: 900000, subscription: billed }, } +// Only free plans are ever restricted, and this is where they are sent. +export const FreeAndRestricted: Story = { + args: { + isRestricted: true, + limit: 50000, + subscription: subscriptionOf({ plan: 'free' }), + }, +} + +// The block outlives the overage, which is most of that 30 day window. +export const RestrictedButBackUnderTheLimit: Story = { + args: { + isRestricted: true, + limit: 5000000, + subscription: subscriptionOf({ plan: 'free' }), + }, +} + export const FreeOnARollingWindow: Story = { args: { limit: 50000, subscription: subscriptionOf({ plan: 'free' }) }, } @@ -203,6 +269,17 @@ export const EnterpriseWithoutABillingPeriod: Story = { }, } +// Invoiced outside Chargebee, so no charge line. +export const EnterpriseOverTheLimit: Story = { + args: { + limit: 1000000, + subscription: subscriptionOf({ + payment_method: 'XERO', + plan: 'enterprise', + }), + }, +} + // On Chargebee, but no period has arrived. Reads differently from invoiced, // because this one may resolve itself. export const ChargebeeWithoutAPeriodYet: Story = { diff --git a/frontend/web/__tests__/routePaths.test.ts b/frontend/web/__tests__/routePaths.test.ts index a5cf3a29eb2f..d0d412b07870 100644 --- a/frontend/web/__tests__/routePaths.test.ts +++ b/frontend/web/__tests__/routePaths.test.ts @@ -1,8 +1,7 @@ import { isAllowedWhileBlocked } from 'web/routePaths' // App renders wherever this is false, so a wrong answer either -// locks a blocked organisation out of the page explaining the block, or lets -// it back into the app. +// locks a blocked organisation out, or lets it back in. describe('isAllowedWhileBlocked', () => { it.each` pathname | allowed diff --git a/frontend/web/components/pages/usage/UsageDashboard.tsx b/frontend/web/components/pages/usage/UsageDashboard.tsx deleted file mode 100644 index 6402cb0866fc..000000000000 --- a/frontend/web/components/pages/usage/UsageDashboard.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { FC, ReactNode } from 'react' -import { Res } from 'common/types/responses' -import { PlanLimit } from 'components/shared/UsageBar/utils' -import EmptyState from 'components/EmptyState' -import SectionHeading from './components/SectionHeading' -import UsageMeter from './components/UsageMeter' -import UsageOverTime from './components/UsageOverTime' - -export type UsageDashboardProps = { - data: Res['organisationUsage'] | undefined - total: number - limit: PlanLimit - planCopy: { title: string; hint: string } - periodLabel: string - meterNote?: ReactNode - showPlanCeiling?: boolean - hasBillingPeriod: boolean - isError?: boolean - isLoading?: boolean - isExploring?: boolean - onRetry?: () => void - filters?: ReactNode - breakdown?: ReactNode -} - -const UsageDashboard: FC = ({ - breakdown, - data, - filters, - hasBillingPeriod, - isError, - isExploring, - isLoading, - limit, - meterNote, - onRetry, - periodLabel, - planCopy, - showPlanCeiling, - total, -}) => { - let content - - if (isLoading) { - content = ( -
- -
- ) - } else if (isError) { - content = ( - - Try again - - ) - } - /> - ) - } else { - content = ( - <> - - - - - - - {isExploring ? ( -
- -
- ) : ( - <> - - - {breakdown} - - )} - - ) - } - - return ( -
-

Usage

- {content} -
- ) -} - -export default UsageDashboard diff --git a/frontend/web/components/pages/usage/UsageDashboardPage.scss b/frontend/web/components/pages/usage/UsageDashboardPage.scss deleted file mode 100644 index 1d1cc3421b5f..000000000000 --- a/frontend/web/components/pages/usage/UsageDashboardPage.scss +++ /dev/null @@ -1,3 +0,0 @@ -.usage-dashboard__filter { - min-width: 210px; -} diff --git a/frontend/web/components/pages/usage/UsageDashboardPage.tsx b/frontend/web/components/pages/usage/UsageDashboardPage.tsx index 4f1d91b642bf..3d34852899cf 100644 --- a/frontend/web/components/pages/usage/UsageDashboardPage.tsx +++ b/frontend/web/components/pages/usage/UsageDashboardPage.tsx @@ -1,16 +1,21 @@ -import { FC, useState } from 'react' +import { FC, useMemo, useState } from 'react' import { skipToken } from '@reduxjs/toolkit/query' import Utils, { planNames } from 'common/utils/utils' import { useGetOrganisationQuery } from 'common/services/useOrganisation' import { useGetSubscriptionMetadataQuery } from 'common/services/useSubscriptionMetadata' -import ProjectFilter from 'components/ProjectFilter' -import { PeriodOption } from 'common/types/requests' +import OverLimitBanner from './components/OverLimitBanner' +import SectionHeading from './components/SectionHeading' import UsageBreakdown, { useUsageBreakdown } from './components/UsageBreakdown' -import UsageDashboard from './UsageDashboard' +import UsageFilters from './components/UsageFilters' +import UsageMeter from './components/UsageMeter' +import UsageOverTime from './components/UsageOverTime' +import UsagePageLayout from './components/UsagePageLayout' import { useUsageData } from './useUsageData' +import { overLimitNote, overLimitOf } from './overLimit' import { isBilledOnAPeriod, isBillingPeriodSelected, + isChargedForOverages, contributionNote, planSectionCopy, showsContribution, @@ -21,7 +26,6 @@ import { usageBasisOf, resolvePeriod, } from './utils' -import './UsageDashboardPage.scss' type UsageDashboardPageProps = { organisationId: number | undefined @@ -68,84 +72,119 @@ const UsageDashboardPage: FC = ({ organisationId ? { id: organisationId } : skipToken, ) + // The block outlives going over the limit, so this cannot key off exceeded. + const isRestricted = !!organisation?.block_access_to_admin + const mayBeCharged = + isBilledOnAPeriod(basis) && isChargedForOverages(subscription) + + const limit = subscriptionMeta?.max_api_calls + const allowanceTotal = usage.allowance?.totals?.total ?? 0 + // Walks every day in the window to find the crossing, so not per render. + const exceeded = useMemo( + () => overLimitOf(allowanceTotal, limit, usage.allowance), + [allowanceTotal, limit, usage.allowance], + ) + const periods = periodsFor(planIsBilled) const { setDimension, ...breakdown } = useUsageBreakdown({ data: usage.scoped, }) + const selectedPeriod = periodLabel(periods, billingPeriod) + const scope = [ selectedProjectId ? projectName : 'All projects', - periodLabel(periods, billingPeriod), + selectedPeriod, ] .filter(Boolean) .join(' ยท ') + const contribution = + showsContribution(basis, billingPeriod, selectedProjectId) && projectName + ? contributionNote( + projectName, + usage.scoped?.totals?.total ?? 0, + allowanceTotal, + ) + : undefined + + // One line, so being over the limit outranks the project's share. + const meterNote = exceeded ? overLimitNote(exceeded) : contribution + if (!organisationId) { return null } return ( - - } + + ) + } onRetry={() => { refetchOrganisation() refetchLimit() usage.retry() }} - filters={ - -
- onChangePeriod(option.value)} + value={periods.find((option) => option.value === period)} + options={periods} + /> +
+
+ +
+
+) + +export default UsageFilters diff --git a/frontend/web/components/pages/usage/components/UsageFilters/index.ts b/frontend/web/components/pages/usage/components/UsageFilters/index.ts new file mode 100644 index 000000000000..a38e848308c3 --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsageFilters/index.ts @@ -0,0 +1,2 @@ +export { default } from './UsageFilters' +export type { UsageFiltersProps } from './UsageFilters' diff --git a/frontend/web/components/pages/usage/components/UsagePageLayout.tsx b/frontend/web/components/pages/usage/components/UsagePageLayout.tsx new file mode 100644 index 000000000000..1b92733e387e --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsagePageLayout.tsx @@ -0,0 +1,55 @@ +import { FC, ReactNode } from 'react' +import EmptyState from 'components/EmptyState' + +export type UsagePageLayoutProps = { + isError?: boolean + isLoading?: boolean + onRetry?: () => void + /** Outlives the loading and error states: a restricted organisation needs + * to know why it is cut off even when the usage request fails. */ + alert?: ReactNode + children?: ReactNode +} + +const UsagePageLayout: FC = ({ + alert, + children, + isError, + isLoading, + onRetry, +}) => { + let content = children + + if (isLoading) { + content = ( +
+ +
+ ) + } else if (isError) { + content = ( + + Try again + + ) + } + /> + ) + } + + return ( +
+

Usage

+ {alert} + {content} +
+ ) +} + +export default UsagePageLayout diff --git a/frontend/web/components/pages/usage/index.ts b/frontend/web/components/pages/usage/index.ts index a42d3b483e04..20695f388977 100644 --- a/frontend/web/components/pages/usage/index.ts +++ b/frontend/web/components/pages/usage/index.ts @@ -1,3 +1 @@ export { default } from './UsageDashboardPage' -export { default as UsageDashboard } from './UsageDashboard' -export type { UsageDashboardProps } from './UsageDashboard' diff --git a/frontend/web/components/pages/usage/overLimit.ts b/frontend/web/components/pages/usage/overLimit.ts new file mode 100644 index 000000000000..f8531f4a6dd6 --- /dev/null +++ b/frontend/web/components/pages/usage/overLimit.ts @@ -0,0 +1,84 @@ +import { Res } from 'common/types/responses' +import Format from 'common/utils/format' +import { PlanLimit } from 'components/shared/UsageBar/utils' +import { cumulativeTotals, dailyTotals } from './components/UsageOverTime/utils' +import { allowanceWindowLabel, UsageBasis } from './utils' + +export type OverLimit = { + limit: number + overBy: number + /** Undefined when the rows do not cover the crossing. */ + crossedOn: string | undefined +} + +// Same running total the chart draws, so the two cannot disagree. +export const limitCrossedOn = ( + data: Res['organisationUsage'] | undefined, + limit: PlanLimit, +): string | undefined => + limit + ? cumulativeTotals(dailyTotals(data)).find( + (point) => point.cumulative >= limit, + )?.day + : undefined + +export const overLimitOf = ( + total: number, + limit: PlanLimit, + data: Res['organisationUsage'] | undefined, +): OverLimit | undefined => + limit && total > limit + ? { crossedOn: limitCrossedOn(data, limit), limit, overBy: total - limit } + : undefined + +const sentences = (...parts: (string | false | undefined)[]): string => + parts.filter(Boolean).join(' ') + +// Only the overage is evidence the limit was reached. block_access_to_admin +// says an organisation is blocked, not why, and support can set it by hand. +const limitReached = (over: OverLimit | undefined): string | undefined => + over && + `You reached your ${Format.shortenNumber(over.limit)} plan limit${ + over.crossedOn ? ` on ${over.crossedOn}` : '' + }.` + +// Says access, not flags: the API does not expose stop_serving_flags. +const RECOVERY = + 'Upgrading restores access straight away. Otherwise access returns once' + + ' your usage has stayed under the limit for 30 days.' + +const STAYS_VISIBLE = + 'Your usage stays visible below so you can see what happened.' + +export type BannerContext = { + /** The organisation is on a plan that gets billed for overages. */ + mayBeCharged?: boolean +} + +// The block outlives going over the limit, so the overage is optional here. +export const restrictedBannerCopy = ( + over: OverLimit | undefined, +): { title: string; body: string } => ({ + body: sentences(limitReached(over), RECOVERY), + title: 'Your organisation is restricted', +}) + +export const overLimitBannerCopy = ( + over: OverLimit, + basis: UsageBasis, + { mayBeCharged }: BannerContext = {}, +): { title: string; body: string } => ({ + body: sentences( + limitReached(over), + // Hedged: the API does not say whether the charge actually lands. + mayBeCharged && + `Overage charges may apply over ${allowanceWindowLabel(basis)}.`, + STAYS_VISIBLE, + ), + title: 'Your organisation has exceeded its plan limit', +}) + +export const overLimitNote = (over: OverLimit): string => + `${Format.shortenNumber(over.overBy)} ${ + over.overBy === 1 ? 'call' : 'calls' + } over your ${Format.shortenNumber(over.limit)} limit.` diff --git a/frontend/web/components/pages/usage/useUsageData.ts b/frontend/web/components/pages/usage/useUsageData.ts index 453df37731ec..10974a1fd976 100644 --- a/frontend/web/components/pages/usage/useUsageData.ts +++ b/frontend/web/components/pages/usage/useUsageData.ts @@ -15,16 +15,15 @@ type UseUsageData = { export type UsageData = { /** The period and project on screen. Feeds the chart and the breakdown. */ scoped: Res['organisationUsage'] | undefined - /** The organisation over the window its allowance covers. Feeds the meter. */ - allowanceTotal: number + /** The organisation over the window its allowance covers. */ + allowance: Res['organisationUsage'] | undefined isLoadingPlan: boolean isLoadingScoped: boolean failed: boolean retry: () => void } -// usage-data is throttled at five requests a minute per user, so refetching -// every time the tab regains focus spends the budget the page needs. +// usage-data is throttled at five requests a minute per user. const OPTIONS = { refetchOnFocus: false } export const useUsageData = ({ @@ -51,7 +50,7 @@ export const useUsageData = ({ ) return { - allowanceTotal: allowance.data?.totals?.total ?? 0, + allowance: allowance.data, // Either query failing leaves a number missing, so both are fatal. failed: scoped.isError || allowance.isError, diff --git a/frontend/web/components/pages/usage/utils.ts b/frontend/web/components/pages/usage/utils.ts index 853b90de916c..458052d9d538 100644 --- a/frontend/web/components/pages/usage/utils.ts +++ b/frontend/web/components/pages/usage/utils.ts @@ -9,6 +9,11 @@ import { PlanLimit } from 'components/shared/UsageBar/utils' export type PeriodSelection = BillingPeriod | 'default' +// 'free' reads like any other rolling window on purpose. The seven days +// before flags stop only applies to a first breach: OrganisationBreachedGracePeriod +// is written on the first restriction and never deleted, and +// restrict_use_due_to_api_limit_grace_period_over drops the wait once it exists. +// The API does not say which case an organisation is in. export type RollingReason = 'free' | 'no-period' export type UsageBasis = @@ -29,6 +34,15 @@ export const usageBasisOf = ( export const isBilledOnAPeriod = (basis: UsageBasis): boolean => basis.window === 'billing-period' +// Only Start-Up and Scale-Up are billed for overages. Mirrors +// SubscriptionPlanFamily.get_by_plan_id. +export const isChargedForOverages = ( + subscription: Subscription | undefined, +): boolean => { + const plan = (subscription?.plan ?? '').replace(/-/g, '').toLowerCase() + return plan.startsWith('startup') || plan.startsWith('scaleup') +} + export const planSectionCopy = ( basis: UsageBasis, limit: PlanLimit, diff --git a/frontend/web/routePaths.ts b/frontend/web/routePaths.ts index b526a0612fd5..a635814245f0 100644 --- a/frontend/web/routePaths.ts +++ b/frontend/web/routePaths.ts @@ -1,8 +1,7 @@ import { matchPath } from 'react-router-dom' // Kept out of web/routes, which imports App: reading a path from there in a -// component is a cycle, and it already left this map empty at module level -// once, taking the app down with it. +// component is a cycle, and it took the app down once. export const ORGANISATIONS = '/organisations' export const ORGANISATION_USAGE = '/organisation/:organisationId/usage' From d6b42611d8fd0843a031ed934169b299df54f13c Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 3 Sep 2026 15:32:33 -0300 Subject: [PATCH 3/8] docs(frontend): say what the import and component rules enforce Rule 2 forbade all relative imports, which contradicts the lint config and every barrel in the codebase: prefer-alias only requires an alias when the path goes up, and eslint --fix rewrites one back to relative inside its own root. Rule 8 required a folder and a barrel for every component. A barrel re-exporting one file buys nothing, since the import specifier is the same either way, so a file can become a folder later without touching a caller. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 38babdba1aee..a98739996f32 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -13,13 +13,13 @@ ## Rules 1. **API Integration**: Use `npx ssg` CLI + check `../api` backend -2. **Imports**: Use `common/`, `components/`, `project/` (NO relative imports) +2. **Imports**: Use the `common/`, `components/`, `project/` aliases when the relative path would go up (`../`); use a relative path for same-folder or descendant imports. This is enforced by `@dword-design/import-alias/prefer-alias`, and `eslint --fix` will rewrite an alias back to relative inside its own alias root (e.g. `components/base/forms/X` becomes `./base/forms/X` in a file under `web/components/`). 3. **State**: Redux Toolkit + RTK Query, store in `common/store.ts` 4. **Feature Flags**: When user says "create a feature flag", you MUST: (1) Create it in Flagsmith using MCP tools (`mcp__flagsmith__create_feature`), (2) Implement code with `useFlags` hook. See `.claude/context/feature-flags/` for details 5. **Linting**: ALWAYS run `npx eslint --fix ` on any files you modify 6. **Type Enums**: Extract inline union types to named types (e.g., `type Status = 'A' | 'B'` instead of inline) 7. **NO FETCH**: NEVER use `fetch()` directly - ALWAYS use RTK Query mutations/queries (inject endpoints into services in `common/services/`), see api-integration context -8. **Component structure**: Each new component lives in its own folder with an `index.ts` barrel - `ComponentName/ComponentName.tsx`, co-located `ComponentName.scss`, any sub-components, and an `index.ts` that re-exports the default (and public types). Import via the folder (`components/.../ComponentName`), never the inner file. Keep files focused (~100 lines as a target); split by concern, not to hit a number. Data tables/constant maps are exempt. +8. **Component structure**: A component with nothing to keep beside it is a single `ComponentName.tsx`. It gets a folder once it has a co-located `ComponentName.scss`, sub-components, tests or hooks: `ComponentName/ComponentName.tsx` plus an `index.ts` re-exporting the default (and public types). Import via `components/.../ComponentName` either way, never the inner file, so promoting a file to a folder changes no imports. Keep files focused (~100 lines as a target); split by concern, not to hit a number. Data tables/constant maps are exempt. ## Key Files - Store: `common/store.ts` From 41597d0122b9f64f430511283c2f88f3d00f4fcb Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 3 Sep 2026 17:32:53 -0300 Subject: [PATCH 4/8] refactor(usage): give the page's copy a module of its own Strings lived in three places. overLimit mixed computing an overage with writing prose about it, utils carried the plan section and the project contribution, and the two banners said the same thing in two slightly different sentences as a result. copy.ts now holds everything the page says about a plan and its limit, so someone asking what a customer is told reads one file. overLimit keeps the arithmetic, utils keeps periods and windows, and the tests split the same way: copy.test asserts prose, overLimit.test asserts numbers. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/UsageDashboard.stories.tsx | 9 +- .../pages/usage/UsageDashboardPage.tsx | 5 +- .../pages/usage/__tests__/copy.test.ts | 102 ++++++++++++++++++ .../pages/usage/__tests__/overLimit.test.ts | 97 +---------------- .../pages/usage/__tests__/utils.test.ts | 3 +- .../usage/components/OverLimitBanner.tsx | 4 +- frontend/web/components/pages/usage/copy.ts | 96 +++++++++++++++++ .../web/components/pages/usage/overLimit.ts | 54 ---------- frontend/web/components/pages/usage/utils.ts | 43 -------- 9 files changed, 210 insertions(+), 203 deletions(-) create mode 100644 frontend/web/components/pages/usage/__tests__/copy.test.ts create mode 100644 frontend/web/components/pages/usage/copy.ts diff --git a/frontend/documentation/components/UsageDashboard.stories.tsx b/frontend/documentation/components/UsageDashboard.stories.tsx index 41f04b6b641a..c03b20d5cad9 100644 --- a/frontend/documentation/components/UsageDashboard.stories.tsx +++ b/frontend/documentation/components/UsageDashboard.stories.tsx @@ -8,17 +8,20 @@ import UsageBreakdown, { } from 'components/pages/usage/components/UsageBreakdown' import UsageMeter from 'components/pages/usage/components/UsageMeter' import UsageOverTime from 'components/pages/usage/components/UsageOverTime' -import { overLimitNote, overLimitOf } from 'components/pages/usage/overLimit' import { - allowanceWindow, contributionNote, + overLimitNote, + planSectionCopy, +} from 'components/pages/usage/copy' +import { overLimitOf } from 'components/pages/usage/overLimit' +import { + allowanceWindow, isBilledOnAPeriod, isBillingPeriodSelected, isChargedForOverages, periodLabel, periodsFor, PeriodSelection, - planSectionCopy, resolvePeriod, showsContribution, showsPlanCeiling, diff --git a/frontend/web/components/pages/usage/UsageDashboardPage.tsx b/frontend/web/components/pages/usage/UsageDashboardPage.tsx index 3d34852899cf..9dedf1e6d24c 100644 --- a/frontend/web/components/pages/usage/UsageDashboardPage.tsx +++ b/frontend/web/components/pages/usage/UsageDashboardPage.tsx @@ -11,13 +11,12 @@ import UsageMeter from './components/UsageMeter' import UsageOverTime from './components/UsageOverTime' import UsagePageLayout from './components/UsagePageLayout' import { useUsageData } from './useUsageData' -import { overLimitNote, overLimitOf } from './overLimit' +import { contributionNote, overLimitNote, planSectionCopy } from './copy' +import { overLimitOf } from './overLimit' import { isBilledOnAPeriod, isBillingPeriodSelected, isChargedForOverages, - contributionNote, - planSectionCopy, showsContribution, showsPlanCeiling, periodLabel, diff --git a/frontend/web/components/pages/usage/__tests__/copy.test.ts b/frontend/web/components/pages/usage/__tests__/copy.test.ts new file mode 100644 index 000000000000..2fcbb7a3501b --- /dev/null +++ b/frontend/web/components/pages/usage/__tests__/copy.test.ts @@ -0,0 +1,102 @@ +import { + overLimitBannerCopy, + overLimitNote, + restrictedBannerCopy, +} from 'components/pages/usage/copy' +import { OverLimit, overLimitOf } from 'components/pages/usage/overLimit' +import { UsageBasis } from 'components/pages/usage/utils' +import { usageEvent, usageResponse } from './fixtures' + +const billed: UsageBasis = { window: 'billing-period' } + +const days = (perDay: number[]) => + usageResponse( + perDay.map((flags, index) => + usageEvent({ day: `2026-08-${`${index + 1}`.padStart(2, '0')}`, flags }), + ), + ) + +// overLimitOf is undefined below the limit; every copy test is above it. +const exceeding = ( + total: number, + limit: number, + data?: ReturnType, +) => overLimitOf(total, limit, data) as OverLimit + +describe('usage copy', () => { + it('names the day when the data shows it', () => { + const over = exceeding(60000, 50000, days([40000, 20000])) + + expect(overLimitBannerCopy(over, billed).body).toContain( + 'plan limit on 2 Aug', + ) + }) + + // Artificial: totals and rows always arrive in the same response. + it('leaves the day out when the rows are missing', () => { + const over = exceeding(60000, 50000) + + const { body } = overLimitBannerCopy(over, billed) + + expect(body).toContain('your 50K plan limit.') + expect(body).not.toContain(' on ') + }) + + it('warns about charges only where they can be charged', () => { + const over = exceeding(60000, 50000, days([60000])) + + expect( + overLimitBannerCopy(over, billed, { mayBeCharged: true }).body, + ).toContain('Overage charges may apply over this billing period.') + expect(overLimitBannerCopy(over, billed).body).not.toContain( + 'Overage charges', + ) + expect( + overLimitBannerCopy(over, { window: 'rolling' } as UsageBasis, { + mayBeCharged: true, + }).body, + ).not.toContain('this billing period') + }) + + it('still reports the overage itself on a rolling window', () => { + const over = exceeding(60000, 50000, days([40000, 20000])) + const body = overLimitBannerCopy(over, { + window: 'rolling', + } as UsageBasis).body + + expect(body).toContain('You reached your 50K plan limit on 2 Aug.') + expect(body).toContain('Your usage stays visible below') + }) + + it('tells a restricted organisation how to get access back', () => { + const over = exceeding(60000, 50000, days([40000, 20000])) + const { body, title } = restrictedBannerCopy(over) + + expect(title).toBe('Your organisation is restricted') + expect(body).toContain('You reached your 50K plan limit on 2 Aug.') + expect(body).toContain('stayed under the limit for 30 days') + // The charge is not the point once they are already cut off. + expect(body).not.toContain('Overage charges') + }) + + // Most of that 30 day window has no overage left to report. + it('explains the restriction with no overage to report', () => { + const { body, title } = restrictedBannerCopy(undefined) + + expect(title).toBe('Your organisation is restricted') + expect(body).toContain('stayed under the limit for 30 days') + // block_access_to_admin can be set by hand, so with no overage in + // evidence the copy must not claim the limit was reached. + expect(body).not.toContain('plan limit') + }) + + it('says how far over in the note under the meter', () => { + const over = exceeding(60000, 50000, days([60000])) + + expect(overLimitNote(over)).toBe('10K calls over your 50K limit.') + // shortenNumber leaves small counts alone, so one is reachable. + expect(overLimitNote(exceeding(50001, 50000, days([50001])))).toBe( + '1 call over your 50K limit.', + ) + }) +}) diff --git a/frontend/web/components/pages/usage/__tests__/overLimit.test.ts b/frontend/web/components/pages/usage/__tests__/overLimit.test.ts index 4c1f46e69320..e13cfc8592a3 100644 --- a/frontend/web/components/pages/usage/__tests__/overLimit.test.ts +++ b/frontend/web/components/pages/usage/__tests__/overLimit.test.ts @@ -1,16 +1,6 @@ -import { - limitCrossedOn, - OverLimit, - restrictedBannerCopy, - overLimitBannerCopy, - overLimitNote, - overLimitOf, -} from 'components/pages/usage/overLimit' -import { UsageBasis } from 'components/pages/usage/utils' +import { limitCrossedOn, overLimitOf } from 'components/pages/usage/overLimit' import { usageEvent, usageResponse } from './fixtures' -const billed: UsageBasis = { window: 'billing-period' } - const days = (perDay: number[]) => usageResponse( perDay.map((flags, index) => @@ -18,13 +8,6 @@ const days = (perDay: number[]) => ), ) -// Every copy test is above the limit, so the cast holds. -const exceeding = ( - total: number, - limit: number, - data?: ReturnType, -) => overLimitOf(total, limit, data) as OverLimit - describe('overLimit', () => { describe('overLimitOf', () => { it('is nothing until usage passes the limit', () => { @@ -74,82 +57,4 @@ describe('overLimit', () => { expect(limitCrossedOn(undefined, 100)).toBeUndefined() }) }) - - describe('copy', () => { - it('names the day when the data shows it', () => { - const over = exceeding(60000, 50000, days([40000, 20000])) - - expect(overLimitBannerCopy(over, billed).body).toContain( - 'plan limit on 2 Aug', - ) - }) - - // Artificial: totals and rows always arrive in the same response. - it('leaves the day out when the rows are missing', () => { - const over = exceeding(60000, 50000) - - const { body } = overLimitBannerCopy(over, billed) - - expect(body).toContain('your 50K plan limit.') - expect(body).not.toContain(' on ') - }) - - it('warns about charges only where they can be charged', () => { - const over = exceeding(60000, 50000, days([60000])) - - expect( - overLimitBannerCopy(over, billed, { mayBeCharged: true }).body, - ).toContain('Overage charges may apply over this billing period.') - expect(overLimitBannerCopy(over, billed).body).not.toContain( - 'Overage charges', - ) - expect( - overLimitBannerCopy(over, { window: 'rolling' } as UsageBasis, { - mayBeCharged: true, - }).body, - ).not.toContain('this billing period') - }) - - it('still reports the overage itself on a rolling window', () => { - const over = exceeding(60000, 50000, days([40000, 20000])) - const body = overLimitBannerCopy(over, { - window: 'rolling', - } as UsageBasis).body - - expect(body).toContain('You reached your 50K plan limit on 2 Aug.') - expect(body).toContain('Your usage stays visible below') - }) - - it('tells a restricted organisation how to get access back', () => { - const over = exceeding(60000, 50000, days([40000, 20000])) - const { body, title } = restrictedBannerCopy(over) - - expect(title).toBe('Your organisation is restricted') - expect(body).toContain('You reached your 50K plan limit on 2 Aug.') - expect(body).toContain('stayed under the limit for 30 days') - // The charge is not the point once they are already cut off. - expect(body).not.toContain('Overage charges') - }) - - // Most of that 30 day window has no overage left to report. - it('explains the restriction with no overage to report', () => { - const { body, title } = restrictedBannerCopy(undefined) - - expect(title).toBe('Your organisation is restricted') - expect(body).toContain('stayed under the limit for 30 days') - // block_access_to_admin can be set by hand, so with no overage in - // evidence the copy must not claim the limit was reached. - expect(body).not.toContain('plan limit') - }) - - it('says how far over in the note under the meter', () => { - const over = exceeding(60000, 50000, days([60000])) - - expect(overLimitNote(over)).toBe('10K calls over your 50K limit.') - // shortenNumber leaves small counts alone, so one is reachable. - expect(overLimitNote(exceeding(50001, 50000, days([50001])))).toBe( - '1 call over your 50K limit.', - ) - }) - }) }) diff --git a/frontend/web/components/pages/usage/__tests__/utils.test.ts b/frontend/web/components/pages/usage/__tests__/utils.test.ts index 8e1d50361df2..45b265f259cf 100644 --- a/frontend/web/components/pages/usage/__tests__/utils.test.ts +++ b/frontend/web/components/pages/usage/__tests__/utils.test.ts @@ -1,10 +1,8 @@ import { Subscription } from 'common/types/responses' import { - contributionNote, isBillingPeriodSelected, isChargedForOverages, allowanceWindow, - planSectionCopy, allowanceWindowLabel, showsContribution, showsPlanCeiling, @@ -12,6 +10,7 @@ import { periodsFor, resolvePeriod, } from 'components/pages/usage/utils' +import { contributionNote, planSectionCopy } from 'components/pages/usage/copy' const subscription = (values: Partial): Subscription => ({ has_active_billing_periods: false, plan: null, ...values } as Subscription) diff --git a/frontend/web/components/pages/usage/components/OverLimitBanner.tsx b/frontend/web/components/pages/usage/components/OverLimitBanner.tsx index 326f44492cca..e9e6c321109f 100644 --- a/frontend/web/components/pages/usage/components/OverLimitBanner.tsx +++ b/frontend/web/components/pages/usage/components/OverLimitBanner.tsx @@ -5,9 +5,9 @@ import Icon from 'components/icons/Icon' import { BannerContext, overLimitBannerCopy, - OverLimit, restrictedBannerCopy, -} from 'components/pages/usage/overLimit' +} from 'components/pages/usage/copy' +import { OverLimit } from 'components/pages/usage/overLimit' import { UsageBasis } from 'components/pages/usage/utils' export type OverLimitBannerProps = BannerContext & { diff --git a/frontend/web/components/pages/usage/copy.ts b/frontend/web/components/pages/usage/copy.ts new file mode 100644 index 000000000000..96ff63c4b98f --- /dev/null +++ b/frontend/web/components/pages/usage/copy.ts @@ -0,0 +1,96 @@ +import Format from 'common/utils/format' +import { PlanLimit } from 'components/shared/UsageBar/utils' +import { OverLimit } from './overLimit' +import { allowanceWindowLabel, UsageBasis } from './utils' + +/** + * Everything the usage page says about a plan and its limit, in one place, so + * a sentence cannot drift into two versions of itself. + */ + +const sentences = (...parts: (string | false | undefined)[]): string => + parts.filter(Boolean).join(' ') + +// Only the overage is evidence the limit was reached. block_access_to_admin +// says an organisation is blocked, not why, and support can set it by hand. +const limitReached = (over: OverLimit | undefined): string | undefined => + over && + `You reached your ${Format.shortenNumber(over.limit)} plan limit${ + over.crossedOn ? ` on ${over.crossedOn}` : '' + }.` + +// Says access, not flags: the API does not expose stop_serving_flags. +const RECOVERY = + 'Upgrading restores access straight away. Otherwise access returns once' + + ' your usage has stayed under the limit for 30 days.' + +const STAYS_VISIBLE = + 'Your usage stays visible below so you can see what happened.' + +export type BannerContext = { + /** The organisation is on a plan that gets billed for overages. */ + mayBeCharged?: boolean +} + +// The block outlives going over the limit, so the overage is optional here. +export const restrictedBannerCopy = ( + over: OverLimit | undefined, +): { title: string; body: string } => ({ + body: sentences(limitReached(over), RECOVERY), + title: 'Your organisation is restricted', +}) + +export const overLimitBannerCopy = ( + over: OverLimit, + basis: UsageBasis, + { mayBeCharged }: BannerContext = {}, +): { title: string; body: string } => ({ + body: sentences( + limitReached(over), + // Hedged: the API does not say whether the charge actually lands. + mayBeCharged && + `Overage charges may apply over ${allowanceWindowLabel(basis)}.`, + STAYS_VISIBLE, + ), + title: 'Your organisation has exceeded its plan limit', +}) + +export const overLimitNote = (over: OverLimit): string => + `${Format.shortenNumber(over.overBy)} ${ + over.overBy === 1 ? 'call' : 'calls' + } over your ${Format.shortenNumber(over.limit)} limit.` + +export const planSectionCopy = ( + basis: UsageBasis, + limit: PlanLimit, +): { title: string; hint: string } => { + const window = allowanceWindowLabel(basis) + + if (!limit) { + return { + hint: `API calls over ${window}. This installation has no plan limit.`, + title: 'Your usage', + } + } + + return { + hint: sentences( + `Usage against your plan limit over ${window}.`, + basis.window === 'rolling' && + basis.reason === 'no-period' && + 'We are unable to show exact billing periods for your subscription plan.', + ), + title: 'Your plan', + } +} + +export const contributionNote = ( + projectName: string, + scopedTotal: number, + organisationTotal: number, +): string | undefined => + organisationTotal > 0 + ? `${projectName} accounts for ${Math.round( + (scopedTotal / organisationTotal) * 100, + )}% of that usage.` + : undefined diff --git a/frontend/web/components/pages/usage/overLimit.ts b/frontend/web/components/pages/usage/overLimit.ts index f8531f4a6dd6..142f280921f7 100644 --- a/frontend/web/components/pages/usage/overLimit.ts +++ b/frontend/web/components/pages/usage/overLimit.ts @@ -1,8 +1,6 @@ import { Res } from 'common/types/responses' -import Format from 'common/utils/format' import { PlanLimit } from 'components/shared/UsageBar/utils' import { cumulativeTotals, dailyTotals } from './components/UsageOverTime/utils' -import { allowanceWindowLabel, UsageBasis } from './utils' export type OverLimit = { limit: number @@ -30,55 +28,3 @@ export const overLimitOf = ( limit && total > limit ? { crossedOn: limitCrossedOn(data, limit), limit, overBy: total - limit } : undefined - -const sentences = (...parts: (string | false | undefined)[]): string => - parts.filter(Boolean).join(' ') - -// Only the overage is evidence the limit was reached. block_access_to_admin -// says an organisation is blocked, not why, and support can set it by hand. -const limitReached = (over: OverLimit | undefined): string | undefined => - over && - `You reached your ${Format.shortenNumber(over.limit)} plan limit${ - over.crossedOn ? ` on ${over.crossedOn}` : '' - }.` - -// Says access, not flags: the API does not expose stop_serving_flags. -const RECOVERY = - 'Upgrading restores access straight away. Otherwise access returns once' + - ' your usage has stayed under the limit for 30 days.' - -const STAYS_VISIBLE = - 'Your usage stays visible below so you can see what happened.' - -export type BannerContext = { - /** The organisation is on a plan that gets billed for overages. */ - mayBeCharged?: boolean -} - -// The block outlives going over the limit, so the overage is optional here. -export const restrictedBannerCopy = ( - over: OverLimit | undefined, -): { title: string; body: string } => ({ - body: sentences(limitReached(over), RECOVERY), - title: 'Your organisation is restricted', -}) - -export const overLimitBannerCopy = ( - over: OverLimit, - basis: UsageBasis, - { mayBeCharged }: BannerContext = {}, -): { title: string; body: string } => ({ - body: sentences( - limitReached(over), - // Hedged: the API does not say whether the charge actually lands. - mayBeCharged && - `Overage charges may apply over ${allowanceWindowLabel(basis)}.`, - STAYS_VISIBLE, - ), - title: 'Your organisation has exceeded its plan limit', -}) - -export const overLimitNote = (over: OverLimit): string => - `${Format.shortenNumber(over.overBy)} ${ - over.overBy === 1 ? 'call' : 'calls' - } over your ${Format.shortenNumber(over.limit)} limit.` diff --git a/frontend/web/components/pages/usage/utils.ts b/frontend/web/components/pages/usage/utils.ts index 458052d9d538..4bb5a8a22f0f 100644 --- a/frontend/web/components/pages/usage/utils.ts +++ b/frontend/web/components/pages/usage/utils.ts @@ -5,7 +5,6 @@ import { rollingPeriodOptions, } from 'common/types/requests' import { Subscription } from 'common/types/responses' -import { PlanLimit } from 'components/shared/UsageBar/utils' export type PeriodSelection = BillingPeriod | 'default' @@ -43,34 +42,6 @@ export const isChargedForOverages = ( return plan.startsWith('startup') || plan.startsWith('scaleup') } -export const planSectionCopy = ( - basis: UsageBasis, - limit: PlanLimit, -): { title: string; hint: string } => { - if (!limit) { - return { - hint: `API calls over ${allowanceWindowLabel( - basis, - )}. This installation has no plan limit.`, - title: 'Your usage', - } - } - - if (basis.window === 'rolling' && basis.reason === 'no-period') { - return { - hint: `Usage against your plan limit over ${allowanceWindowLabel( - basis, - )}. We are unable to show exact billing periods for your subscription plan.`, - title: 'Your plan', - } - } - - return { - hint: `Usage against your plan limit over ${allowanceWindowLabel(basis)}.`, - title: 'Your plan', - } -} - export const resolvePeriod = ( chosen: PeriodSelection, billingPeriodAvailable: boolean, @@ -84,20 +55,6 @@ export const resolvePeriod = ( export const isBillingPeriodSelected = (period: BillingPeriod): boolean => period === 'current_billing_period' || period === 'previous_billing_period' -export const contributionNote = ( - projectName: string, - scopedTotal: number, - organisationTotal: number, -): string | undefined => { - if (organisationTotal <= 0) { - return undefined - } - - const percent = Math.round((scopedTotal / organisationTotal) * 100) - - return `${projectName} accounts for ${percent}% of that usage.` -} - // The note sits under the meter, so it can only compare over the window the // meter shows. On any other period "that usage" would name a figure that is // not on screen. From 7cf1b50e900eb895d57d6d98527de58fbb40a63e Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 3 Sep 2026 17:35:39 -0300 Subject: [PATCH 5/8] fix(usage): promise nothing to a restriction we cannot explain Neither recovery route works for a block support set by hand: the plan-change hook and the unrestricting task both skip organisations with no APILimitAccessBlock record. So an organisation blocked for any other reason was being told to upgrade, which would not have helped. With no overage in evidence the block could be either that or a usage block whose usage has since dropped, and the API does not say which, so the banner now points at support instead of promising a route that may not exist. Co-Authored-By: Claude Opus 5 (1M context) --- .../documentation/components/UsageDashboard.stories.tsx | 5 +++-- .../web/components/pages/usage/__tests__/copy.test.ts | 8 +++++--- frontend/web/components/pages/usage/copy.ts | 8 +++++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/documentation/components/UsageDashboard.stories.tsx b/frontend/documentation/components/UsageDashboard.stories.tsx index c03b20d5cad9..5a339b5589a1 100644 --- a/frontend/documentation/components/UsageDashboard.stories.tsx +++ b/frontend/documentation/components/UsageDashboard.stories.tsx @@ -247,8 +247,9 @@ export const FreeAndRestricted: Story = { }, } -// The block outlives the overage, which is most of that 30 day window. -export const RestrictedButBackUnderTheLimit: Story = { +// The block outlives the overage, and support can set it by hand, so with +// nothing in evidence the banner promises nothing. +export const RestrictedWithNoOverageInEvidence: Story = { args: { isRestricted: true, limit: 5000000, diff --git a/frontend/web/components/pages/usage/__tests__/copy.test.ts b/frontend/web/components/pages/usage/__tests__/copy.test.ts index 2fcbb7a3501b..17a9905f085c 100644 --- a/frontend/web/components/pages/usage/__tests__/copy.test.ts +++ b/frontend/web/components/pages/usage/__tests__/copy.test.ts @@ -84,10 +84,12 @@ describe('usage copy', () => { const { body, title } = restrictedBannerCopy(undefined) expect(title).toBe('Your organisation is restricted') - expect(body).toContain('stayed under the limit for 30 days') - // block_access_to_admin can be set by hand, so with no overage in - // evidence the copy must not claim the limit was reached. + expect(body).toBe('Contact support to restore access.') + // block_access_to_admin can be set by hand, and neither recovery route + // works for such a block, so with no overage in evidence we promise + // nothing. expect(body).not.toContain('plan limit') + expect(body).not.toContain('Upgrading') }) it('says how far over in the note under the meter', () => { diff --git a/frontend/web/components/pages/usage/copy.ts b/frontend/web/components/pages/usage/copy.ts index 96ff63c4b98f..806fcc74f08e 100644 --- a/frontend/web/components/pages/usage/copy.ts +++ b/frontend/web/components/pages/usage/copy.ts @@ -24,6 +24,12 @@ const RECOVERY = 'Upgrading restores access straight away. Otherwise access returns once' + ' your usage has stayed under the limit for 30 days.' +// Neither route works for a block support set by hand: the plan-change hook +// and the unrestricting task both skip organisations with no +// APILimitAccessBlock. With no overage in evidence we cannot tell the two +// apart, so we promise nothing. +const ASK_SUPPORT = 'Contact support to restore access.' + const STAYS_VISIBLE = 'Your usage stays visible below so you can see what happened.' @@ -36,7 +42,7 @@ export type BannerContext = { export const restrictedBannerCopy = ( over: OverLimit | undefined, ): { title: string; body: string } => ({ - body: sentences(limitReached(over), RECOVERY), + body: over ? sentences(limitReached(over), RECOVERY) : ASK_SUPPORT, title: 'Your organisation is restricted', }) From 6cebfd13e6fffa605970cd1a6f9357d68bc76a58 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 3 Sep 2026 17:37:45 -0300 Subject: [PATCH 6/8] refactor(usage): gather the fixed sentences into one block Every sentence that does not depend on a number now sits in COPY, so the prose can be read top to bottom without following the logic that picks it. Two more came in from planSectionCopy, which had them inline. The interpolated ones stay as functions: a placeholder in a data file becomes an empty string at runtime, where a template literal is a compile error. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/web/components/pages/usage/copy.ts | 42 +++++++++++---------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/frontend/web/components/pages/usage/copy.ts b/frontend/web/components/pages/usage/copy.ts index 806fcc74f08e..10a7fb2118a5 100644 --- a/frontend/web/components/pages/usage/copy.ts +++ b/frontend/web/components/pages/usage/copy.ts @@ -19,19 +19,23 @@ const limitReached = (over: OverLimit | undefined): string | undefined => over.crossedOn ? ` on ${over.crossedOn}` : '' }.` -// Says access, not flags: the API does not expose stop_serving_flags. -const RECOVERY = - 'Upgrading restores access straight away. Otherwise access returns once' + - ' your usage has stayed under the limit for 30 days.' - -// Neither route works for a block support set by hand: the plan-change hook -// and the unrestricting task both skip organisations with no -// APILimitAccessBlock. With no overage in evidence we cannot tell the two -// apart, so we promise nothing. -const ASK_SUPPORT = 'Contact support to restore access.' - -const STAYS_VISIBLE = - 'Your usage stays visible below so you can see what happened.' +/** Every sentence that does not depend on a number. */ +const COPY = { + // Neither route works for a block support set by hand: the plan-change hook + // and the unrestricting task both skip organisations with no + // APILimitAccessBlock, so this is all we can offer without evidence. + askSupport: 'Contact support to restore access.', + noBillingPeriod: + 'We are unable to show exact billing periods for your subscription plan.', + noPlanLimit: 'This installation has no plan limit.', + overLimitTitle: 'Your organisation has exceeded its plan limit', + // Says access, not flags: the API does not expose stop_serving_flags. + recovery: + 'Upgrading restores access straight away. Otherwise access returns once' + + ' your usage has stayed under the limit for 30 days.', + restrictedTitle: 'Your organisation is restricted', + staysVisible: 'Your usage stays visible below so you can see what happened.', +} export type BannerContext = { /** The organisation is on a plan that gets billed for overages. */ @@ -42,8 +46,8 @@ export type BannerContext = { export const restrictedBannerCopy = ( over: OverLimit | undefined, ): { title: string; body: string } => ({ - body: over ? sentences(limitReached(over), RECOVERY) : ASK_SUPPORT, - title: 'Your organisation is restricted', + body: over ? sentences(limitReached(over), COPY.recovery) : COPY.askSupport, + title: COPY.restrictedTitle, }) export const overLimitBannerCopy = ( @@ -56,9 +60,9 @@ export const overLimitBannerCopy = ( // Hedged: the API does not say whether the charge actually lands. mayBeCharged && `Overage charges may apply over ${allowanceWindowLabel(basis)}.`, - STAYS_VISIBLE, + COPY.staysVisible, ), - title: 'Your organisation has exceeded its plan limit', + title: COPY.overLimitTitle, }) export const overLimitNote = (over: OverLimit): string => @@ -74,7 +78,7 @@ export const planSectionCopy = ( if (!limit) { return { - hint: `API calls over ${window}. This installation has no plan limit.`, + hint: sentences(`API calls over ${window}.`, COPY.noPlanLimit), title: 'Your usage', } } @@ -84,7 +88,7 @@ export const planSectionCopy = ( `Usage against your plan limit over ${window}.`, basis.window === 'rolling' && basis.reason === 'no-period' && - 'We are unable to show exact billing periods for your subscription plan.', + COPY.noBillingPeriod, ), title: 'Your plan', } From bcd955ac8c748dbb6c6b60f6d290ebbe9209d224 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 4 Sep 2026 09:21:39 -0300 Subject: [PATCH 7/8] refactor(usage): stop the story importing a stylesheet for one rule The harness fakes the project select rather than rendering UsageFilters, so it was reaching into that component's stylesheet for the width alone. A constant in the story says the same thing without coupling a story to a component it does not render. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/UsageDashboard.stories.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/documentation/components/UsageDashboard.stories.tsx b/frontend/documentation/components/UsageDashboard.stories.tsx index 5a339b5589a1..17aba57a4bb1 100644 --- a/frontend/documentation/components/UsageDashboard.stories.tsx +++ b/frontend/documentation/components/UsageDashboard.stories.tsx @@ -31,9 +31,10 @@ import { BillingPeriod, PeriodOption } from 'common/types/requests' import { PlanLimit } from 'components/shared/UsageBar/utils' import { Subscription } from 'common/types/responses' import { toUsageResponse, USAGE_SCENARIOS } from './fixtures/usage' -// The harness fakes the project select, so it never renders UsageFilters and -// would otherwise miss the width its stylesheet sets. -import 'components/pages/usage/components/UsageFilters/UsageFilters.scss' + +// UsageFilters sets this in its stylesheet, which the harness never loads: +// it fakes the project select rather than rendering the real component. +const FILTER_WIDTH = { minWidth: 210 } const PROJECTS = [ 'All Projects', @@ -170,7 +171,7 @@ const UsagePage: FC = ({ hint='Narrow the chart and the breakdown by period or project.' action={ -
+
From bc544c155cdbaa2ac83dcef79d72fea42a10df34 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 4 Sep 2026 09:23:49 -0300 Subject: [PATCH 8/8] refactor(usage): keep the section titles with the rest of the copy Two were still inline while their siblings sat in COPY, which made the block look like it held some of the prose rather than all of it. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/web/components/pages/usage/copy.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/pages/usage/copy.ts b/frontend/web/components/pages/usage/copy.ts index 10a7fb2118a5..402747a32840 100644 --- a/frontend/web/components/pages/usage/copy.ts +++ b/frontend/web/components/pages/usage/copy.ts @@ -29,12 +29,14 @@ const COPY = { 'We are unable to show exact billing periods for your subscription plan.', noPlanLimit: 'This installation has no plan limit.', overLimitTitle: 'Your organisation has exceeded its plan limit', + planTitle: 'Your plan', // Says access, not flags: the API does not expose stop_serving_flags. recovery: 'Upgrading restores access straight away. Otherwise access returns once' + ' your usage has stayed under the limit for 30 days.', restrictedTitle: 'Your organisation is restricted', staysVisible: 'Your usage stays visible below so you can see what happened.', + usageTitle: 'Your usage', } export type BannerContext = { @@ -79,7 +81,7 @@ export const planSectionCopy = ( if (!limit) { return { hint: sentences(`API calls over ${window}.`, COPY.noPlanLimit), - title: 'Your usage', + title: COPY.usageTitle, } } @@ -90,7 +92,7 @@ export const planSectionCopy = ( basis.reason === 'no-period' && COPY.noBillingPeriod, ), - title: 'Your plan', + title: COPY.planTitle, } }