diff --git a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx index 39e6d819d..3f6c8909a 100644 --- a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx +++ b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx @@ -23,7 +23,7 @@ import { getDetailedBudgetCsvRows, } from '@/pages/report-output/budgetary-impact/budgetChartUtils'; import type { RootState } from '@/store'; -import { absoluteChangeMessage } from '@/utils/chartMessages'; +import { absoluteChangeMessage, wordWrap } from '@/utils/chartMessages'; import { getClampedChartHeight } from '@/utils/chartUtils'; import { currencySymbol, formatCurrencyAbbr } from '@/utils/formatters'; @@ -37,6 +37,8 @@ interface ProgramBudgetItem { baseline: number; difference: number; reform: number; + /** Whether the program is a tax; absent from outputs built before the simulation API sent it. */ + is_tax?: boolean | null; } /** "−£296.5m": each bar in its own unit, the minus before the currency symbol. */ @@ -46,9 +48,10 @@ function formatBarAmount(value: number, countryId: CountryId): string { } /** - * The benefit programmes in a detailed budget. The simulation API reports - * each programme's own total, so a benefit's change is a change in - * spending, and it costs the budget when it rises. + * The benefit programmes in a detailed budget, for outputs whose rows + * don't say whether each is a tax. The simulation API reports each + * programme's own total, so a benefit's change is a change in spending, + * and it costs the budget when it rises. */ const BENEFIT_PROGRAMS = new Set([ 'universal_credit', @@ -69,6 +72,15 @@ const BENEFIT_PROGRAMS = new Set([ 'ctc', ]); +/** + * Whether a row is a benefit: its own `is_tax` flag when the output has + * one, which covers every programme the model reports, otherwise the + * fixed list. + */ +function isBenefit(key: string, values: ProgramBudgetItem): boolean { + return typeof values.is_tax === 'boolean' ? !values.is_tax : BENEFIT_PROGRAMS.has(key); +} + /** * Whether the benefits' differences are spending changes, to negate into * budget changes. The simulation API sends spending; the legacy API sent @@ -85,7 +97,7 @@ function benefitsAreSpending( if (typeof values?.difference !== 'number') { continue; } - if (BENEFIT_PROGRAMS.has(key)) { + if (isBenefit(key, values)) { benefits += values.difference; } else { taxes += values.difference; @@ -111,10 +123,33 @@ export function getChangedPrograms( .map(([key, values]) => ({ key, label: variableLabels[key]?.label || key, - value: negateBenefits && BENEFIT_PROGRAMS.has(key) ? -values.difference : values.difference, + value: negateBenefits && isBenefit(key, values) ? -values.difference : values.difference, })); } +/** + * The smallest unlisted share of the total that gets its own bar: below it + * the gap is rounding, not a program. + */ +const OTHER_PROGRAMS_MIN_SHARE = 0.001; + +/** + * The part of the total budgetary impact that the listed programs don't + * add up to; 0 when they do. The total sums every tax and benefit in the + * model, but the detailed budget lists only some programs, so a change in + * any other one (such as Scottish Child Payment lost with Universal Credit) + * is in the total with no bar of its own. + */ +export function getOtherProgramsAmount( + budgetaryImpact: number, + programs: Array<{ value: number }> +): number { + const listed = programs.reduce((sum, program) => sum + program.value, 0); + const other = budgetaryImpact - listed; + const threshold = Math.max(1, OTHER_PROGRAMS_MIN_SHARE * Math.abs(budgetaryImpact)); + return Number.isFinite(other) && Math.abs(other) >= threshold ? other : 0; +} + /** * The UK report's budgetary impact by program, as v2 shows it: a * waterfall of each program's change and the total, drawn the flagship @@ -134,8 +169,10 @@ export default function BudgetaryImpactByProgramSubPage({ const budgetaryImpact = output.budget.budgetary_impact; const programs = getChangedPrograms(output, metadata.variables ?? {}); const programWord = countryId === 'uk' ? 'programme' : 'program'; + const otherLabel = `Other ${programWord}s`; + const other = output.detailed_budget ? getOtherProgramsAmount(budgetaryImpact, programs) : 0; - if (programs.length === 0) { + if (programs.length === 0 && other === 0) { return ( @@ -149,6 +186,7 @@ export default function BudgetaryImpactByProgramSubPage({ const items: WaterfallItem[] = [ ...programs.map((program) => ({ name: program.label, value: program.value })), + ...(other !== 0 ? [{ name: otherLabel, value: other }] : []), { name: 'Total', value: budgetaryImpact, isTotal: true }, ]; @@ -157,6 +195,20 @@ export default function BudgetaryImpactByProgramSubPage({ const toAmount = (valueInUnit: number) => valueInUnit * unit.divisor; const hoverMessage = (name: string, amount: number) => { + if (name === otherLabel) { + const change = absoluteChangeMessage( + 'This reform', + `the budgetary impact of ${programWord}s that this chart doesn't list separately`, + amount, + 0, + (v) => formatBillions(v, countryId) + ); + const why = wordWrap( + 'The total includes every tax and benefit in the model, so their changes are in it without a bar of their own.', + 50 + ); + return `${change}.\n${why}`; + } const object = name === 'Total' ? 'the budget deficit' : `the ${name} ${programWord}'s budgetary impact`; return absoluteChangeMessage( diff --git a/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts b/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts index 7be03679a..a984dba18 100644 --- a/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts +++ b/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts @@ -319,3 +319,43 @@ export function withUKBreakdowns(output: SocietyWideReportOutput): SocietyWideRe detailed_budget: UK_DETAILED_BUDGET, } as unknown as SocietyWideReportOutput; } + +/** + * Report 4738's budget (the UK's 2027 Universal Credit freeze): the total, + * £958.5m, includes £25.0m of Scottish Child Payment that the detailed + * budget has no row for, so its rows add up to only £933.5m. + */ +export const UC_FREEZE_TOTAL = 958.5e6; +export const UC_FREEZE_DETAILED_BUDGET = { + universal_credit: { baseline: 82244.8e6, reform: 81311.3e6, difference: -933.5e6 }, + child_benefit: { baseline: 14306.5e6, reform: 14306.5e6, difference: 0 }, +}; + +/** A report's output with report 4738's UK budget and detailed budget. */ +export function withUCFreezeBudget(output: SocietyWideReportOutput): SocietyWideReportOutput { + return { + ...withBudget(output, { federal: 0, state: 0, benefits: -UC_FREEZE_TOTAL }), + detailed_budget: UC_FREEZE_DETAILED_BUDGET, + } as unknown as SocietyWideReportOutput; +} + +/** + * Report 4738's detailed budget once the simulation API sends a row for + * every programme the reform changes, each with its `is_tax` flag: + * Scottish Child Payment, which no fixed list names, is a benefit. + */ +export const UC_FREEZE_FLAGGED_DETAILED_BUDGET = { + universal_credit: { baseline: 82244.8e6, reform: 81311.3e6, difference: -933.5e6, is_tax: false }, + scottish_child_payment: { baseline: 600e6, reform: 575e6, difference: -25e6, is_tax: false }, + income_tax: { baseline: 300e9, reform: 300e9, difference: 0, is_tax: true }, +}; + +/** A report's output with report 4738's budget and its flagged detailed budget. */ +export function withFlaggedUCFreezeBudget( + output: SocietyWideReportOutput +): SocietyWideReportOutput { + return { + ...withBudget(output, { federal: 0, state: 0, benefits: -UC_FREEZE_TOTAL }), + detailed_budget: UC_FREEZE_FLAGGED_DETAILED_BUDGET, + } as unknown as SocietyWideReportOutput; +} diff --git a/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx b/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx index 2420e9b9d..96b8da658 100644 --- a/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx +++ b/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx @@ -3,11 +3,16 @@ import { render, screen } from '@test-utils'; import { describe, expect, test, vi } from 'vitest'; import BudgetaryImpactByProgramSubPage, { getChangedPrograms, + getOtherProgramsAmount, } from '@/components/flagship/report/charts/BudgetaryImpactByProgramSubPage'; import DistributionalImpactIncomeAverageSubPage from '@/components/flagship/report/charts/DistributionalImpactIncomeAverageSubPage'; import WinnersLosersIncomeDecileSubPage from '@/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage'; import { REPORT_4332_OUTPUT, + UC_FREEZE_TOTAL, + withBudget, + withFlaggedUCFreezeBudget, + withUCFreezeBudget, withUKBreakdowns, } from '@/tests/fixtures/components/flagship/report/charts/reportChartMocks'; @@ -56,7 +61,105 @@ describe('getChangedPrograms', () => { }); }); +describe('getChangedPrograms with is_tax flags', () => { + test('given flagged rows then a benefit no list names is signed as spending', () => { + // Given: report 4738 with a Scottish Child Payment row flagged as a benefit. + const output = withFlaggedUCFreezeBudget(REPORT_4332_OUTPUT); + + // When + const programs = getChangedPrograms(output, {}); + + // Then: both benefits fell, so both save money, and they add up to the total. + expect(programs).toEqual([ + { key: 'universal_credit', label: 'universal_credit', value: 933.5e6 }, + { key: 'scottish_child_payment', label: 'scottish_child_payment', value: 25e6 }, + ]); + expect(getOtherProgramsAmount(UC_FREEZE_TOTAL, programs)).toBe(0); + }); + + test('given a row flagged as a tax then its change is signed as revenue', () => { + const output = { + ...withBudget(UK_OUTPUT, { federal: 40e6, state: 0 }), + detailed_budget: { + capital_gains_tax: { baseline: 15e9, reform: 15.04e9, difference: 40e6, is_tax: true }, + }, + } as typeof UK_OUTPUT; + + expect(getChangedPrograms(output, {})).toEqual([ + { key: 'capital_gains_tax', label: 'capital_gains_tax', value: 40e6 }, + ]); + }); +}); + +describe('getOtherProgramsAmount', () => { + test('given programs that add up to the total then there is nothing else', () => { + expect(getOtherProgramsAmount(1.5e9, [{ value: 2e9 }, { value: -0.5e9 }])).toBe(0); + }); + + test('given a total that the programs fall short of then returns the difference', () => { + // Report 4738: £25.0m of Scottish Child Payment has no row of its own. + expect(getOtherProgramsAmount(UC_FREEZE_TOTAL, [{ value: 933.5e6 }])).toBeCloseTo(25e6); + }); + + test('given a difference within rounding then there is nothing else', () => { + expect(getOtherProgramsAmount(958.5e6, [{ value: 958.4e6 }])).toBe(0); + }); + + test('given a total that no listed program explains then returns all of it', () => { + expect(getOtherProgramsAmount(17e6, [])).toBe(17e6); + }); +}); + describe('BudgetaryImpactByProgramSubPage', () => { + test('given a total the programmes fall short of then an other programmes bar makes up the gap', () => { + // Given + const output = withUCFreezeBudget(REPORT_4332_OUTPUT); + + // When + render(, 'uk'); + + // Then + expect(screen.getByText('Other programmes')).toBeInTheDocument(); + expect(screen.getByText('£25.0m')).toBeInTheDocument(); + expect(screen.getByText('£958.5m')).toBeInTheDocument(); + }); + + test('given flagged rows for every programme then no other programmes bar is needed', () => { + render( + , + 'uk' + ); + + expect(screen.queryByText('Other programmes')).not.toBeInTheDocument(); + expect(screen.getByText('£25.0m')).toBeInTheDocument(); + }); + + test('given programmes that add up to the total then there is no other programmes bar', () => { + render(, 'uk'); + + expect(screen.queryByText('Other programmes')).not.toBeInTheDocument(); + }); + + test('given a total with no listed programme changing then the whole total is other programmes', () => { + // Given: a PIP cut, which no listed programme covers. + const output = { + ...withBudget(UK_OUTPUT, { federal: 0, state: 0, benefits: -17e6 }), + detailed_budget: { universal_credit: { baseline: 1, reform: 1, difference: 0 } }, + } as typeof UK_OUTPUT; + + // When + render(, 'uk'); + + // Then + expect(screen.getByText('Other programmes')).toBeInTheDocument(); + expect( + screen.queryByText('This reform has no impact on individual programmes.') + ).not.toBeInTheDocument(); + }); + test('given no detailed budget then says it is not available', () => { render(, 'uk'); @@ -67,7 +170,7 @@ describe('BudgetaryImpactByProgramSubPage', () => { test('given a budget where no program changes then says so', () => { const output = { - ...UK_OUTPUT, + ...withBudget(UK_OUTPUT, { federal: 0, state: 0 }), detailed_budget: { income_tax: { baseline: 1, reform: 1, difference: 0 } }, } as typeof UK_OUTPUT; diff --git a/app/src/utils/chartMessages.ts b/app/src/utils/chartMessages.ts index 091f91068..93ff22d24 100644 --- a/app/src/utils/chartMessages.ts +++ b/app/src/utils/chartMessages.ts @@ -14,7 +14,7 @@ type CountryId = (typeof countryIds)[number]; * Returns newline-separated text. Consumers that render HTML (e.g. Plotly * hover templates) should convert '\n' → '
' at point of use. */ -function wordWrap(text: string, width: number = 50): string { +export function wordWrap(text: string, width: number = 50): string { return wordwrap.wrap(text, { width }); }