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` diff --git a/frontend/documentation/components/UsageDashboard.stories.tsx b/frontend/documentation/components/UsageDashboard.stories.tsx index 44f2074816b5..17aba57a4bb1 100644 --- a/frontend/documentation/components/UsageDashboard.stories.tsx +++ b/frontend/documentation/components/UsageDashboard.stories.tsx @@ -1,18 +1,27 @@ 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 { - 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, @@ -23,6 +32,10 @@ import { PlanLimit } from 'components/shared/UsageBar/utils' import { Subscription } from 'common/types/responses' import { toUsageResponse, USAGE_SCENARIOS } from './fixtures/usage' +// 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', 'Checkout', @@ -69,6 +82,7 @@ type HarnessProps = { empty?: boolean isLoading?: boolean isError?: boolean + isRestricted?: boolean } /** @@ -79,6 +93,7 @@ const UsagePage: FC = ({ empty, isError, isLoading, + isRestricted, limit, scale = 1, subscription, @@ -99,10 +114,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 +139,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 +234,30 @@ 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, 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, + subscription: subscriptionOf({ plan: 'free' }), + }, +} + export const FreeOnARollingWindow: Story = { args: { limit: 50000, subscription: subscriptionOf({ plan: 'free' }) }, } @@ -203,6 +274,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/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..d0d412b07870 --- /dev/null +++ b/frontend/web/__tests__/routePaths.test.ts @@ -0,0 +1,28 @@ +import { isAllowedWhileBlocked } from 'web/routePaths' + +// App renders wherever this is false, so a wrong answer either +// locks a blocked organisation out, or lets it back in. +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/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..9dedf1e6d24c 100644 --- a/frontend/web/components/pages/usage/UsageDashboardPage.tsx +++ b/frontend/web/components/pages/usage/UsageDashboardPage.tsx @@ -1,18 +1,22 @@ -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 { contributionNote, overLimitNote, planSectionCopy } from './copy' +import { overLimitOf } from './overLimit' import { isBilledOnAPeriod, isBillingPeriodSelected, - contributionNote, - planSectionCopy, + isChargedForOverages, showsContribution, showsPlanCeiling, periodLabel, @@ -21,7 +25,6 @@ import { usageBasisOf, resolvePeriod, } from './utils' -import './UsageDashboardPage.scss' type UsageDashboardPageProps = { organisationId: number | undefined @@ -68,84 +71,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/copy.ts b/frontend/web/components/pages/usage/copy.ts new file mode 100644 index 000000000000..402747a32840 --- /dev/null +++ b/frontend/web/components/pages/usage/copy.ts @@ -0,0 +1,108 @@ +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}` : '' + }.` + +/** 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', + 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 = { + /** 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: over ? sentences(limitReached(over), COPY.recovery) : COPY.askSupport, + title: COPY.restrictedTitle, +}) + +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)}.`, + COPY.staysVisible, + ), + title: COPY.overLimitTitle, +}) + +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: sentences(`API calls over ${window}.`, COPY.noPlanLimit), + title: COPY.usageTitle, + } + } + + return { + hint: sentences( + `Usage against your plan limit over ${window}.`, + basis.window === 'rolling' && + basis.reason === 'no-period' && + COPY.noBillingPeriod, + ), + title: COPY.planTitle, + } +} + +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/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..142f280921f7 --- /dev/null +++ b/frontend/web/components/pages/usage/overLimit.ts @@ -0,0 +1,30 @@ +import { Res } from 'common/types/responses' +import { PlanLimit } from 'components/shared/UsageBar/utils' +import { cumulativeTotals, dailyTotals } from './components/UsageOverTime/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 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..4bb5a8a22f0f 100644 --- a/frontend/web/components/pages/usage/utils.ts +++ b/frontend/web/components/pages/usage/utils.ts @@ -5,10 +5,14 @@ import { rollingPeriodOptions, } from 'common/types/requests' import { Subscription } from 'common/types/responses' -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,32 +33,13 @@ export const usageBasisOf = ( export const isBilledOnAPeriod = (basis: UsageBasis): boolean => basis.window === 'billing-period' -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', - } +// 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 resolvePeriod = ( @@ -70,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. diff --git a/frontend/web/routePaths.ts b/frontend/web/routePaths.ts new file mode 100644 index 000000000000..a635814245f0 --- /dev/null +++ b/frontend/web/routePaths.ts @@ -0,0 +1,15 @@ +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 took the app down once. +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',