From 5cad195cc45fc3dc538616b20ed445441ceac7e8 Mon Sep 17 00:00:00 2001 From: Christian Staudt <875194+clstaudt@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:16:53 +0200 Subject: [PATCH] Show no tax estimate for a year without its tax rules Tax rules are kept in one file per year. For a year without its own file, before the first or after the last, the engine took the nearest year's rules, so the tax view estimated that year's income tax with rules that were never in force for it and showed it like any other estimate. A year without its own rules now has none: the tax view, the salary and the cash flow show no tax for it and say so, for example "No tax rules for Germany in 2023." A country's currency no longer depends on the year, so amounts are still converted into it. Co-Authored-By: Claude Opus 5.5 --- tuttle/fx.py | 6 +- tuttle/tax.py | 31 ++++--- tuttle/tax_reserves.py | 28 ++----- tuttle_tests/test_effective_salary.py | 7 +- .../test_no_tax_estimate_without_year_data.py | 84 +++++++++++++++++++ tuttle_tests/test_tax.py | 16 ++-- 6 files changed, 125 insertions(+), 47 deletions(-) create mode 100644 tuttle_tests/test_no_tax_estimate_without_year_data.py diff --git a/tuttle/fx.py b/tuttle/fx.py index e60208c6..c7154400 100644 --- a/tuttle/fx.py +++ b/tuttle/fx.py @@ -23,7 +23,7 @@ from babel.numbers import get_territory_currencies from .app_db import AppDatabase -from .tax import get_tax_system +from .tax import NoTaxRules, tax_currency logger = logging.getLogger(__name__) @@ -94,8 +94,8 @@ def default_currency(country: str) -> str: otherwise); else EUR. """ try: - return get_tax_system(country).currency - except NotImplementedError: + return tax_currency(country) + except NoTaxRules: pass code = _COUNTRY_ALIASES.get(country) if code is None: diff --git a/tuttle/tax.py b/tuttle/tax.py index 802c4322..753ef564 100644 --- a/tuttle/tax.py +++ b/tuttle/tax.py @@ -5,7 +5,8 @@ thresholds, polynomial coefficients, surcharge rates, VAT rates). The engine loads the right year's data for any given date, so historical -invoices are taxed with the rules that were actually in force. +invoices are taxed with the rules that were actually in force. A year +without its own file has no tax rules: no other year's stand in for it. Adding a new year: copy the JSON file, update the coefficients. Adding a new country: create a new subdirectory with its own JSON files. @@ -103,19 +104,23 @@ def _resolve_country(country: str) -> str: def _get_params(country: str, year: int) -> TaxParams: """Get TaxParams for a country and year. - Falls back to the closest available year if the exact year isn't found. + A year without its own data raises ``NoTaxRules``: another year's rules + would pass for that year's, and the estimate with them. """ canonical = _resolve_country(country) - years = _REGISTRY[canonical] - if year in years: - return years[year] - # Fall back: the most recent past year, else the earliest one we have - available = sorted(years.keys()) - best = available[0] - for y in available: - if y <= year: - best = y - return years[best] + params = _REGISTRY[canonical].get(year) + if params is None: + raise NoTaxRules(f"No tax rules for {canonical} in {year}.") + return params + + +def tax_currency(country: str) -> str: + """The currency *country*'s tax rules are written in, whatever the year. + + Raises ``NoTaxRules`` when there is no tax model for *country*. + """ + years = _REGISTRY[_resolve_country(country)] + return years[max(years)].currency def supported_countries() -> list[str]: @@ -361,7 +366,7 @@ def get_tax_system( Defaults to today. Raises: - NoTaxRules: If the country has no data packages. + NoTaxRules: If the country has no data packages, or none for the year. """ if date is None: date = datetime.date.today() diff --git a/tuttle/tax_reserves.py b/tuttle/tax_reserves.py index 7483e383..fd9d2549 100644 --- a/tuttle/tax_reserves.py +++ b/tuttle/tax_reserves.py @@ -12,7 +12,7 @@ from pandas import DataFrame -from .fx import convert, fx_haircut +from .fx import convert, default_currency, fx_haircut from .fx import rate as fx_rate from .model import Invoice, Project, RecurringExpense from .tax import NoTaxRules, get_tax_system @@ -235,8 +235,8 @@ def compute_income_tax_reserve( system's). The tariff is written in the tax system's currency, so income in another one is converted for it, and the tax back. - Raises ``NoTaxRules`` when there are no tax rules for *country*: a tax of - zero would pass for an estimate, so there is none. + Raises ``NoTaxRules`` when there are no tax rules for *country* in that + year: a tax of zero would pass for an estimate, so there is none. """ today = datetime.date.today() _zero = IncomeTaxReserve( @@ -324,9 +324,9 @@ def compute_spendable_income( year, as computed by ``forecasting.contracted_revenue``; other years ignore it. - Without tax rules for *country* the figures up to the tax base stay, and - the income tax, the expenses charged on taxed income and Safe to Spend - are None, with ``tax_unavailable_reason`` saying why. + Without tax rules for *country* in the year, the figures up to the tax + base stay, and the income tax, the expenses charged on taxed income and + Safe to Spend are None, with ``tax_unavailable_reason`` saying why. """ today = datetime.date.today() # Any year but the current one is over: it has no planned or contracted work. @@ -335,14 +335,7 @@ def compute_spendable_income( year_end = datetime.date(year or today.year, 12, 31) cent = Decimal("0.01") - ref_date = datetime.date(year, 7, 1) if year is not None else today - - if currency is None: - try: - tax_system = get_tax_system(country, date=ref_date) - currency = tax_system.currency - except NotImplementedError: - pass + currency = currency or default_currency(country) received_gross = Decimal(0) received_vat = Decimal(0) @@ -625,12 +618,7 @@ def compute_effective_salary( this_month = today.replace(day=1) cent = Decimal("0.01") - if currency is None: - try: - tax_system = get_tax_system(country, date=today) - currency = tax_system.currency - except NotImplementedError: - currency = "EUR" + currency = currency or default_currency(country) # A draft is not invoiced yet, so it is neither revenue nor where the history starts. invoices = [inv for inv in invoices if inv.is_issued and not inv.cancelled] diff --git a/tuttle_tests/test_effective_salary.py b/tuttle_tests/test_effective_salary.py index 7e654c8b..27927796 100644 --- a/tuttle_tests/test_effective_salary.py +++ b/tuttle_tests/test_effective_salary.py @@ -9,7 +9,7 @@ import pytest -from tuttle import tax_reserves +from tuttle import tax, tax_reserves from tuttle.model import RecurringExpense from tuttle.tax_reserves import compute_effective_salary, compute_spendable_income from tuttle.time import Cycle @@ -68,9 +68,12 @@ def test_steady_income_gives_the_same_salary_all_year(today, country): assert len(salaries) == 1 -def test_a_full_year_matches_safe_to_spend(today): +def test_a_full_year_matches_safe_to_spend(today, monkeypatch): """On New Year's Day the salary averages exactly the year just closed.""" today(datetime.date(2027, 1, 1)) + # The salary is taxed with this year's rules, the estimate with 2026's: let + # 2027 have the same ones, so that only the averaging is compared. + monkeypatch.setitem(tax._REGISTRY["Germany"], 2027, tax.get_tax_system("Germany", datetime.date(2026, 7, 1)).params) invoices = _monthly_invoices(2026, range(1, 13)) expenses = [_fixed("800"), _fixed("200", deductible=False)] diff --git a/tuttle_tests/test_no_tax_estimate_without_year_data.py b/tuttle_tests/test_no_tax_estimate_without_year_data.py new file mode 100644 index 00000000..a77bf4e7 --- /dev/null +++ b/tuttle_tests/test_no_tax_estimate_without_year_data.py @@ -0,0 +1,84 @@ +"""A year without its own tax rules gets no tax estimate: no other year's rules stand in.""" + +import datetime +from decimal import Decimal + +import pytest + +from tuttle import fx +from tuttle.einvoice import _tax_currency +from tuttle.model import Address, Client, Contract, Invoice, InvoiceItem, Project, User +from tuttle.tax import NoTaxRules, available_years, get_tax_system, tax_currency +from tuttle.tax_reserves import compute_income_tax_reserve, compute_spendable_income +from tuttle.time import Cycle, TimeUnit + +FIRST = available_years("Germany")[0] +LAST = available_years("Germany")[-1] + + +def _invoice(date: datetime.date, net: str, currency: str = "EUR") -> Invoice: + client = Client( + name="Central Services", + address=Address(street="Main Street", number="42", postal_code="55555", city="Metro", country="Germany"), + ) + contract = Contract( + title="Heating", + client=client, + rate=Decimal("100"), + currency=currency, + unit=TimeUnit.hour, + units_per_workday=8, + term_of_payment=14, + billing_cycle=Cycle.monthly, + ) + project = Project(title="Boiler", tag="#boiler", start_date=date, contract=contract) + inv = Invoice(number=f"{date}-1", date=date, contract=contract, project=project, sent=True, paid=True) + InvoiceItem(invoice=inv, quantity=1, unit="flat", unit_price=Decimal(net), VAT_rate=Decimal("0"), description="Work") + return inv + + +@pytest.mark.parametrize("year", [FIRST - 1, LAST + 1]) +def test_a_year_before_or_after_the_data_has_no_rules(year): + with pytest.raises(NoTaxRules, match=rf"^No tax rules for Germany in {year}\.$"): + get_tax_system("Germany", datetime.date(year, 7, 1)) + with pytest.raises(NoTaxRules, match=rf"^No tax rules for Germany in {year}\.$"): + compute_income_tax_reserve(Decimal(50000), "Germany", year=year) + + +def test_an_alias_is_named_by_its_country(): + with pytest.raises(NoTaxRules, match=rf"^No tax rules for Germany in {LAST + 1}\.$"): + get_tax_system("Deutschland", datetime.date(LAST + 1, 7, 1)) + + +def test_a_year_with_data_is_unaffected(): + assert get_tax_system("Germany", datetime.date(2025, 7, 1)).income_tax(Decimal(50000)) == 10691 + assert compute_income_tax_reserve(Decimal(50000), "Germany", year=2025).ytd_reserve > 0 + + +@pytest.mark.parametrize("year", [FIRST - 1, LAST + 1]) +def test_tax_view_shows_no_estimate_for_that_year(year): + spending = compute_spendable_income([_invoice(datetime.date(year, 3, 1), "40000")], "Germany", currency="EUR", year=year) + + assert spending.net_revenue_ytd == 40000 + assert spending.tax_base == 40000 + assert spending.income_tax_reserve is None + assert spending.spendable is None + assert spending.tax_unavailable_reason == f"No tax rules for Germany in {year}." + + +def test_the_currency_does_not_depend_on_the_year(): + assert tax_currency("United States") == "USD" + assert tax_currency("Deutschland") == "EUR" + with pytest.raises(NoTaxRules): + tax_currency("Norway") + assert fx.default_currency("United States") == "USD" + assert fx.default_currency("Norway") == "NOK" + assert _tax_currency(User(operating_country="United States")) == "USD" + + +def test_without_a_currency_a_year_without_rules_still_converts(): + """Foreign revenue is converted into the country's currency, not summed as it is.""" + year = FIRST - 1 + spending = compute_spendable_income([_invoice(datetime.date(year, 3, 1), "1000", currency="USD")], "Germany", year=year) + # USD 1,000 at the suite's stub rate of 0.9. + assert spending.net_revenue_ytd == 900 diff --git a/tuttle_tests/test_tax.py b/tuttle_tests/test_tax.py index 9a6c38f1..91ea1bae 100644 --- a/tuttle_tests/test_tax.py +++ b/tuttle_tests/test_tax.py @@ -155,11 +155,10 @@ def _make_invoice(date, items_data, cancelled=False, vat_rate=None): class TestTaxSystemFramework: - def test_years_before_the_data_use_the_earliest_rules(self): + def test_years_before_the_data_have_no_rules(self): earliest = available_years("Germany")[0] - older = get_tax_system("Germany", date=datetime.date(earliest - 3, 6, 1)) - first = get_tax_system("Germany", date=datetime.date(earliest, 6, 1)) - assert older.income_tax(Decimal(50000)) == first.income_tax(Decimal(50000)) + with pytest.raises(NoTaxRules, match=f"No tax rules for Germany in {earliest - 3}."): + get_tax_system("Germany", date=datetime.date(earliest - 3, 6, 1)) def test_get_tax_system_germany(self): system = get_tax_system("Germany") @@ -194,11 +193,10 @@ def test_year_selection(self): # Basic allowance increased 2024 → 2026 assert sys_2024.params.basic_allowance < sys_2026.params.basic_allowance - def test_year_fallback(self): - """Year without data falls back to nearest available.""" - sys = get_tax_system("Germany", date=datetime.date(2030, 1, 1)) - # Should get the latest available year - assert sys.year == max(available_years("Germany")) + def test_years_after_the_data_have_no_rules(self): + """No other year's rules stand in for a year without its own.""" + with pytest.raises(NoTaxRules, match="No tax rules for Germany in 2030."): + get_tax_system("Germany", date=datetime.date(2030, 1, 1)) # ── German income tax ────────────────────────────────────────