Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions tuttle/fx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand Down
31 changes: 18 additions & 13 deletions tuttle/tax.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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()
Expand Down
28 changes: 8 additions & 20 deletions tuttle/tax_reserves.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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]
Expand Down
7 changes: 5 additions & 2 deletions tuttle_tests/test_effective_salary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)]

Expand Down
84 changes: 84 additions & 0 deletions tuttle_tests/test_no_tax_estimate_without_year_data.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 7 additions & 9 deletions tuttle_tests/test_tax.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 ────────────────────────────────────────
Expand Down
Loading