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 ────────────────────────────────────────