From 100a1e181ebca71a258113fdd6099011b911fad6 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 28 Oct 2026 08:54:48 -0400 Subject: [PATCH 1/4] Keep rows outside the total out of the "Other programmes" bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UK total budgetary impact counts the taxes households pay and the benefits they get, so it leaves out employer National Insurance and VAT, and the tax_credits row repeats Working Tax Credit and Child Tax Credit. The "Other programmes" bar treated those rows as part of the total, so an employer NI threshold rise drew employer NI at -£15.5bn and an "Other programmes" bar of +£15.4bn to make up for it. Those rows now stay out of the "Other" sum and the sign check, and stand after the Total bar as standalone bars, from zero and without connectors, named "(not in total)" with a hover note. Co-Authored-By: Claude Opus 5.5 --- app/src/components/charts/waterfallUtils.ts | 22 +++++- .../BudgetaryImpactByProgramSubPage.tsx | 69 +++++++++++++++++-- .../report/charts/reportChartMocks.ts | 21 ++++++ .../components/charts/waterfallUtils.test.ts | 32 +++++++++ .../report/charts/UKBreakdownCharts.test.tsx | 31 +++++++++ 5 files changed, 166 insertions(+), 9 deletions(-) diff --git a/app/src/components/charts/waterfallUtils.ts b/app/src/components/charts/waterfallUtils.ts index a557abb8d..cf882d3e1 100644 --- a/app/src/components/charts/waterfallUtils.ts +++ b/app/src/components/charts/waterfallUtils.ts @@ -17,6 +17,8 @@ export interface WaterfallItem { value: number; /** Whether this item is the total bar (anchored to 0) */ isTotal?: boolean; + /** Whether this item stands apart: anchored to 0, and left out of the running total */ + isStandalone?: boolean; } export interface WaterfallDatum { @@ -36,6 +38,8 @@ export interface WaterfallDatum { label: string; /** Whether this is the total bar */ isTotal: boolean; + /** Whether this bar stands apart from the running total */ + isStandalone?: boolean; } // --------------------------------------------------------------------------- @@ -92,9 +96,19 @@ export function computeWaterfallData( let runningTotal = 0; for (const item of items) { - const { name, value, isTotal = false } = item; + const { name, value, isTotal = false, isStandalone = false } = item; - if (isTotal) { + if (isStandalone) { + const metrics = computeBarMetrics(0, value, value); + result.push({ + name, + value, + ...metrics, + label: formatValue(value), + isTotal: false, + isStandalone: true, + }); + } else if (isTotal) { const metrics = computeBarMetrics(0, runningTotal, runningTotal); result.push({ name, @@ -158,6 +172,10 @@ export function computeWaterfallConnectors(data: WaterfallDatum[]): WaterfallCon const connectors: WaterfallConnector[] = []; for (let i = 0; i < data.length - 1; i++) { const d = data[i]; + // A standalone bar isn't part of the running total, so no line joins it. + if (d.isStandalone || data[i + 1].isStandalone) { + continue; + } const y = d.runningTotalEdge === 'top' ? d.base + d.barHeight : d.base; connectors.push({ y, fromIndex: i, toIndex: i + 1 }); } diff --git a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx index 3f6c8909a..e079fff72 100644 --- a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx +++ b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx @@ -72,6 +72,22 @@ const BENEFIT_PROGRAMS = new Set([ 'ctc', ]); +/** + * Rows the total budgetary impact doesn't include: the total is the change + * in the taxes households pay less the change in their benefits, so it + * leaves out employer National Insurance and VAT, and `tax_credits` repeats + * the Working Tax Credit and Child Tax Credit rows. Their changes are shown + * but not added up against the total. + */ +const OUTSIDE_TOTAL_PROGRAMS = new Set(['ni_employer', 'vat', 'tax_credits']); + +/** Short names for the rows outside the total, for a narrow axis. */ +const OUTSIDE_TOTAL_SHORT_NAMES: Record = { + ni_employer: 'Employer NI', + vat: 'VAT', + tax_credits: 'Tax credits', +}; + /** * 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 @@ -94,7 +110,7 @@ function benefitsAreSpending( let taxes = 0; let benefits = 0; for (const [key, values] of Object.entries(detailedBudget)) { - if (typeof values?.difference !== 'number') { + if (typeof values?.difference !== 'number' || OUTSIDE_TOTAL_PROGRAMS.has(key)) { continue; } if (isBenefit(key, values)) { @@ -127,6 +143,11 @@ export function getChangedPrograms( })); } +/** Whether a row's change is left out of the total budgetary impact. */ +export function isOutsideTotal(key: string | undefined): boolean { + return key !== undefined && OUTSIDE_TOTAL_PROGRAMS.has(key); +} + /** * The smallest unlisted share of the total that gets its own bar: below it * the gap is rounding, not a program. @@ -138,13 +159,16 @@ const OTHER_PROGRAMS_MIN_SHARE = 0.001; * 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. + * is in the total with no bar of its own. Rows outside the total don't + * count towards it. */ export function getOtherProgramsAmount( budgetaryImpact: number, - programs: Array<{ value: number }> + programs: Array<{ key?: string; value: number }> ): number { - const listed = programs.reduce((sum, program) => sum + program.value, 0); + const listed = programs + .filter((program) => !isOutsideTotal(program.key)) + .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; @@ -171,6 +195,12 @@ export default function BudgetaryImpactByProgramSubPage({ const programWord = countryId === 'uk' ? 'programme' : 'program'; const otherLabel = `Other ${programWord}s`; const other = output.detailed_budget ? getOtherProgramsAmount(budgetaryImpact, programs) : 0; + // Rows the total leaves out, by bar name: they stand after the total. + const outsideTotal = new Map( + programs + .filter((program) => isOutsideTotal(program.key)) + .map((program) => [`${program.label} (not in total)`, program]) + ); if (programs.length === 0 && other === 0) { return ( @@ -184,10 +214,19 @@ export default function BudgetaryImpactByProgramSubPage({ ); } + // Rows the total leaves out stand after it, each from zero, so the bars + // before it still add up to the total. const items: WaterfallItem[] = [ - ...programs.map((program) => ({ name: program.label, value: program.value })), + ...programs + .filter((program) => !isOutsideTotal(program.key)) + .map((program) => ({ name: program.label, value: program.value })), ...(other !== 0 ? [{ name: otherLabel, value: other }] : []), { name: 'Total', value: budgetaryImpact, isTotal: true }, + ...[...outsideTotal].map(([name, program]) => ({ + name, + value: program.value, + isStandalone: true, + })), ]; // The unit comes from the bars' full extents, running totals included. @@ -209,15 +248,24 @@ export default function BudgetaryImpactByProgramSubPage({ ); return `${change}.\n${why}`; } + const label = outsideTotal.get(name)?.label ?? name; const object = - name === 'Total' ? 'the budget deficit' : `the ${name} ${programWord}'s budgetary impact`; - return absoluteChangeMessage( + name === 'Total' ? 'the budget deficit' : `the ${label} ${programWord}'s budgetary impact`; + const change = absoluteChangeMessage( 'This reform', object, name === 'Total' ? -amount : amount, 0, (v) => formatBillions(v, countryId) ); + if (!outsideTotal.has(name)) { + return change; + } + const why = wordWrap( + "The total counts the taxes households pay and the benefits they get, so this change isn't in it.", + 50 + ); + return `${change}.\n${why}`; }; const data = computeWaterfallData( @@ -232,6 +280,13 @@ export default function BudgetaryImpactByProgramSubPage({ const unitName = `${symbol}${unit.suffix}`; const chartProps = { data, + // A narrow axis shows a bar outside the total by its short name. + shortNames: Object.fromEntries( + [...outsideTotal].map(([name, program]) => [ + name, + `${OUTSIDE_TOTAL_SHORT_NAMES[program.key] ?? program.label} (not in total)`, + ]) + ), yAxisTitles: [`Budgetary impact (${unitName})`, `Impact (${unitName})`], formatTick: (value: number, decimals: number) => formatUnitAmount(value, unit, symbol, decimals), 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 a984dba18..df3585f38 100644 --- a/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts +++ b/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts @@ -359,3 +359,24 @@ export function withFlaggedUCFreezeBudget( detailed_budget: UC_FREEZE_FLAGGED_DETAILED_BUDGET, } as unknown as SocietyWideReportOutput; } + +/** + * An employer NI threshold rise from £5,000 to £9,100 (2027-28): employers + * pay £15.5bn less employer NI, which the total leaves out, and pass it on + * as pay, so income tax, NI and Universal Credit add up to the £5.3bn total. + */ +export const EMPLOYER_NI_TOTAL = 5.3e9; +export const EMPLOYER_NI_DETAILED_BUDGET = { + income_tax: { baseline: 300e9, reform: 304e9, difference: 4e9 }, + national_insurance: { baseline: 150e9, reform: 150.9e9, difference: 0.9e9 }, + universal_credit: { baseline: 82e9, reform: 81.6e9, difference: -0.4e9 }, + ni_employer: { baseline: 120e9, reform: 104.5e9, difference: -15.5e9 }, +}; + +/** A report's output with the employer NI threshold rise's budget. */ +export function withEmployerNIBudget(output: SocietyWideReportOutput): SocietyWideReportOutput { + return { + ...withBudget(output, { federal: 4.9e9, state: 0, benefits: -0.4e9 }), + detailed_budget: EMPLOYER_NI_DETAILED_BUDGET, + } as unknown as SocietyWideReportOutput; +} diff --git a/app/src/tests/unit/components/charts/waterfallUtils.test.ts b/app/src/tests/unit/components/charts/waterfallUtils.test.ts index c3da31763..24462af77 100644 --- a/app/src/tests/unit/components/charts/waterfallUtils.test.ts +++ b/app/src/tests/unit/components/charts/waterfallUtils.test.ts @@ -11,6 +11,25 @@ import { const fmt = (v: number) => `$${v}`; describe('computeWaterfallData', () => { + it('given a standalone item then anchors it to 0 and leaves the running total alone', () => { + const items: WaterfallItem[] = [ + { name: 'A', value: 10 }, + { name: 'Total', value: 0, isTotal: true }, + { name: 'Apart', value: -4, isStandalone: true }, + ]; + + const result = computeWaterfallData(items, fmt); + + expect(result[1]).toMatchObject({ value: 10, isTotal: true }); + expect(result[2]).toMatchObject({ + base: -4, + value: -4, + range: [-4, 0], + isTotal: false, + isStandalone: true, + }); + }); + it('stacks all-positive values upward from 0', () => { const items: WaterfallItem[] = [ { name: 'A', value: 10 }, @@ -302,6 +321,19 @@ describe('getWaterfallDomain', () => { }); describe('computeWaterfallConnectors', () => { + it('given a standalone bar then no connector joins it', () => { + const data = computeWaterfallData( + [ + { name: 'A', value: 10 }, + { name: 'Total', value: 0, isTotal: true }, + { name: 'Apart', value: -4, isStandalone: true }, + ], + fmt + ); + + expect(computeWaterfallConnectors(data)).toEqual([{ y: 10, fromIndex: 0, toIndex: 1 }]); + }); + it('connects all-positive bars at running totals', () => { const data = computeWaterfallData( [ 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 96b8da658..8bafce8c5 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 @@ -8,9 +8,11 @@ import BudgetaryImpactByProgramSubPage, { import DistributionalImpactIncomeAverageSubPage from '@/components/flagship/report/charts/DistributionalImpactIncomeAverageSubPage'; import WinnersLosersIncomeDecileSubPage from '@/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage'; import { + EMPLOYER_NI_TOTAL, REPORT_4332_OUTPUT, UC_FREEZE_TOTAL, withBudget, + withEmployerNIBudget, withFlaggedUCFreezeBudget, withUCFreezeBudget, withUKBreakdowns, @@ -108,6 +110,17 @@ describe('getOtherProgramsAmount', () => { test('given a total that no listed program explains then returns all of it', () => { expect(getOtherProgramsAmount(17e6, [])).toBe(17e6); }); + + test('given a row the total leaves out then it does not count towards the total', () => { + // Given: the employer NI threshold rise, whose £15.5bn of employer NI the total leaves out. + const programs = getChangedPrograms(withEmployerNIBudget(REPORT_4332_OUTPUT), {}); + + // When + const other = getOtherProgramsAmount(EMPLOYER_NI_TOTAL, programs); + + // Then: the other rows add up to the total, so there is nothing else. + expect(other).toBe(0); + }); }); describe('BudgetaryImpactByProgramSubPage', () => { @@ -137,6 +150,24 @@ describe('BudgetaryImpactByProgramSubPage', () => { expect(screen.getByText('£25.0m')).toBeInTheDocument(); }); + test('given a row the total leaves out then no other programmes bar offsets it', () => { + // Given + const output = withEmployerNIBudget(REPORT_4332_OUTPUT); + + // When + const { container } = render( + , + 'uk' + ); + + // Then: the bars before the total add up to it, and employer NI stands after it. + expect(screen.queryByText('Other programmes')).not.toBeInTheDocument(); + expect(screen.getByText('£5.3bn')).toBeInTheDocument(); + // The axis label wraps over two lines. + expect(container).toHaveTextContent(/Total.*Employer NI \(not\s*in total\)/); + expect(screen.getByText('−£15.5bn')).toBeInTheDocument(); + }); + test('given programmes that add up to the total then there is no other programmes bar', () => { render(, 'uk'); From dd0188cd9ccbbd485ad0d931348f78b5924ac504 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 28 Oct 2026 09:06:45 -0400 Subject: [PATCH 2/4] Drop the repeated tax credits row instead of marking it outside the total tax_credits is Working Tax Credit and Child Tax Credit together, and the total counts both through their own rows, so it isn't outside the total: it repeats them. The chart now drops it where either row is listed, and counts it as a normal row in outputs that have only the combined row. Co-Authored-By: Claude Opus 5.5 --- .../BudgetaryImpactByProgramSubPage.tsx | 31 ++++++++++++---- .../report/charts/UKBreakdownCharts.test.tsx | 36 +++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx index e079fff72..474226594 100644 --- a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx +++ b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx @@ -75,19 +75,29 @@ const BENEFIT_PROGRAMS = new Set([ /** * Rows the total budgetary impact doesn't include: the total is the change * in the taxes households pay less the change in their benefits, so it - * leaves out employer National Insurance and VAT, and `tax_credits` repeats - * the Working Tax Credit and Child Tax Credit rows. Their changes are shown + * leaves out employer National Insurance and VAT. Their changes are shown * but not added up against the total. */ -const OUTSIDE_TOTAL_PROGRAMS = new Set(['ni_employer', 'vat', 'tax_credits']); +const OUTSIDE_TOTAL_PROGRAMS = new Set(['ni_employer', 'vat']); /** Short names for the rows outside the total, for a narrow axis. */ const OUTSIDE_TOTAL_SHORT_NAMES: Record = { ni_employer: 'Employer NI', vat: 'VAT', - tax_credits: 'Tax credits', }; +/** + * Whether a row repeats others: `tax_credits` is Working Tax Credit and + * Child Tax Credit together, which the total counts through their own rows, + * so it is dropped where either of them is listed. + */ +function isRepeatedRow(key: string, detailedBudget: Record): boolean { + return ( + key === 'tax_credits' && + ('working_tax_credit' in detailedBudget || 'child_tax_credit' in detailedBudget) + ); +} + /** * 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 @@ -110,7 +120,11 @@ function benefitsAreSpending( let taxes = 0; let benefits = 0; for (const [key, values] of Object.entries(detailedBudget)) { - if (typeof values?.difference !== 'number' || OUTSIDE_TOTAL_PROGRAMS.has(key)) { + if ( + typeof values?.difference !== 'number' || + OUTSIDE_TOTAL_PROGRAMS.has(key) || + isRepeatedRow(key, detailedBudget) + ) { continue; } if (isBenefit(key, values)) { @@ -135,7 +149,12 @@ export function getChangedPrograms( const detailedBudget = (output.detailed_budget ?? {}) as Record; const negateBenefits = benefitsAreSpending(detailedBudget, output.budget?.budgetary_impact); return Object.entries(detailedBudget) - .filter(([, values]) => typeof values?.difference === 'number' && values.difference !== 0) + .filter( + ([key, values]) => + typeof values?.difference === 'number' && + values.difference !== 0 && + !isRepeatedRow(key, detailedBudget) + ) .map(([key, values]) => ({ key, label: variableLabels[key]?.label || key, 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 8bafce8c5..2d6b59071 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 @@ -93,6 +93,42 @@ describe('getChangedPrograms with is_tax flags', () => { }); }); +describe('getChangedPrograms with the tax credits row', () => { + test('given tax credits with their own rows then the combined row is dropped', () => { + // Given: tax_credits is Working Tax Credit and Child Tax Credit together. + const output = { + ...withBudget(UK_OUTPUT, { federal: 0, state: 0, benefits: -300e6 }), + detailed_budget: { + working_tax_credit: { baseline: 1e9, reform: 0.9e9, difference: -100e6 }, + child_tax_credit: { baseline: 2e9, reform: 1.8e9, difference: -200e6 }, + tax_credits: { baseline: 3e9, reform: 2.7e9, difference: -300e6 }, + }, + } as typeof UK_OUTPUT; + + // When + const programs = getChangedPrograms(output, {}); + + // Then: the two rows add up to the total with nothing counted twice. + expect(programs.map((program) => program.key)).toEqual([ + 'working_tax_credit', + 'child_tax_credit', + ]); + expect(getOtherProgramsAmount(300e6, programs)).toBe(0); + }); + + test('given only the combined tax credits row then it counts towards the total', () => { + const output = { + ...withBudget(UK_OUTPUT, { federal: 0, state: 0, benefits: -300e6 }), + detailed_budget: { tax_credits: { baseline: 3e9, reform: 2.7e9, difference: -300e6 } }, + } as typeof UK_OUTPUT; + + const programs = getChangedPrograms(output, {}); + + expect(programs).toEqual([{ key: 'tax_credits', label: 'tax_credits', value: 300e6 }]); + expect(getOtherProgramsAmount(300e6, programs)).toBe(0); + }); +}); + 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); From ea6580f7483b1c89a8ed483c3ca9abe8e8556056 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 28 Oct 2026 09:14:09 -0400 Subject: [PATCH 3/4] Count VAT in the total; only employer NI stands outside it The tax total counts vat_change, VAT less VAT at current law's rates, so a VAT change is in the total: vat changes by exactly as much as vat_change. VAT is now an ordinary row, dropped only where a vat_change row repeats it, as tax_credits is next to its parts. Employer NI is the one row the total leaves out. Co-Authored-By: Claude Opus 5.5 --- .../BudgetaryImpactByProgramSubPage.tsx | 28 +++++++++------- .../report/charts/UKBreakdownCharts.test.tsx | 32 +++++++++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx index 474226594..003040dcf 100644 --- a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx +++ b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx @@ -74,28 +74,32 @@ const BENEFIT_PROGRAMS = new Set([ /** * Rows the total budgetary impact doesn't include: the total is the change - * in the taxes households pay less the change in their benefits, so it - * leaves out employer National Insurance and VAT. Their changes are shown - * but not added up against the total. + * in the taxes households pay less the change in their benefits, and + * employers pay employer National Insurance. Their changes are shown but + * not added up against the total. */ -const OUTSIDE_TOTAL_PROGRAMS = new Set(['ni_employer', 'vat']); +const OUTSIDE_TOTAL_PROGRAMS = new Set(['ni_employer']); /** Short names for the rows outside the total, for a narrow axis. */ const OUTSIDE_TOTAL_SHORT_NAMES: Record = { ni_employer: 'Employer NI', - vat: 'VAT', }; /** - * Whether a row repeats others: `tax_credits` is Working Tax Credit and - * Child Tax Credit together, which the total counts through their own rows, - * so it is dropped where either of them is listed. + * Rows that repeat others, with the rows they repeat: `tax_credits` is + * Working Tax Credit and Child Tax Credit together, and `vat` changes by + * as much as `vat_change`, the VAT above current law's that the total + * counts. Each is dropped where a row it repeats is listed, and is an + * ordinary row otherwise. */ +const REPEATED_ROWS: Record = { + tax_credits: ['working_tax_credit', 'child_tax_credit'], + vat: ['vat_change'], +}; + +/** Whether a row repeats others the detailed budget lists. */ function isRepeatedRow(key: string, detailedBudget: Record): boolean { - return ( - key === 'tax_credits' && - ('working_tax_credit' in detailedBudget || 'child_tax_credit' in detailedBudget) - ); + return (REPEATED_ROWS[key] ?? []).some((repeated) => repeated in detailedBudget); } /** 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 2d6b59071..717b6b093 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 @@ -129,6 +129,38 @@ describe('getChangedPrograms with the tax credits row', () => { }); }); +describe('getChangedPrograms with the VAT row', () => { + test('given a VAT change and no vat_change row then VAT counts towards the total', () => { + // Given: the total counts VAT through vat_change, which changes by as much as VAT. + const output = { + ...withBudget(UK_OUTPUT, { federal: 2e9, state: 0 }), + detailed_budget: { vat: { baseline: 170e9, reform: 172e9, difference: 2e9 } }, + } as typeof UK_OUTPUT; + + // When + const programs = getChangedPrograms(output, {}); + + // Then + expect(programs.map((program) => program.key)).toEqual(['vat']); + expect(getOtherProgramsAmount(2e9, programs)).toBe(0); + }); + + test('given both VAT rows then the vat row is dropped', () => { + const output = { + ...withBudget(UK_OUTPUT, { federal: 2e9, state: 0 }), + detailed_budget: { + vat: { baseline: 170e9, reform: 172e9, difference: 2e9, is_tax: true }, + vat_change: { baseline: 0, reform: 2e9, difference: 2e9, is_tax: true }, + }, + } as typeof UK_OUTPUT; + + const programs = getChangedPrograms(output, {}); + + expect(programs.map((program) => program.key)).toEqual(['vat_change']); + expect(getOtherProgramsAmount(2e9, programs)).toBe(0); + }); +}); + 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); From e93df9f2ffea8aa95182099d2ab4a1e01af09565 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 28 Oct 2026 09:31:38 -0400 Subject: [PATCH 4/4] Keep the tax credits entitlement row outside the total tax_credits is Working Tax Credit and Child Tax Credit entitlement before take-up; the total counts the claimed amounts through working_tax_credit and child_tax_credit. It is still dropped where either of those rows is listed, and otherwise stands after the total as "(not in total)" rather than counting towards it. Each row outside the total now has its own reason in the hover text. Co-Authored-By: Claude Opus 5.5 --- .../BudgetaryImpactByProgramSubPage.tsx | 36 ++++++++++--------- .../report/charts/UKBreakdownCharts.test.tsx | 10 ++++-- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx index 003040dcf..7d7db86b9 100644 --- a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx +++ b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx @@ -73,24 +73,28 @@ const BENEFIT_PROGRAMS = new Set([ ]); /** - * Rows the total budgetary impact doesn't include: the total is the change - * in the taxes households pay less the change in their benefits, and - * employers pay employer National Insurance. Their changes are shown but - * not added up against the total. + * Rows the total budgetary impact doesn't include, with why, to follow + * "Not in the total:" in a bar's hover text. The total is + * the change in the taxes households pay less the change in the benefits + * they get. Their changes are shown but not added up against the total. */ -const OUTSIDE_TOTAL_PROGRAMS = new Set(['ni_employer']); +const OUTSIDE_TOTAL_REASONS: Record = { + ni_employer: 'employers pay it, so the total of household taxes and benefits leaves it out.', + tax_credits: + 'it is what families are entitled to; the total counts the Working Tax Credit and Child Tax Credit they claim.', +}; /** Short names for the rows outside the total, for a narrow axis. */ const OUTSIDE_TOTAL_SHORT_NAMES: Record = { ni_employer: 'Employer NI', + tax_credits: 'Tax credits', }; /** - * Rows that repeat others, with the rows they repeat: `tax_credits` is - * Working Tax Credit and Child Tax Credit together, and `vat` changes by - * as much as `vat_change`, the VAT above current law's that the total - * counts. Each is dropped where a row it repeats is listed, and is an - * ordinary row otherwise. + * Rows dropped where rows that cover the same money are listed: + * `tax_credits` is the entitlement behind Working Tax Credit and Child Tax + * Credit, and `vat` changes by as much as `vat_change`, the VAT above + * current law's that the total counts. */ const REPEATED_ROWS: Record = { tax_credits: ['working_tax_credit', 'child_tax_credit'], @@ -126,7 +130,7 @@ function benefitsAreSpending( for (const [key, values] of Object.entries(detailedBudget)) { if ( typeof values?.difference !== 'number' || - OUTSIDE_TOTAL_PROGRAMS.has(key) || + key in OUTSIDE_TOTAL_REASONS || isRepeatedRow(key, detailedBudget) ) { continue; @@ -168,7 +172,7 @@ export function getChangedPrograms( /** Whether a row's change is left out of the total budgetary impact. */ export function isOutsideTotal(key: string | undefined): boolean { - return key !== undefined && OUTSIDE_TOTAL_PROGRAMS.has(key); + return key !== undefined && key in OUTSIDE_TOTAL_REASONS; } /** @@ -281,13 +285,11 @@ export default function BudgetaryImpactByProgramSubPage({ 0, (v) => formatBillions(v, countryId) ); - if (!outsideTotal.has(name)) { + const outside = outsideTotal.get(name); + if (!outside) { return change; } - const why = wordWrap( - "The total counts the taxes households pay and the benefits they get, so this change isn't in it.", - 50 - ); + const why = wordWrap(`Not in the total: ${OUTSIDE_TOTAL_REASONS[outside.key]}`, 50); return `${change}.\n${why}`; }; 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 717b6b093..ea707ac03 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 @@ -116,16 +116,20 @@ describe('getChangedPrograms with the tax credits row', () => { expect(getOtherProgramsAmount(300e6, programs)).toBe(0); }); - test('given only the combined tax credits row then it counts towards the total', () => { + test('given only the tax credits entitlement row then it does not count towards the total', () => { + // Given: tax_credits is entitlement before take-up; the total counts the + // claimed Working Tax Credit and Child Tax Credit, which have no rows here. const output = { - ...withBudget(UK_OUTPUT, { federal: 0, state: 0, benefits: -300e6 }), + ...withBudget(UK_OUTPUT, { federal: 0, state: 0, benefits: -250e6 }), detailed_budget: { tax_credits: { baseline: 3e9, reform: 2.7e9, difference: -300e6 } }, } as typeof UK_OUTPUT; + // When const programs = getChangedPrograms(output, {}); + // Then: the claimed amounts are the other programmes. expect(programs).toEqual([{ key: 'tax_credits', label: 'tax_credits', value: 300e6 }]); - expect(getOtherProgramsAmount(300e6, programs)).toBe(0); + expect(getOtherProgramsAmount(250e6, programs)).toBe(250e6); }); });