diff --git a/frontend/package.json b/frontend/package.json index 0ec3e89..03873ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", - "fixtures": "bun scripts/write-wizard-fixtures.ts" + "fixtures": "bun scripts/write-wizard-fixtures.ts", + "faq-numbers": "bun scripts/write-faq-numbers.ts" }, "dependencies": { "@policyengine/ui-kit": "^0.12.0", diff --git a/frontend/scripts/write-faq-numbers.ts b/frontend/scripts/write-faq-numbers.ts new file mode 100644 index 0000000..8c18bd7 --- /dev/null +++ b/frontend/scripts/write-faq-numbers.ts @@ -0,0 +1,75 @@ +/** + * Run the FAQ's example households through the GiveCalc API and write the + * answers to src/lib/faqNumbers.json, which the FAQ reads: + * + * bun run faq-numbers # the production API + * GIVECALC_API_URL=http://localhost:8000 bun run faq-numbers + * + * Each request is the one the calculator sends for that household + * (src/lib/faqCases.ts). The file records the API and the model versions + * modal_app.py pins, which the production API runs once main deploys. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { toCalculateRequest } from "../src/lib/household"; +import { + UK_FAQ_CASES, + US_BUNCHING_CASES, + US_FAQ_CASES, + type UKFaqCase, + type USFaqCase, +} from "../src/lib/faqCases"; + +const PRODUCTION_API = "https://policyengine--givecalc-fastapi-app.modal.run"; +const api = process.env.GIVECALC_API_URL || PRODUCTION_API; +// api.ts reads its URL when it loads. +process.env.NEXT_PUBLIC_API_URL = api; +const { calculateSavings, calculateUKSavings, ukApiRequest } = await import( + "../src/lib/api" +); + +function pinnedVersion(name: string): string { + const modalApp = readFileSync( + fileURLToPath(new URL("../../modal_app.py", import.meta.url)), + "utf8", + ); + const match = new RegExp(`"${name}==([^"]+)"`).exec(modalApp); + if (!match) throw new Error(`modal_app.py does not pin ${name}`); + return match[1]; +} + +// A cold API container takes about a minute; wake it before timing out. +await fetch(`${api}/api/health`, { signal: AbortSignal.timeout(300_000) }); + +const us: Record = {}; +for (const name of Object.keys(US_FAQ_CASES) as USFaqCase[]) { + const request = { + ...toCalculateRequest(US_FAQ_CASES[name]), + ...(US_BUNCHING_CASES.has(name) ? { bunching: true } : {}), + }; + const response = await calculateSavings(request); + us[name] = { request, response }; + console.log(`${name}: ${JSON.stringify(response)}`); +} + +const uk: Record = {}; +for (const name of Object.keys(UK_FAQ_CASES) as UKFaqCase[]) { + const request = ukApiRequest(UK_FAQ_CASES[name]); + const response = await calculateUKSavings(request); + uk[name] = { request, response }; + console.log(`${name}: ${JSON.stringify(response)}`); +} + +const out = fileURLToPath(new URL("../src/lib/faqNumbers.json", import.meta.url)); +const numbers = { + api, + generated: new Date().toISOString().slice(0, 10), + models: { + "policyengine-us": pinnedVersion("policyengine-us"), + "policyengine-uk": pinnedVersion("policyengine-uk"), + }, + us, + uk, +}; +writeFileSync(out, `${JSON.stringify(numbers, null, 2)}\n`); +console.log(`Wrote ${out}`); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eb4012c..20a690f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import InputForm from "./components/InputForm"; import UKInputForm from "./components/UKInputForm"; import Results from "./components/Results"; import TaxInfo from "./components/TaxInfo"; +import Faq from "./components/faq/Faq"; import { useStates, useCalculateDonation, @@ -532,6 +533,7 @@ function Calculator() { } />
{taxInfo}
+ diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 76c465e..c5374ca 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -17,7 +17,7 @@ export default function Header() { />
-
+
{/* Logo with refined typography */}

@@ -43,10 +43,18 @@ export default function Header() {

- {/* Tagline with subtle animation */} -

- Calculate how charitable giving affects your taxes -

+
+ {/* Tagline with subtle animation */} +

+ Calculate how charitable giving affects your taxes +

+ + Questions + +
diff --git a/frontend/src/components/__tests__/Faq.test.tsx b/frontend/src/components/__tests__/Faq.test.tsx new file mode 100644 index 0000000..998a492 --- /dev/null +++ b/frontend/src/components/__tests__/Faq.test.tsx @@ -0,0 +1,170 @@ +/** + * The FAQ: what it renders, and that its numbers are the API's. + * + * The figures come from faqNumbers.json, written from production API runs + * by `bun run faq-numbers`; lib/__tests__/faqNumbers.test.ts checks that + * file describes the households the answers name. + */ + +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import Faq from "../faq/Faq"; +import { FAQ_ITEMS, exampleHref } from "../faq/questions"; +import { costHeadline } from "../Results"; +import { US_FAQ_CASES } from "../../lib/faqCases"; +import numbers from "../../lib/faqNumbers.json"; +import { toSearchParams } from "../../lib/urlState"; + +function renderFaq() { + render(); + return screen.getByRole("region", { name: "Questions" }); +} + +/** The answer text under a question, read from the DOM. */ +function answer(question: string): string { + const summary = screen.getByText(question).closest("summary"); + const details = summary?.parentElement; + if (!details) throw new Error(`No question "${question}"`); + return (details.textContent ?? "").slice(question.length); +} + +describe("FAQ", () => { + it("renders every question as a closed disclosure", () => { + const region = renderFaq(); + const disclosures = region.querySelectorAll("details"); + expect(disclosures).toHaveLength(FAQ_ITEMS.length); + expect(FAQ_ITEMS.length).toBeGreaterThanOrEqual(6); + expect(FAQ_ITEMS.length).toBeLessThanOrEqual(9); + disclosures.forEach((details, index) => { + expect(details.open).toBe(false); + const summary = details.querySelector("summary"); + expect(summary?.textContent).toBe(FAQ_ITEMS[index].question); + }); + }); + + it("opens an answer when its question is activated", async () => { + renderFaq(); + const question = "How does the bunching check work?"; + const details = screen.getByText(question).closest("details"); + expect(details?.open).toBe(false); + await userEvent.click(screen.getByText(question)); + expect(details?.open).toBe(true); + }); + + it("has unique ids for its questions", () => { + const ids = FAQ_ITEMS.map((item) => item.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("uses sentence case, no emoji and no pledge wording", () => { + const region = renderFaq(); + const text = region.textContent ?? ""; + expect(text).not.toMatch(/pledge/i); + expect(text).not.toMatch(/\p{Extended_Pictographic}/u); + // Words after the first that may be capitalized: names and quoted labels. + const proper = new Set(["GiveCalc", "Gift", "Aid", "Share"]); + for (const { question } of FAQ_ITEMS) { + const words = question.replace(/[^\p{L}\d\s-]/gu, "").split(/\s+/); + expect(words[0][0]).toMatch(/\p{Lu}|\d/u); + for (const word of words.slice(1)) { + if (!proper.has(word)) expect(word, question).not.toMatch(/^\p{Lu}/u); + } + } + }); + + it("quotes the API's California figures", () => { + renderFaq(); + expect(answer("Why did my gift save little or nothing?")).toContain( + "A single 30-year-old in California earning $75,000 saves $220 by giving $1,000 in 2026, and the same $220 by giving $5,000. In 2025, the $1,000 gift saved nothing.", + ); + expect(numbers.us.california.response.tax_savings).toBe(220); + expect(numbers.us.californiaLarger.response.tax_savings).toBe(220); + expect(numbers.us.california2025.response.tax_savings).toBe(0); + }); + + it("quotes the API's top-bracket figures", () => { + renderFaq(); + const text = answer("What changed for 2026?"); + expect(text).toContain( + "Giving $110,000 in 2026 saves $3,500 more than giving $100,000: 35 cents for each extra dollar.", + ); + const extra = + numbers.us.texasMore.response.tax_savings - + numbers.us.texas.response.tax_savings; + expect(Math.round(extra)).toBe(3_500); + expect(Math.round((100 * extra) / 10_000)).toBe(35); + }); + + it("quotes the API's bunching figures", () => { + renderFaq(); + expect(answer("How does the bunching check work?")).toContain( + "Giving $30,000 in 2026 and nothing in 2027 saves $2,820 more in tax than giving $15,000 in each year.", + ); + expect(Math.round(numbers.us.newYork.response.bunching.bunching_gain)).toBe( + 2_820, + ); + }); + + it("quotes the API's Gift Aid figures, as the results card does", () => { + renderFaq(); + const text = answer("How does Gift Aid work in GiveCalc?"); + expect(text).toContain( + "25p for every £1, so a £1,000 gift gives the charity £1,250.", + ); + expect(text).toContain( + "earning £70,000 who gives £1,000 in 2026/27 saves £250. One earning £35,000 saves nothing", + ); + expect(numbers.uk.higherRate.response.tax_savings).toBe(250); + expect(numbers.uk.basicRate.response.tax_savings).toBe(0); + // The charity's £1,250 is the figure the results headline gives. + const { headline } = costHeadline( + { ...numbers.uk.higherRate.response, donation_amount: 1_000 }, + "GBP", + 2026, + ); + expect(headline).toContain("worth £1,250 to a charity"); + }); + + it("quotes the rules it states", () => { + renderFaq(); + const text = answer("What changed for 2026?"); + expect(text).toContain("up to $1,000 of cash gifts, or $2,000 on a joint return."); + expect(text).toContain("above 0.5% of their adjusted gross income"); + expect(text).toContain( + "reaches the top 37% bracket give up 2/37 of their itemized deductions, up to 2/37 of their income in that bracket.", + ); + expect(answer("How does Gift Aid work in GiveCalc?")).toContain( + "tapers the personal allowance above £100,000", + ); + expect(answer("How does Gift Aid work in GiveCalc?")).toContain( + "where relief starts at the 21% intermediate rate", + ); + }); + + it("links each US example to the calculator while it offers the year", () => { + const region = renderFaq(); + // Links show only while the calculator offers the example's year. + const links = within(region).queryAllByRole("link", { name: /example/ }); + for (const link of links) { + expect(link.getAttribute("href")).toMatch(/^\?amount=\d+&income=\d+&state=[A-Z]{2}&/); + } + const form = US_FAQ_CASES.california; + expect(exampleHref(form, [2025, 2026, 2027])).toBe(`?${toSearchParams(form)}`); + expect(exampleHref(form, [2027, 2028, 2029])).toBeNull(); + }); + + it("links to the public code", () => { + const region = renderFaq(); + const hrefs = within(region) + .getAllByRole("link") + .map((link) => link.getAttribute("href")); + expect(hrefs).toEqual( + expect.arrayContaining([ + "https://github.com/PolicyEngine/givecalc", + "https://github.com/PolicyEngine/policyengine-us", + "https://github.com/PolicyEngine/policyengine-uk", + ]), + ); + }); +}); diff --git a/frontend/src/components/faq/Faq.tsx b/frontend/src/components/faq/Faq.tsx new file mode 100644 index 0000000..f33ea26 --- /dev/null +++ b/frontend/src/components/faq/Faq.tsx @@ -0,0 +1,52 @@ +/** + * Questions and answers below the calculator. Each answer opens in place, + * with native
elements, so keyboards and screen readers get a + * disclosure button for every question. + */ + +import { FAQ_ITEMS } from "./questions"; + +export const FAQ_HEADING_ID = "questions"; + +export default function Faq() { + return ( +
+

+ Questions +

+
+ {FAQ_ITEMS.map(({ id, question, answer }) => ( +
+ + {question} + + +
+ {answer} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/faq/questions.tsx b/frontend/src/components/faq/questions.tsx new file mode 100644 index 0000000..e48567b --- /dev/null +++ b/frontend/src/components/faq/questions.tsx @@ -0,0 +1,318 @@ +/** + * The FAQ's questions and answers. Every figure comes from faqFigures.ts: + * results from API runs of the households in faqCases.ts, and rules that + * tests/test_faq_numbers.py checks against the PolicyEngine parameters. + */ + +import type { ReactNode } from "react"; +import { bunchingSentence } from "../Results"; +import { FAQ_FIGURES, FAQ_RULES } from "../../lib/faqFigures"; +import { US_FAQ_CASES } from "../../lib/faqCases"; +import { formatCurrency, formatPercent } from "../../lib/format"; +import { TAX_YEAR_OPTIONS, type FormState } from "../../lib/types"; +import { toSearchParams } from "../../lib/urlState"; + +export interface FaqItem { + id: string; + question: string; + answer: ReactNode; +} + +const usd = (value: number) => formatCurrency(value); +const gbp = (value: number) => formatCurrency(value, "GBP"); + +/** A link that opens the example in the calculator, while it offers its year. */ +export function exampleHref( + form: FormState, + offeredYears: readonly number[] = TAX_YEAR_OPTIONS, +): string | null { + return offeredYears.includes(form.household.year) + ? `?${toSearchParams(form)}` + : null; +} + +function ExampleLink({ form, place }: { form: FormState; place: string }) { + const href = exampleHref(form); + if (!href) return null; + return ( + <> + {" "} + + Open the {place} example + + + ); +} + +function Link({ href, children }: { href: string; children: ReactNode }) { + return ( + + {children} + (opens in a new tab) + + ); +} + +const { california, newYork, texas, giftAid } = FAQ_FIGURES; +const { us, uk } = FAQ_RULES; +const singleCap = usd(us.non_itemizer_cap.single); +const jointCap = usd(us.non_itemizer_cap.joint); +const limit = `${us.top_bracket_limit.numerator}/${us.top_bracket_limit.denominator}`; +const centsPerExtraDollar = Math.round( + (100 * texas.extraSavings) / (texas.largerGift - texas.gift), +); +const toCharity = giftAid.gift / (1 - uk.basic_rate); +const pencePerPound = Math.round((100 * uk.basic_rate) / (1 - uk.basic_rate)); +const ukTaxYear = `${giftAid.year}/${giftAid.year + 1 - 2000}`; + +export const FAQ_ITEMS: FaqItem[] = [ + { + id: "what", + question: "What does GiveCalc calculate?", + answer: ( + <> +

+ GiveCalc estimates what a cash gift to charity costs you after tax. + It runs PolicyEngine's model of your household twice, without + the gift and with it, and reports how much the gift lowers your net + taxes: your taxes minus any benefits you receive. +

+

+ In the US, GiveCalc counts federal and state taxes, and the local + income taxes PolicyEngine models, such as New York City's. In + the UK, it applies Gift Aid to your income tax. The charts show how your + net taxes change with gifts from nothing up to your income. +

+ + ), + }, + { + id: "little-or-nothing", + question: "Why did my gift save little or nothing?", + answer: ( + <> +

+ In the US, a gift lowers your federal income tax only through the + part you can deduct. If you take the standard deduction, you can + deduct up to {singleCap} of cash gifts from {us.year}, or {jointCap}{" "} + on a joint return; in {california.earlierYear} you could deduct + nothing. Above that, a larger gift lowers your federal income tax + only once itemizing, gift included, would lower it more than the + standard deduction does. Some states, such as California, let you + itemize on the state return either way, so a larger gift can still + lower your state tax. +

+

+ A single 30-year-old in California earning {usd(california.wages)}{" "} + saves {usd(california.savings)} by giving {usd(california.gift)} in{" "} + {california.year}, and the same{" "} + {usd(california.largerGiftSavings)} by giving{" "} + {usd(california.largerGift)}. In {california.earlierYear}, the{" "} + {usd(california.gift)} gift saved{" "} + {california.earlierYearSavings === 0 + ? "nothing" + : usd(california.earlierYearSavings)} + . + +

+

+ In the UK, a basic-rate taxpayer saves nothing: Gift Aid's + basic-rate relief goes to the charity. +

+ + ), + }, + { + id: "2026", + question: `What changed for ${us.year}?`, + answer: ( + <> +

+ Three federal rules took effect in {us.year}, and GiveCalc applies + all three. +

+
    +
  • + Filers who take the standard deduction can deduct up to{" "} + {singleCap} of cash gifts, or {jointCap}{" "} + on a joint return. +
  • +
  • + Itemizers deduct only the part of their gifts above{" "} + {formatPercent(us.itemizer_floor_share_of_agi, 1)} of their + adjusted gross income. +
  • +
  • + Filers whose income before itemized deductions reaches the top{" "} + {formatPercent(us.top_bracket_rate, 0)} bracket give up {limit} of + their itemized deductions, up to {limit} of their income in that + bracket. +
  • +
+

+ Take a single 30-year-old earning {usd(texas.wages)} in Texas, + which has no state income tax. Giving {usd(texas.largerGift)} in{" "} + {texas.year} saves {usd(texas.extraSavings)} more than giving{" "} + {usd(texas.gift)}: {centsPerExtraDollar} cents for each extra + dollar. + +

+ + ), + }, + { + id: "share-of-income", + question: "What does “Share of income” do?", + answer: ( +

+ In the US calculator, choose “Share of income” in the first + step and enter a percentage, such as 10%. GiveCalc then works out the + gift from the income you enter in step 3: everyone's wages, tips, + self-employment income, interest, dividends and capital gains, minus + any losses. The UK calculator asks for an amount. +

+ ), + }, + { + id: "bunching", + question: "How does the bunching check work?", + answer: ( + <> +

+ After a US result, GiveCalc checks whether giving two years' + gifts at once would save more tax. It compares giving your gift in + the year you chose and again the next year with giving twice the + gift in the first year and nothing in the second. It keeps your + household and income the same in the second year, with everyone a + year older, and applies that year's tax rules. +

+

+ Bunching can lower your taxes when you itemize in the year you give + and take the standard deduction in the year you skip. Take a single + 30-year-old earning {usd(newYork.wages)} who lives in New York, + outside New York City.{" "} + {bunchingSentence(newYork.bunching, newYork.gift, newYork.year)} + +

+

+ For a gift in the latest year GiveCalc offers, pick an earlier year + to run the check. +

+ + ), + }, + { + id: "gift-aid", + question: "How does Gift Aid work in GiveCalc?", + answer: ( + <> +

+ With Gift Aid, a charity claims the basic-rate tax on your gift:{" "} + {pencePerPound}p for every £1, so a {gbp(giftAid.gift)} gift + gives the charity {gbp(toCharity)}. Taxpayers above the basic rate + also save income tax: the difference between their rate and the + basic rate, on as much of that {gbp(toCharity)} as they pay their + higher rate on. GiveCalc shows that saving as your tax relief. +

+

+ A donor in England, Wales or Northern Ireland earning{" "} + {gbp(giftAid.higherRateIncome)} who gives {gbp(giftAid.gift)} in{" "} + {ukTaxYear} saves {gbp(giftAid.higherRateSavings)}. One earning{" "} + {gbp(giftAid.basicRateIncome)} saves{" "} + {giftAid.basicRateSavings === 0 + ? "nothing" + : gbp(giftAid.basicRateSavings)} + , because the basic-rate relief goes to the charity. +

+

+ GiveCalc uses Scottish rates for donors in Scotland, where relief + starts at the {formatPercent(uk.scottish_intermediate_rate, 0)}{" "} + intermediate rate. Gift Aid also + lowers the income that tapers the personal allowance above{" "} + {gbp(uk.personal_allowance_taper_start)} and sets the High Income + Child Benefit Charge, and GiveCalc counts both effects. +

+ + ), + }, + { + id: "leaves-out", + question: "What does GiveCalc leave out?", + answer: ( + <> +

+ GiveCalc models cash gifts and assumes each one qualifies for the + relief it applies. It leaves out: +

+
    +
  • gifts of shares, property and goods;
  • +
  • + state tax credits that depend on which charity you give to, such + as Arizona's and Mississippi's; +
  • +
  • countries other than the US and the UK.
  • +
+

+ GiveCalc treats each US gift as cash to a public charity. Gifts to + donor-advised funds can't use the deduction for filers + who don't itemize, and GiveCalc doesn't tell them apart. + Use its estimates to plan, and ask a tax professional about your + own return. +

+ + ), + }, + { + id: "stored", + question: "Is my information stored?", + answer: ( + <> +

+ GiveCalc asks for no name, email address or account. To calculate, + your browser sends the household you enter to GiveCalc's + server, which runs the model and sends back the result. GiveCalc's + code saves none of it to a database or file. The server's log + records each request's type, such as a savings calculation, + whether it worked and how long it took, without the household. +

+

+ Each server keeps recent households and their results in memory to + answer repeat questions faster, and drops them when it needs the room or shuts + down. +

+

+ When a link can reproduce a US calculation, GiveCalc puts your + inputs in the page's address so you can share it. Your + browser's history keeps that address, and opening it sends the + inputs to the site's host, as any web address does. +

+ + ), + }, + { + id: "model", + question: "Where does the model come from?", + answer: ( +

+ GiveCalc runs PolicyEngine's open-source models of taxes and + benefits,{" "} + + policyengine-us + {" "} + and{" "} + + policyengine-uk + + . PolicyEngine is a nonprofit that provides free, open source software + to compute the impact of public policy. It publishes the code for{" "} + GiveCalc{" "} + and both models on GitHub. +

+ ), + }, +]; diff --git a/frontend/src/lib/__tests__/faqNumbers.test.ts b/frontend/src/lib/__tests__/faqNumbers.test.ts new file mode 100644 index 0000000..cf9c34a --- /dev/null +++ b/frontend/src/lib/__tests__/faqNumbers.test.ts @@ -0,0 +1,118 @@ +/** + * faqNumbers.json holds the API's answers for the FAQ's households. These + * tests check the answers belong to the households the FAQ describes, and + * that they bear out the FAQ's words ("the same $220", "saved nothing"). + * + * If a test fails after a model update, rerun `bun run faq-numbers` and + * reread the FAQ: the words may need to change with the numbers. + * + * Invariants: + * - Each recorded request is what the calculator sends for its case today. + * - Each US case is a household a link reproduces (`isShareable`). + * - Each answer's gift and year are its case's. + * - The bunching gain is the bunched savings minus both years' savings. + */ + +import { describe, it, expect } from "vitest"; +import { ukApiRequest } from "../api"; +import { + UK_FAQ_CASES, + US_BUNCHING_CASES, + US_FAQ_CASES, + type UKFaqCase, + type USFaqCase, +} from "../faqCases"; +import { FAQ_FIGURES } from "../faqFigures"; +import numbers from "../faqNumbers.json"; +import { toCalculateRequest } from "../household"; +import { isShareable } from "../urlState"; + +const usCases = Object.keys(US_FAQ_CASES) as USFaqCase[]; +const ukCases = Object.keys(UK_FAQ_CASES) as UKFaqCase[]; + +describe("FAQ numbers", () => { + it("cover every case and nothing else", () => { + expect(Object.keys(numbers.us).sort()).toEqual([...usCases].sort()); + expect(Object.keys(numbers.uk).sort()).toEqual([...ukCases].sort()); + }); + + it.each(usCases)("record the request the calculator sends for %s", (name) => { + const form = US_FAQ_CASES[name]; + expect(numbers.us[name].request).toEqual({ + ...toCalculateRequest(form), + ...(US_BUNCHING_CASES.has(name) ? { bunching: true } : {}), + }); + expect(isShareable(form)).toBe(true); + const { response } = numbers.us[name]; + expect(response.donation_amount).toBe(form.donation_amount); + expect(response.year).toBe(form.household.year); + expect(response.tax_savings).toBeCloseTo( + response.baseline_net_tax - response.net_tax_at_donation, + 6, + ); + }); + + it.each(ukCases)("record the request the calculator sends for %s", (name) => { + const request = UK_FAQ_CASES[name]; + expect(numbers.uk[name].request).toEqual(ukApiRequest(request)); + const { response } = numbers.uk[name]; + expect(response.gift_aid).toBe(request.gift_aid); + expect(response.year).toBe(request.year); + }); + + it("bear out the California answer", () => { + const { california } = FAQ_FIGURES; + expect(california.savings).toBeGreaterThan(0); + // "and the same $220 by giving $5,000" + expect(california.largerGiftSavings).toBe(california.savings); + expect(california.largerGift).toBeGreaterThan(california.gift); + // "In 2025, the $1,000 gift saved nothing." + expect(california.earlierYearSavings).toBe(0); + // "a larger gift can still lower your state tax": California's own + // itemized deduction, while the federal saving stays at the cap. The + // extra saving fits within California's top rate (13.3%) on the extra + // $3,000, so it can be state tax alone. + const stateItemizer = numbers.us.californiaStateItemizer; + const stateExtra = + stateItemizer.response.tax_savings - california.largerGiftSavings; + expect(stateExtra).toBeGreaterThan(0); + expect(stateExtra).toBeLessThan( + 0.133 * (stateItemizer.response.donation_amount - california.largerGift), + ); + expect(california.earlierYear).toBe(california.year - 1); + }); + + it("bear out the bunching answer", () => { + const bunching = numbers.us.newYork.response.bunching; + const { newYork } = FAQ_FIGURES; + expect(bunching.next_year).toBe(newYork.year + 1); + expect(bunching.bunched_donation_amount).toBe(2 * newYork.gift); + expect(newYork.bunching.gain).toBeCloseTo(bunching.bunching_gain, 6); + expect(newYork.bunching.gain).toBeCloseTo( + bunching.bunched_tax_savings - + numbers.us.newYork.response.tax_savings - + bunching.next_year_tax_savings, + 6, + ); + // "saves $2,820 more in tax" + expect(newYork.bunching.gain).toBeGreaterThan(0); + }); + + it("bear out the top-bracket answer", () => { + const { texas } = FAQ_FIGURES; + // 37% of the 35/37 of each extra dollar the 2/37 limit leaves. + const perDollar = texas.extraSavings / (texas.largerGift - texas.gift); + expect(perDollar).toBeCloseTo(0.37 * (35 / 37), 3); + }); + + it("bear out the Gift Aid answer", () => { + const { giftAid } = FAQ_FIGURES; + // "saves nothing, because the basic-rate relief goes to the charity" + expect(giftAid.basicRateSavings).toBe(0); + // A 40% taxpayer saves 40% - 20% of the gift grossed up at 20%. + expect(giftAid.higherRateSavings).toBeCloseTo( + (0.4 - 0.2) * (giftAid.gift / 0.8), + 6, + ); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6e70620..1b7b0f1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -140,7 +140,7 @@ export async function getUKRegions(): Promise { // Map simplified region values to API-compatible regions. For tax purposes, // only Scotland differs: all other regions use the same rates. -function ukApiRequest(request: UKCalculateRequest): UKCalculateRequest { +export function ukApiRequest(request: UKCalculateRequest): UKCalculateRequest { return { ...request, region: request.region === "SCOTLAND" ? "SCOTLAND" : "LONDON", diff --git a/frontend/src/lib/faqCases.ts b/frontend/src/lib/faqCases.ts new file mode 100644 index 0000000..47a0258 --- /dev/null +++ b/frontend/src/lib/faqCases.ts @@ -0,0 +1,80 @@ +/** + * The households behind the FAQ's examples. + * + * `bun run faq-numbers` sends each case to the GiveCalc API, as the + * calculator sends it, and writes the answers to `faqNumbers.json`, which + * the FAQ reads. `tests/test_faq_numbers.py` sends the same requests to the + * API code at the model versions uv.lock pins, so a model update that moves + * an answer fails CI until the file is written again. + * + * Every US case is a household a link can open: one 30-year-old (the age + * links assume), single, with wages and no children or home costs. + */ + +import { + addPerson, + createBlankDraft, +} from "policyengine-household-wizard/us-household"; +import { blankForm, type FormState, type UKCalculateRequest } from "./types"; +import { LINK_ADULT_AGE } from "./urlState"; + +function single( + state: string, + wages: number, + gift: number, + year: number, +): FormState { + const household = addPerson( + { ...createBlankDraft(year), state, maritalStatus: "single" }, + "adult", + { age: LINK_ADULT_AGE, employmentIncome: wages }, + ); + return { + ...blankForm(year), + household, + hasHomeCosts: false, + donation_amount: gift, + }; +} + +export const US_FAQ_CASES = { + /** A gift within the 2026 deduction for filers who don't itemize. */ + california: single("CA", 75_000, 1_000, 2026), + /** The same household giving more than that deduction allows. */ + californiaLarger: single("CA", 75_000, 5_000, 2026), + /** A gift above California's own standard deduction. */ + californiaStateItemizer: single("CA", 75_000, 8_000, 2026), + /** The first gift a year earlier, before the deduction began. */ + california2025: single("CA", 75_000, 1_000, 2025), + /** Giving each year, compared with giving two years' gifts at once. */ + newYork: single("NY", 150_000, 15_000, 2026), + /** A top-bracket itemizer, and the same donor giving $10,000 more. */ + texas: single("TX", 1_000_000, 100_000, 2026), + texasMore: single("TX", 1_000_000, 110_000, 2026), +} satisfies Record; + +export type USFaqCase = keyof typeof US_FAQ_CASES; + +/** Cases whose request also runs the bunching check. */ +export const US_BUNCHING_CASES: ReadonlySet = new Set(["newYork"]); + +function ukDonor(employmentIncome: number): UKCalculateRequest { + return { + income: { employment_income: employmentIncome, self_employment_income: 0 }, + // The form's "England, Wales, NI". + region: "ENGLAND", + gift_aid: 1_000, + is_married: false, + num_children: 0, + year: 2026, + }; +} + +export const UK_FAQ_CASES = { + /** A higher-rate taxpayer. */ + higherRate: ukDonor(70_000), + /** A basic-rate taxpayer. */ + basicRate: ukDonor(35_000), +} satisfies Record; + +export type UKFaqCase = keyof typeof UK_FAQ_CASES; diff --git a/frontend/src/lib/faqFigures.ts b/frontend/src/lib/faqFigures.ts new file mode 100644 index 0000000..a100b07 --- /dev/null +++ b/frontend/src/lib/faqFigures.ts @@ -0,0 +1,60 @@ +/** + * Every figure the FAQ quotes. + * + * Results come from the API answers in faqNumbers.json, for the households + * in faqCases.ts. Rules come from faqRules.json, which + * tests/test_faq_numbers.py checks against the PolicyEngine parameters. + */ + +import numbers from "./faqNumbers.json"; +import rules from "./faqRules.json"; +import { UK_FAQ_CASES, US_FAQ_CASES } from "./faqCases"; +import { compareBunching } from "./pledge"; + +const { us, uk } = numbers; +const bunching = us.newYork.response.bunching; + +function wages(form: (typeof US_FAQ_CASES)[keyof typeof US_FAQ_CASES]): number { + return form.household.people[0].employmentIncome ?? 0; +} + +export const FAQ_RULES = rules; + +export const FAQ_FIGURES = { + california: { + wages: wages(US_FAQ_CASES.california), + gift: US_FAQ_CASES.california.donation_amount, + largerGift: US_FAQ_CASES.californiaLarger.donation_amount, + year: US_FAQ_CASES.california.household.year, + earlierYear: US_FAQ_CASES.california2025.household.year, + savings: us.california.response.tax_savings, + largerGiftSavings: us.californiaLarger.response.tax_savings, + earlierYearSavings: us.california2025.response.tax_savings, + }, + newYork: { + wages: wages(US_FAQ_CASES.newYork), + gift: US_FAQ_CASES.newYork.donation_amount, + year: US_FAQ_CASES.newYork.household.year, + bunching: compareBunching( + us.newYork.response.tax_savings, + bunching.next_year_tax_savings, + bunching.bunched_tax_savings, + ), + }, + texas: { + wages: wages(US_FAQ_CASES.texas), + gift: US_FAQ_CASES.texas.donation_amount, + largerGift: US_FAQ_CASES.texasMore.donation_amount, + year: US_FAQ_CASES.texas.household.year, + extraSavings: + us.texasMore.response.tax_savings - us.texas.response.tax_savings, + }, + giftAid: { + gift: UK_FAQ_CASES.higherRate.gift_aid, + year: UK_FAQ_CASES.higherRate.year, + higherRateIncome: UK_FAQ_CASES.higherRate.income.employment_income, + higherRateSavings: uk.higherRate.response.tax_savings, + basicRateIncome: UK_FAQ_CASES.basicRate.income.employment_income, + basicRateSavings: uk.basicRate.response.tax_savings, + }, +}; diff --git a/frontend/src/lib/faqNumbers.json b/frontend/src/lib/faqNumbers.json new file mode 100644 index 0000000..e558597 --- /dev/null +++ b/frontend/src/lib/faqNumbers.json @@ -0,0 +1,508 @@ +{ + "api": "https://policyengine--givecalc-fastapi-app.modal.run", + "generated": "2026-10-22", + "models": { + "policyengine-us": "2.49.2", + "policyengine-uk": "2.134.6" + }, + "us": { + "california": { + "request": { + "household": { + "people": { + "adult-1": { + "age": { + "2026": 30 + }, + "is_tax_unit_dependent": { + "2026": false + }, + "employment_income": { + "2026": 75000 + } + } + }, + "families": { + "family": { + "members": [ + "adult-1" + ] + } + }, + "marital_units": {}, + "tax_units": { + "tax_unit": { + "members": [ + "adult-1" + ] + } + }, + "spm_units": { + "spm_unit": { + "members": [ + "adult-1" + ] + } + }, + "households": { + "household": { + "members": [ + "adult-1" + ], + "state_name": { + "2026": "CA" + } + } + } + }, + "donation_amount": 1000, + "year": 2026 + }, + "response": { + "year": 2026, + "donation_amount": 1000, + "baseline_net_tax": 17090.97265625, + "net_tax_at_donation": 16870.97265625, + "tax_savings": 220, + "baseline_net_income": 57909.02734375, + "net_income_after_donation": 57129.02734375, + "bunching": null + } + }, + "californiaLarger": { + "request": { + "household": { + "people": { + "adult-1": { + "age": { + "2026": 30 + }, + "is_tax_unit_dependent": { + "2026": false + }, + "employment_income": { + "2026": 75000 + } + } + }, + "families": { + "family": { + "members": [ + "adult-1" + ] + } + }, + "marital_units": {}, + "tax_units": { + "tax_unit": { + "members": [ + "adult-1" + ] + } + }, + "spm_units": { + "spm_unit": { + "members": [ + "adult-1" + ] + } + }, + "households": { + "household": { + "members": [ + "adult-1" + ], + "state_name": { + "2026": "CA" + } + } + } + }, + "donation_amount": 5000, + "year": 2026 + }, + "response": { + "year": 2026, + "donation_amount": 5000, + "baseline_net_tax": 17090.97265625, + "net_tax_at_donation": 16870.97265625, + "tax_savings": 220, + "baseline_net_income": 57909.02734375, + "net_income_after_donation": 53129.02734375, + "bunching": null + } + }, + "californiaStateItemizer": { + "request": { + "household": { + "people": { + "adult-1": { + "age": { + "2026": 30 + }, + "is_tax_unit_dependent": { + "2026": false + }, + "employment_income": { + "2026": 75000 + } + } + }, + "families": { + "family": { + "members": [ + "adult-1" + ] + } + }, + "marital_units": {}, + "tax_units": { + "tax_unit": { + "members": [ + "adult-1" + ] + } + }, + "spm_units": { + "spm_unit": { + "members": [ + "adult-1" + ] + } + }, + "households": { + "household": { + "members": [ + "adult-1" + ], + "state_name": { + "2026": "CA" + } + } + } + }, + "donation_amount": 8000, + "year": 2026 + }, + "response": { + "year": 2026, + "donation_amount": 8000, + "baseline_net_tax": 17090.97265625, + "net_tax_at_donation": 16697.796875, + "tax_savings": 393.17578125, + "baseline_net_income": 57909.02734375, + "net_income_after_donation": 50302.203125, + "bunching": null + } + }, + "california2025": { + "request": { + "household": { + "people": { + "adult-1": { + "age": { + "2025": 30 + }, + "is_tax_unit_dependent": { + "2025": false + }, + "employment_income": { + "2025": 75000 + } + } + }, + "families": { + "family": { + "members": [ + "adult-1" + ] + } + }, + "marital_units": {}, + "tax_units": { + "tax_unit": { + "members": [ + "adult-1" + ] + } + }, + "spm_units": { + "spm_unit": { + "members": [ + "adult-1" + ] + } + }, + "households": { + "household": { + "members": [ + "adult-1" + ], + "state_name": { + "2025": "CA" + } + } + } + }, + "donation_amount": 1000, + "year": 2025 + }, + "response": { + "year": 2025, + "donation_amount": 1000, + "baseline_net_tax": 17443.0703125, + "net_tax_at_donation": 17443.0703125, + "tax_savings": 0, + "baseline_net_income": 57556.9296875, + "net_income_after_donation": 56556.9296875, + "bunching": null + } + }, + "newYork": { + "request": { + "household": { + "people": { + "adult-1": { + "age": { + "2026": 30 + }, + "is_tax_unit_dependent": { + "2026": false + }, + "employment_income": { + "2026": 150000 + } + } + }, + "families": { + "family": { + "members": [ + "adult-1" + ] + } + }, + "marital_units": {}, + "tax_units": { + "tax_unit": { + "members": [ + "adult-1" + ] + } + }, + "spm_units": { + "spm_unit": { + "members": [ + "adult-1" + ] + } + }, + "households": { + "household": { + "members": [ + "adult-1" + ], + "state_name": { + "2026": "NY" + } + } + } + }, + "donation_amount": 15000, + "year": 2026, + "bunching": true + }, + "response": { + "year": 2026, + "donation_amount": 15000, + "baseline_net_tax": 44942.109375, + "net_tax_at_donation": 43353.20703125, + "tax_savings": 1588.90234375, + "baseline_net_income": 105057.890625, + "net_income_after_donation": 91646.796875, + "bunching": { + "next_year": 2027, + "next_year_baseline_net_tax": 44472.95703125, + "next_year_net_tax_at_donation": 43028.8203125, + "next_year_tax_savings": 1444.13671875, + "bunched_donation_amount": 30000, + "bunched_net_tax": 39089.45703125, + "bunched_tax_savings": 5852.65234375, + "every_year_tax_savings": 3033.0390625, + "bunching_gain": 2819.61328125 + } + } + }, + "texas": { + "request": { + "household": { + "people": { + "adult-1": { + "age": { + "2026": 30 + }, + "is_tax_unit_dependent": { + "2026": false + }, + "employment_income": { + "2026": 1000000 + } + } + }, + "families": { + "family": { + "members": [ + "adult-1" + ] + } + }, + "marital_units": {}, + "tax_units": { + "tax_unit": { + "members": [ + "adult-1" + ] + } + }, + "spm_units": { + "spm_unit": { + "members": [ + "adult-1" + ] + } + }, + "households": { + "household": { + "members": [ + "adult-1" + ], + "state_name": { + "2026": "TX" + } + } + } + }, + "donation_amount": 100000, + "year": 2026 + }, + "response": { + "year": 2026, + "donation_amount": 100000, + "baseline_net_tax": 353139.25, + "net_tax_at_donation": 325161.15625, + "tax_savings": 27978.09375, + "baseline_net_income": 646860.75, + "net_income_after_donation": 574838.875, + "bunching": null + } + }, + "texasMore": { + "request": { + "household": { + "people": { + "adult-1": { + "age": { + "2026": 30 + }, + "is_tax_unit_dependent": { + "2026": false + }, + "employment_income": { + "2026": 1000000 + } + } + }, + "families": { + "family": { + "members": [ + "adult-1" + ] + } + }, + "marital_units": {}, + "tax_units": { + "tax_unit": { + "members": [ + "adult-1" + ] + } + }, + "spm_units": { + "spm_unit": { + "members": [ + "adult-1" + ] + } + }, + "households": { + "household": { + "members": [ + "adult-1" + ], + "state_name": { + "2026": "TX" + } + } + } + }, + "donation_amount": 110000, + "year": 2026 + }, + "response": { + "year": 2026, + "donation_amount": 110000, + "baseline_net_tax": 353139.25, + "net_tax_at_donation": 321661.1875, + "tax_savings": 31478.0625, + "baseline_net_income": 646860.75, + "net_income_after_donation": 568338.8125, + "bunching": null + } + } + }, + "uk": { + "higherRate": { + "request": { + "income": { + "employment_income": 70000, + "self_employment_income": 0 + }, + "region": "LONDON", + "gift_aid": 1000, + "is_married": false, + "num_children": 0, + "year": 2026 + }, + "response": { + "year": 2026, + "gift_aid": 1000, + "baseline_net_tax": 19022.646484375, + "net_tax_at_donation": 18772.646484375, + "tax_savings": 250, + "baseline_net_income": 50977.3515625, + "net_income_after_donation": 50227.3515625 + } + }, + "basicRate": { + "request": { + "income": { + "employment_income": 35000, + "self_employment_income": 0 + }, + "region": "LONDON", + "gift_aid": 1000, + "is_married": false, + "num_children": 0, + "year": 2026 + }, + "response": { + "year": 2026, + "gift_aid": 1000, + "baseline_net_tax": 6460.44775390625, + "net_tax_at_donation": 6460.44775390625, + "tax_savings": 0, + "baseline_net_income": 28539.552734375, + "net_income_after_donation": 27539.552734375 + } + } + } +} diff --git a/frontend/src/lib/faqRules.json b/frontend/src/lib/faqRules.json new file mode 100644 index 0000000..ee9054f --- /dev/null +++ b/frontend/src/lib/faqRules.json @@ -0,0 +1,15 @@ +{ + "us": { + "year": 2026, + "non_itemizer_cap": { "single": 1000, "joint": 2000 }, + "itemizer_floor_share_of_agi": 0.005, + "top_bracket_rate": 0.37, + "top_bracket_limit": { "numerator": 2, "denominator": 37 } + }, + "uk": { + "year": 2026, + "basic_rate": 0.2, + "scottish_intermediate_rate": 0.21, + "personal_allowance_taper_start": 100000 + } +} diff --git a/tests/test_faq_numbers.py b/tests/test_faq_numbers.py new file mode 100644 index 0000000..1bfad01 --- /dev/null +++ b/tests/test_faq_numbers.py @@ -0,0 +1,129 @@ +"""The FAQ's numbers against the API code and the models it pins. + +``frontend/src/lib/faqNumbers.json`` holds production API answers for the +FAQ's households (``bun run faq-numbers``); ``faqRules.json`` holds the +rules the FAQ states. + +Invariants: +- Differential: the API code here, at the versions uv.lock pins, gives the + answer the production API recorded for every FAQ request. +- The file records the model versions modal_app.py deploys, which are the + ones installed here. +- Each rule the FAQ states equals its PolicyEngine parameter in the year + the FAQ names. + +When a model update moves a number, rerun ``bun run faq-numbers`` from +``frontend/`` (against a local API, ``GIVECALC_API_URL=http://localhost:8000``, +until the update deploys) and reread the FAQ's words. +""" + +import json +import re +from importlib.metadata import version +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from api.main import app +from api.simulations import clear_caches + +ROOT = Path(__file__).parent.parent +LIB = ROOT / "frontend" / "src" / "lib" +NUMBERS = json.loads((LIB / "faqNumbers.json").read_text()) +RULES = json.loads((LIB / "faqRules.json").read_text()) + +client = TestClient(app) + +CASES = [ + ("/api/savings", name, case) for name, case in NUMBERS["us"].items() +] + [("/api/uk/savings", name, case) for name, case in NUMBERS["uk"].items()] + + +def numeric_fields(record: dict, prefix: str = "") -> dict: + """Every number in a response, by its path.""" + fields = {} + for key, value in record.items(): + if isinstance(value, dict): + fields.update(numeric_fields(value, f"{prefix}{key}.")) + elif isinstance(value, (int, float)) and not isinstance(value, bool): + fields[f"{prefix}{key}"] = value + return fields + + +@pytest.mark.parametrize( + "path,name,case", CASES, ids=[name for _, name, _ in CASES] +) +def test_api_gives_the_recorded_answer(path, name, case): + clear_caches() + response = client.post(path, json=case["request"]) + assert response.status_code == 200, response.text + got = numeric_fields(response.json()) + recorded = numeric_fields(case["response"]) + assert got.keys() == recorded.keys() + for field, value in recorded.items(): + assert got[field] == pytest.approx(value, abs=0.01), field + + +def test_numbers_record_the_deployed_models(): + modal_app = (ROOT / "modal_app.py").read_text() + for package, recorded in NUMBERS["models"].items(): + pinned = re.search(rf'"{package}==([^"]+)"', modal_app).group(1) + assert recorded == pinned, ( + f"faqNumbers.json records {package} {recorded}; modal_app.py " + f"deploys {pinned}. Rerun `bun run faq-numbers`." + ) + assert version(package) == pinned + + +def test_us_rules_match_policyengine_us(): + from policyengine_us.system import system + + rules = RULES["us"] + p = system.parameters(f"{rules['year']}-01-01").gov.irs + charity = p.deductions.itemized.charity + assert charity.non_itemizers_amount.SINGLE == ( + rules["non_itemizer_cap"]["single"] + ) + assert charity.non_itemizers_amount.JOINT == ( + rules["non_itemizer_cap"]["joint"] + ) + assert charity.floor.applies + assert charity.floor.amount == rules["itemizer_floor_share_of_agi"] + # The year before, neither rule applied. + before = system.parameters(f"{rules['year'] - 1}-01-01").gov.irs + assert not before.deductions.itemized.charity.floor.applies + assert before.deductions.itemized.charity.non_itemizers_amount.SINGLE == 0 + # The 37% bracket and the 2/37 limit on itemized deductions. + rates = p.income.bracket.rates + assert rates["7"] == rules["top_bracket_rate"] + assert max(rates[str(i)] for i in range(1, 8)) == rates["7"] + limitation = p.deductions.itemized.limitation + assert limitation.applies and limitation.obbb.applies + limit = rules["top_bracket_limit"] + assert limitation.obbb.rate == pytest.approx( + limit["numerator"] / limit["denominator"], abs=1e-7 + ) + assert not before.deductions.itemized.limitation.obbb.applies + + +def test_uk_rules_match_policyengine_uk(): + from policyengine_uk.system import system + from policyengine_uk.utils.income_tax import ( + first_extended_scottish_threshold, + ) + + rules = RULES["uk"] + # PolicyEngine UK's year 2026 is the 2026/27 tax year, from 6 April; + # policyengine-core reads the year's parameters at 1 January. Check both. + for day in ("01-01", "04-06"): + p = system.parameters(f"{rules['year']}-{day}").gov.hmrc.income_tax + assert p.rates.uk.rates[0] == rules["basic_rate"] + # Scottish relief starts at the first band policyengine-uk extends + # for Gift Aid, which is the intermediate band. + scotland = p.rates.scotland.rates + first = first_extended_scottish_threshold(scotland) + assert scotland.rates[first] == rules["scottish_intermediate_rate"] + assert p.allowances.personal_allowance.maximum_ANI == ( + rules["personal_allowance_taper_start"] + )