Tom Gould

Writing

Taxing a household one earner at a time

How Where Should I Live taxes each earner alone, lays a country’s joint and family rules on top, and proves that one person’s figures haven’t moved at all.

Where Should I Live started as a calculator for one person. You gave it an income, and every city got a tax bill from that country’s rules. Then it learned about households: a partner who may also earn, other adults, and children in three age bands. Tax is where that gets hard. Some countries tax each person separately, some let a married couple pool their income, and most give something for children, as a deduction, a tax credit or cash.

I didn’t want to rewrite the tax calculator to do it. It came from the first version, it’s checked against golden figures from official schedules and calculators, and every view reads its output. So the household model works in two phases around it, and a set of tests proves that one person on their own gets exactly the figures they got before.

Phase 1: each earner alone

The first phase is plain. Each earner is taxed on their own income and employment type by the existing calculator, and the results are added up. Lines with the same name, such as income tax or a social contribution, are merged, and each one’s rate is worked out again against the household’s total income. Anyone on £0 isn’t an earner, so if you aren’t working and your partner is, your partner is the only earner.

With one earner, phase 1 builds nothing new. It hands back the calculator’s own result:

if (earners.length === 1) {
    return singleEarnerTax(taxCalculator, city, earners[0].annualIncome, earners[0].employmentType);
}

That one decision is most of the guarantee for a single person. Their figures aren’t recomputed and found to match: they’re the object the calculator produced, untouched. The care goes down to the input. The list of earners passes on the income as it’s stored in the profile, not a parsed copy, so the calculator receives exactly the value it always did.

Phase 2: the country’s rules on top

Phase 2 runs only when a partner or a child is moving, and only in a country whose tax data has a household block. Twenty-nine countries have one. Each block is a list of rules, and each rule has a type:

  • filing rules, which change how a couple is taxed: split for France’s family quotient or Germany’s splitting, joint_brackets for joint tables such as married filing jointly in the United States, and band_increase for Ireland’s wider rate band for married couples
  • tax reliefs: dependant_deduction, dependant_credit, allowance_transfer, and income_transfer for Belgium’s marital quotient
  • child_benefit, paid as cash
  • benefit_or_allowance, for Germany, where the tax office gives you whichever is worth more, child benefit or the child allowance

Every rule records its sources and the date each was checked. Germany’s splitting rule cites section 32a of its income tax act, checked on 18 October 2026, with a note that unmarried couples don’t qualify.

familyRules.js works out the tax for every way the household could file and keeps the cheapest. The options are individual filing, unless pooled filing is compulsory, and one for each filing rule that applies. Of couples, only a married one is pooled. An unmarried couple is taxed apart, but where a split rule gives extra parts for children, each parent is tried as the one who claims them:

let best = null;
for (const basis of candidates) {
    const res = taxUnderBasis(hh, basis, enabledFor(basis));
    if (!best || res.total < best.res.total - 1e-9) best = {basis, res};
}

Only income tax and the surcharges charged on it change, such as Germany’s solidarity surcharge. Social contributions stay per earner, as phase 1 worked them out, and so does everything else the calculator did, including regional taxes. The exception is a region with joint tables of its own, as New York State and New York City have. The rules don’t replace phase 1. They produce a difference, and the difference is laid on top.

Showing the working

A tax bill that dropped by thousands of pounds is no use without the reason. So once the cheapest basis is chosen, the tax is worked out again with the rules switched on one at a time, in a fixed order, and each step records how much it saved. The steps add up exactly to the difference between phase 1 and the final figure, and a test checks that they do. They become the lines under the tax figure on each city’s card.

With the current data, take a married couple moving to Berlin with two children under five, one earning £80,000 as an employee and the other not working. Phase 1 gives £32,968 of tax and contributions. Joint assessment saves £6,592. Kindergeld, the child benefit, adds £5,246 a year in cash. If they choose “Prefer not to say” when asked whether they’re married or in a registered partnership, there’s no splitting, and the only tax line is £86: the child allowance’s relief above Kindergeld on that income.

Cash benefits count towards take-home pay but not towards the effective tax rate, because a benefit isn’t a tax cut. The tax figure everywhere is the tax actually paid.

Only a yes unlocks the rules for married couples or single parents: “No” and “Prefer not to say” are treated the same. Where a yes would cut what the household pays by at least a pound a year, the city’s card says “Married couples may pay less here.” Knowing that means running the rules a second time with the answer flipped, so the result is cached for each country.

Children’s ages come in three bands: under 5, 5 to 13 and 14 to 17. Some rules use exact ages, such as Portugal’s extra deduction for a second or later child aged six or under. Those are averaged across the band, and the line says “(averaged over the age group)”.

Proving one person hasn’t moved

The household model is passed into the scoring engine. Without it, the engine runs the first version’s single-person arithmetic. That makes for a clean test: score every city with the model and without it, and compare.

for (let i = 0; i < legacy.length; i++) {
    assert.deepStrictEqual(figures(household[i]), figures(legacy[i]), legacy[i].name);
}

figures() collects everything the views and the ranking read, from the tax breakdown and costs to every score and the visa result. deepStrictEqual compares numbers exactly, not within a tolerance, so a figure that drifts in its last decimal place fails. The test runs for 22 single profiles across all 78 cities, among them the defaults, a profile saved by the first version, a dual national, no income at all, and every employment type at £25,000, £60,000, £150,000 and £600,000.

A second test calls the household tax directly for a city in every country in the tax data, with every employment type, and checks that the result is the calculator’s own breakdown. Others do the same for costs and visas.

The guarantee is between the engine with the model and without it. Where the single-person arithmetic did change on purpose, a test pins the new figure: eating out is now the Office for National Statistics’ one-adult spend, priced at the city’s restaurant prices, where it used to be 30 restaurant meals a month.

Running it

The unit tests need nothing but Node and the committed data:

node --test tools/tests/*.test.mjs
node tools/household-test.mjs

The first includes the 27 identity tests, alongside unit tests for the rules, costs, visas and more. The second puts a fixed family through every tax country and every city and fails on any result that isn’t a sensible number. The release script runs both before it builds the app, so a rule that moved one person’s figures, or produced nonsense for a family anywhere, stops the release.

In the app, add a partner or children in the first step of the questions, and answer the tax questions in the second. Each city’s card then shows the household’s tax with a line for every rule that changed it.