diff --git a/tuttle/app/salary/intent.py b/tuttle/app/salary/intent.py index a188953d..0e02491c 100644 --- a/tuttle/app/salary/intent.py +++ b/tuttle/app/salary/intent.py @@ -2,7 +2,6 @@ from ...fx import primary_currency, validate_currency_code from ...model import Invoice, RecurringExpense, User -from ...tax import has_tax_model from ...tax_reserves import compute_effective_salary from ..core.abstractions import Intent, SQLModelDataSourceMixin from ..core.intent_result import IntentResult @@ -51,7 +50,7 @@ def get_effective_salary(self) -> IntentResult: "salary": salary, "currency": currency, "country": country, - "country_supported": has_tax_model(country) if country else False, + "country_supported": salary.tax_unavailable_reason is None, }, ) except Exception as e: diff --git a/tuttle/app/tax/intent.py b/tuttle/app/tax/intent.py index e398e3cc..4f8cfb0a 100644 --- a/tuttle/app/tax/intent.py +++ b/tuttle/app/tax/intent.py @@ -79,7 +79,11 @@ def get_spendable_income(self, year: int | None = None) -> IntentResult: ) def get_income_tax_estimate(self, year: int | None = None) -> IntentResult: - """Get detailed income tax estimate with bracket info.""" + """Get detailed income tax estimate with bracket info. + + Without tax rules for the country and year there is no estimate: + ``tax_reserve`` is None and ``tax_unavailable_reason`` says why. + """ try: today = datetime.date.today() @@ -103,16 +107,13 @@ def get_income_tax_estimate(self, year: int | None = None) -> IntentResult: # tax_base, not taxable_profit: deductible dynamic expenses have already # been taken off, and charging them again here would double-count. total_income = spending.tax_base - tax_reserve = compute_income_tax_reserve(total_income, country, year=year, currency=currency) - - ref_date = datetime.date(year, 7, 1) if year else today - try: - tax_system = get_tax_system(country, date=ref_date) - bracket_data = self._compute_bracket_data(tax_system, total_income) - country_supported = True - except NotImplementedError: - bracket_data = [] - country_supported = False + no_tax = spending.tax_unavailable_reason + tax_reserve = None + bracket_data = [] + if no_tax is None: + tax_reserve = compute_income_tax_reserve(total_income, country, year=year, currency=currency) + ref_date = datetime.date(year, 7, 1) if year else today + bracket_data = self._compute_bracket_data(get_tax_system(country, date=ref_date), total_income) data = { "tax_reserve": tax_reserve, @@ -124,7 +125,8 @@ def get_income_tax_estimate(self, year: int | None = None) -> IntentResult: "deducted_expenses": spending.business_expenses + spending.dynamic_expenses_deductible, "brackets": bracket_data, "country": country, - "country_supported": country_supported, + "country_supported": no_tax is None, + "tax_unavailable_reason": no_tax, "currency": currency, } return IntentResult(was_intent_successful=True, data=data) diff --git a/tuttle/forecasting.py b/tuttle/forecasting.py index db0b6ac4..f3d6d474 100644 --- a/tuttle/forecasting.py +++ b/tuttle/forecasting.py @@ -747,6 +747,9 @@ def cash_flow_series( - Inflows from contracted work (:func:`contracted_revenue`), shifted the same way. - Outflows from normalized fixed recurring expenses, rate-based contributions, and income tax reserves at the tax view's effective rate for the year. + Without tax rules there is no rate: ``tax_included`` is False, and the + income tax and the contributions charged on taxed income (named in + ``left_out_with_tax``) are left out, never reserved at zero. - Net flow and running cumulative balance across the forecast window. Empty buckets are included so the time axis stays continuous. @@ -882,7 +885,9 @@ def cash_flow_series( time_data=time_data, contracted=contracted_this_year(projects, invoices, time_data, currency=currency, today=today), ) - effective_tax_rate = spending.income_tax_reserve / spending.tax_base if spending.tax_base > 0 else Decimal(0) + no_tax = spending.tax_unavailable_reason + if no_tax is None: + effective_tax_rate = spending.income_tax_reserve / spending.tax_base if spending.tax_base > 0 else Decimal(0) # 5. Net flow, cumulative balance, and summary totals running_balance = 0.0 @@ -893,10 +898,14 @@ def cash_flow_series( row = buckets[period] month_tax_base, dyn_deductible = month_data[period] - monthly_tax = (month_tax_base * effective_tax_rate).quantize(Decimal("0.01")) - post_tax = max(Decimal(0), month_tax_base - monthly_tax - (monthly_fixed - monthly_fixed_deductible)) - nondeductible_lines = _dynamic_lines(dynamic_expenses, post_tax, deductible=False) - dyn_nondeductible = _lines_total(nondeductible_lines) + if no_tax is None: + monthly_tax = (month_tax_base * effective_tax_rate).quantize(Decimal("0.01")) + post_tax = max(Decimal(0), month_tax_base - monthly_tax - (monthly_fixed - monthly_fixed_deductible)) + dyn_nondeductible = _lines_total(_dynamic_lines(dynamic_expenses, post_tax, deductible=False)) + else: + # Without tax rules neither the tax nor what is charged on taxed + # income is known: both are left out, and the result says so. + monthly_tax = dyn_nondeductible = Decimal(0) total_outflow = monthly_fixed + dyn_deductible + monthly_tax + dyn_nondeductible row["outflow"] = round(float(total_outflow), 2) @@ -921,4 +930,8 @@ def cash_flow_series( "total_inflow": total_inflow, "total_outflow": total_outflow, "net_cash_flow": net_cash_flow, + "tax_included": no_tax is None, + "tax_unavailable_reason": no_tax, + # Charged on taxed income, so left out with the tax. + "left_out_with_tax": [] if no_tax is None else [e.title for e in dynamic_expenses if not e.tax_deductible], } diff --git a/tuttle/kpi.py b/tuttle/kpi.py index e1f5f244..82fae014 100644 --- a/tuttle/kpi.py +++ b/tuttle/kpi.py @@ -10,7 +10,7 @@ from .forecasting import contracted_this_year from .fx import primary_currency from .model import Contract, Invoice, Project, RecurringExpense -from .tax import get_tax_system, has_tax_model +from .tax import NoTaxRules, get_tax_system from .tax_reserves import compute_spendable_income, convert_invoice from .time import TimeUnit from .timetracking import sum_hours_by_tag @@ -31,11 +31,12 @@ class KPISummary(NamedTuple): overdue_invoices: int # Tax reserves vat_reserve: Decimal - income_tax_reserve: Decimal - spendable_income: Decimal + income_tax_reserve: Optional[Decimal] # None without tax rules + spendable_income: Optional[Decimal] # None without tax rules tax_currency: str = "EUR" - country_supported: bool = True + country_supported: bool = True # whether there are tax rules to estimate income tax with country: str = "" + tax_unavailable_reason: Optional[str] = None def to_rpc_dict(self) -> dict: d = self._asdict() @@ -44,8 +45,10 @@ def to_rpc_dict(self) -> dict: d["outstanding_amount_formatted"] = fmt_currency(self.outstanding_amount, tc) d["overdue_amount_formatted"] = fmt_currency(self.overdue_amount, tc) d["vat_reserve_formatted"] = fmt_currency(self.vat_reserve, tc) - d["income_tax_reserve_formatted"] = fmt_currency(self.income_tax_reserve, tc) - d["spendable_income_formatted"] = fmt_currency(self.spendable_income, tc) + d["income_tax_reserve_formatted"] = ( + fmt_currency(self.income_tax_reserve, tc) if self.income_tax_reserve is not None else "—" + ) + d["spendable_income_formatted"] = fmt_currency(self.spendable_income, tc) if self.spendable_income is not None else "—" d["effective_hourly_rate_formatted"] = ( fmt_currency(self.effective_hourly_rate, tc) if self.effective_hourly_rate is not None else "—" ) @@ -134,7 +137,6 @@ def compute_kpis( utilization_rate = float(total_hours / available_hours) # The same inputs as the Tax page, so Spendable Income equals its Safe to Spend. - country_supported = has_tax_model(country) spending = compute_spendable_income( invoices, country, @@ -144,10 +146,6 @@ def compute_kpis( time_data=time_data, contracted=contracted_this_year(projects, invoices, time_data, currency=currency), ) - vat_reserve = spending.vat_reserve - income_tax_reserve = spending.income_tax_reserve - spendable_income = spending.spendable - return KPISummary( total_revenue=total_revenue, total_revenue_ytd=total_revenue_ytd, @@ -159,12 +157,13 @@ def compute_kpis( active_contracts=active_contracts, unpaid_invoices=unpaid_invoices, overdue_invoices=overdue_invoices, - vat_reserve=vat_reserve, - income_tax_reserve=income_tax_reserve, - spendable_income=spendable_income, + vat_reserve=spending.vat_reserve, + income_tax_reserve=spending.income_tax_reserve, + spendable_income=spending.spendable, tax_currency=currency, - country_supported=country_supported, + country_supported=spending.tax_unavailable_reason is None, country=country, + tax_unavailable_reason=spending.tax_unavailable_reason, ) @@ -226,6 +225,9 @@ def monthly_spendable_breakdown( - net_revenue: gross_revenue - vat_due - income_tax_true_up: monthly delta in YTD tax reserve estimate - spendable: net_revenue - income_tax_true_up + + Without tax rules for a month of this year, its true-up and spendable + figure are None. """ today = datetime.date.today() start = (today - datetime.timedelta(days=30 * n_months)).replace(day=1) @@ -270,27 +272,28 @@ def monthly_spendable_breakdown( m["net_revenue"] = m["gross_revenue"] - m["vat_due"] year_str, month_str = key.split("-") month_start = datetime.date(int(year_str), int(month_str), 1) - if month_start.year == today.year: - cumulative_net_ytd += m["net_revenue"] - taxable = cumulative_net_ytd - deductions - if taxable <= 0: - cumulative_reserve = Decimal(0) - else: - try: - month_end = (month_start + datetime.timedelta(days=32)).replace(day=1) - datetime.timedelta(days=1) - as_of = min(month_end, today) - tax_system = get_tax_system(country, date=as_of) - annual_tax = tax_system.income_tax(taxable) - annual_soli = tax_system.solidarity_surcharge(annual_tax) - cumulative_reserve = (annual_tax + annual_soli).quantize(Decimal("0.01")) - except NotImplementedError: - cumulative_reserve = Decimal(0) - - m["income_tax_true_up"] = cumulative_reserve - previous_reserve - previous_reserve = cumulative_reserve - else: + if month_start.year != today.year: m["income_tax_true_up"] = Decimal(0) + m["spendable"] = m["net_revenue"] + continue + cumulative_net_ytd += m["net_revenue"] + month_end = (month_start + datetime.timedelta(days=32)).replace(day=1) - datetime.timedelta(days=1) + try: + tax_system = get_tax_system(country, date=min(month_end, today)) + except NoTaxRules: + # Without tax rules there is no true-up, and no spendable figure after it. + m["income_tax_true_up"] = m["spendable"] = None + continue + taxable = cumulative_net_ytd - deductions + cumulative_reserve = Decimal(0) + if taxable > 0: + annual_tax = tax_system.income_tax(taxable) + annual_soli = tax_system.solidarity_surcharge(annual_tax) + cumulative_reserve = (annual_tax + annual_soli).quantize(Decimal("0.01")) + + m["income_tax_true_up"] = cumulative_reserve - previous_reserve + previous_reserve = cumulative_reserve m["spendable"] = m["net_revenue"] - m["income_tax_true_up"] return [months[k] for k in sorted_keys] diff --git a/tuttle/tax.py b/tuttle/tax.py index e1b79793..802c4322 100644 --- a/tuttle/tax.py +++ b/tuttle/tax.py @@ -25,6 +25,15 @@ _DATA_DIR = Path(__file__).parent / "tax_data" +class NoTaxRules(NotImplementedError): + """There are no tax rules to estimate income tax with. + + Its message says what is missing, in one sentence fit to show the user. + Every figure after income tax depends on it, so callers show none rather + than one computed with a tax of zero. + """ + + # ── TaxParams: one loaded year for one country ─────────────── @@ -87,9 +96,7 @@ def _resolve_country(country: str) -> str: _load_all() canonical = _ALIAS_MAP.get(country) if canonical is None: - raise NotImplementedError( - f"Tax system for '{country}' not yet implemented. Supported: {', '.join(sorted(_ALIAS_MAP.keys()))}" - ) + raise NoTaxRules(f"No tax model for {country}." if country else "No operating country set.") return canonical @@ -354,7 +361,7 @@ def get_tax_system( Defaults to today. Raises: - NotImplementedError: If the country has no data packages. + NoTaxRules: If the country has no data packages. """ if date is None: date = datetime.date.today() diff --git a/tuttle/tax_reserves.py b/tuttle/tax_reserves.py index 8d1ba29c..7483e383 100644 --- a/tuttle/tax_reserves.py +++ b/tuttle/tax_reserves.py @@ -15,7 +15,7 @@ from .fx import convert, fx_haircut from .fx import rate as fx_rate from .model import Invoice, Project, RecurringExpense -from .tax import get_tax_system +from .tax import NoTaxRules, get_tax_system from .time import Cycle, TimeUnit from .timetracking import drop_completed, drop_invoiced, sum_hours_by_tag @@ -46,7 +46,7 @@ class DynamicExpenseLine(NamedTuple): title: str rate: Decimal # percentage of the income it is charged on - amount: Decimal + amount: Optional[Decimal] # None when charged on taxed income and the tax is unknown tax_deductible: bool @@ -58,8 +58,8 @@ class SpendableIncome(NamedTuple): net_revenue_ytd: Decimal # gross minus VAT taxable_profit: Decimal # net_revenue - business_expenses vat_reserve: Decimal # VAT to set aside - income_tax_reserve: Decimal # estimated income tax + soli - spendable: Decimal # post-tax minus non-deductible expenses and FX fee + income_tax_reserve: Optional[Decimal] # estimated income tax + soli; None without tax rules + spendable: Optional[Decimal] # post-tax minus non-deductible expenses and FX fee; None without tax rules # breakdown by income source received_gross: Decimal # paid invoices, gross received_net: Decimal # paid invoices, net of VAT @@ -70,10 +70,11 @@ class SpendableIncome(NamedTuple): conversion_fee: Decimal = Decimal(0) # FX spread on foreign-currency revenue # income-dependent (dynamic) expenses, split by where they land in the waterfall dynamic_expenses_deductible: Decimal = Decimal(0) # charged on the taxable profit - dynamic_expenses_post_tax: Decimal = Decimal(0) # charged on post-tax money + dynamic_expenses_post_tax: Optional[Decimal] = Decimal(0) # charged on post-tax money; None without tax rules tax_base: Decimal = Decimal(0) # taxable_profit - dynamic_expenses_deductible dynamic_expenses: list = [] # per-expense DynamicExpenseLine, never mutated fixed_expenses_post_tax: Decimal = Decimal(0) # non-deductible fixed expenses for the year + tax_unavailable_reason: Optional[str] = None # why there is no tax estimate, e.g. "No tax model for Norway." def _invoice_currency(inv: Invoice) -> Optional[str]: @@ -233,6 +234,9 @@ def compute_income_tax_reserve( *income* and the returned amounts are in *currency* (default: the tax 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. """ today = datetime.date.today() _zero = IncomeTaxReserve( @@ -245,10 +249,7 @@ def compute_income_tax_reserve( ref_date = datetime.date(year, 7, 1) if year is not None else today - try: - tax_system = get_tax_system(country, date=ref_date) - except NotImplementedError: - return _zero + tax_system = get_tax_system(country, date=ref_date) taxable_income = income - deductions if taxable_income <= 0: @@ -322,6 +323,10 @@ def compute_spendable_income( *contracted* is net revenue still expected from committed contracts this 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. """ today = datetime.date.today() # Any year but the current one is over: it has no planned or contracted work. @@ -398,26 +403,38 @@ def compute_spendable_income( # Deductible provisions (health insurance, Rürup) shrink the income the tax # is assessed on; the rest is paid out of money that was already taxed. tax_base = taxable_profit - dyn_deductible - tax_reserve = compute_income_tax_reserve(tax_base, country, deductions, year=year, currency=currency) - - post_tax = tax_base - tax_reserve.ytd_reserve - fixed_post_tax - - # Post-tax dynamic expenses are charged on earned income too: planned - # revenue, and its share of the tax, stay out of their base. The earned - # month bears its part of the year's tax. - earned_base = earned_monthly - _lines_total(deductible_lines) - earned_tax = tax_reserve.ytd_reserve * max(earned_base, Decimal(0)) / tax_base if tax_base > 0 else Decimal(0) - post_tax_lines = _dynamic_lines(dynamic_expenses, earned_base - earned_tax - post_tax_monthly, deductible=False) - dyn_post_tax = (_lines_total(post_tax_lines) * 12).quantize(cent) - - # The per-expense lines are monthly; the aggregates above cover the year. - dynamic_lines = [line._replace(amount=(line.amount * 12).quantize(cent)) for line in deductible_lines + post_tax_lines] # The bank/Wise spread never reduces taxable revenue — only what lands in # the account, so it is subtracted after the tax reserve, not before. conversion_fee = (foreign_net * fx_haircut() / 100).quantize(cent) - spendable = post_tax - dyn_post_tax - conversion_fee + try: + tax_reserve = compute_income_tax_reserve(tax_base, country, deductions, year=year, currency=currency) + except NoTaxRules as e: + # Without tax rules nothing after the tax is known: no tax, no Safe to + # Spend, and no expense charged on taxed income. + no_tax = str(e) + income_tax = dyn_post_tax = spendable = None + post_tax_lines = _dynamic_lines(dynamic_expenses, None, deductible=False) + else: + no_tax = None + income_tax = tax_reserve.ytd_reserve + post_tax = tax_base - income_tax - fixed_post_tax + + # Post-tax dynamic expenses are charged on earned income too: planned + # revenue, and its share of the tax, stay out of their base. The earned + # month bears its part of the year's tax. + earned_base = earned_monthly - _lines_total(deductible_lines) + earned_tax = income_tax * max(earned_base, Decimal(0)) / tax_base if tax_base > 0 else Decimal(0) + post_tax_lines = _dynamic_lines(dynamic_expenses, earned_base - earned_tax - post_tax_monthly, deductible=False) + dyn_post_tax = (_lines_total(post_tax_lines) * 12).quantize(cent) + spendable = post_tax - dyn_post_tax - conversion_fee + + # The per-expense lines are monthly; the aggregates above cover the year. + dynamic_lines = [ + line._replace(amount=None if line.amount is None else (line.amount * 12).quantize(cent)) + for line in deductible_lines + post_tax_lines + ] return SpendableIncome( gross_revenue_ytd=gross_invoiced, @@ -425,7 +442,7 @@ def compute_spendable_income( net_revenue_ytd=net_invoiced, taxable_profit=taxable_profit, vat_reserve=vat_invoiced, - income_tax_reserve=tax_reserve.ytd_reserve, + income_tax_reserve=income_tax, spendable=spendable, received_gross=received_gross, received_net=received_net, @@ -439,6 +456,7 @@ def compute_spendable_income( tax_base=tax_base, dynamic_expenses=dynamic_lines, fixed_expenses_post_tax=fixed_post_tax, + tax_unavailable_reason=no_tax, ) @@ -477,15 +495,19 @@ def dynamic_monthly(expense: RecurringExpense, monthly_income: Decimal) -> Decim def _dynamic_lines( expenses: List[RecurringExpense], - monthly_income: Decimal, + monthly_income: Optional[Decimal], deductible: bool, ) -> List[DynamicExpenseLine]: - """Monthly amount of each dynamic expense matching *deductible*.""" + """Monthly amount of each dynamic expense matching *deductible*. + + An unknown *monthly_income* (taxed income without tax rules) leaves the + amounts unknown too. + """ return [ DynamicExpenseLine( title=e.title, rate=e.rate, - amount=dynamic_monthly(e, monthly_income), + amount=None if monthly_income is None else dynamic_monthly(e, monthly_income), tax_deductible=bool(e.tax_deductible), ) for e in expenses @@ -538,12 +560,15 @@ class EffectiveSalary(NamedTuple): conversion_fee_monthly — bank spread on foreign-currency revenue per month currency — ISO 4217 currency code period_start, period_end — the months the averages are taken over + tax_unavailable_reason — why there are no tax rules to estimate with; the + salaries, the income tax and the expenses charged on taxed income are + then None """ - conservative_monthly: Decimal - optimistic_monthly: Decimal + conservative_monthly: Optional[Decimal] + optimistic_monthly: Optional[Decimal] monthly_expenses: Decimal - income_tax_reserve_monthly: Decimal + income_tax_reserve_monthly: Optional[Decimal] vat_reserve_monthly: Decimal currency: str dynamic_expenses: list = [] # per-expense DynamicExpenseLine, never mutated @@ -551,6 +576,7 @@ class EffectiveSalary(NamedTuple): conversion_fee_monthly: Decimal = Decimal(0) period_start: Optional[datetime.date] = None period_end: Optional[datetime.date] = None + tax_unavailable_reason: Optional[str] = None class _Revenue(NamedTuple): @@ -565,12 +591,13 @@ def __add__(self, other: "_Revenue") -> "_Revenue": class _SalaryScenario(NamedTuple): - salary: Decimal + salary: Optional[Decimal] gross: Decimal vat: Decimal - income_tax: Decimal + income_tax: Optional[Decimal] conversion_fee: Decimal dynamic_lines: List[DynamicExpenseLine] + no_tax: Optional[str] = None def compute_effective_salary( @@ -590,6 +617,9 @@ def compute_effective_salary( The month in progress is left out: most of its invoices are not written yet, so counting it would lower the average every time a month begins. + + Without tax rules for *country* there is no salary figure: see + ``EffectiveSalary.tax_unavailable_reason``. """ today = datetime.date.today() this_month = today.replace(day=1) @@ -642,13 +672,19 @@ def scenario(revenue: _Revenue) -> _SalaryScenario: profit = gross - vat - monthly_exp_deductible deductible_lines = _dynamic_lines(dynamic_expenses, profit, deductible=True) tax_base = profit - _lines_total(deductible_lines) - # Tax a year of this monthly profit: progressive tax on the year-to-date - # sum would treat a few months of income as a whole year's. - income_tax = (compute_income_tax_reserve(tax_base * 12, country, currency=currency).ytd_reserve / 12).quantize(cent) - post_tax = tax_base - income_tax - (monthly_exp - monthly_exp_deductible) - post_tax_lines = _dynamic_lines(dynamic_expenses, post_tax, deductible=False) # The bank spread never reduces taxable revenue, only what lands in the account. conversion_fee = (revenue.foreign_net / months * fx_spread / 100).quantize(cent) + try: + # Tax a year of this monthly profit: progressive tax on the year-to-date + # sum would treat a few months of income as a whole year's. + yearly_tax = compute_income_tax_reserve(tax_base * 12, country, currency=currency).ytd_reserve + except NoTaxRules as e: + # Without tax rules there is no salary, and nothing charged on taxed income. + unknown_lines = _dynamic_lines(dynamic_expenses, None, deductible=False) + return _SalaryScenario(None, gross, vat, None, conversion_fee, deductible_lines + unknown_lines, str(e)) + income_tax = (yearly_tax / 12).quantize(cent) + post_tax = tax_base - income_tax - (monthly_exp - monthly_exp_deductible) + post_tax_lines = _dynamic_lines(dynamic_expenses, post_tax, deductible=False) salary = post_tax - _lines_total(post_tax_lines) - conversion_fee return _SalaryScenario(salary, gross, vat, income_tax, conversion_fee, deductible_lines + post_tax_lines) @@ -667,6 +703,7 @@ def scenario(revenue: _Revenue) -> _SalaryScenario: conversion_fee_monthly=optimistic.conversion_fee, period_start=period_start, period_end=period_end, + tax_unavailable_reason=optimistic.no_tax, ) diff --git a/tuttle_tests/test_dashboard.py b/tuttle_tests/test_dashboard.py index 06dd025a..93cd9554 100644 --- a/tuttle_tests/test_dashboard.py +++ b/tuttle_tests/test_dashboard.py @@ -906,7 +906,7 @@ def test_empty_data(self): def test_tax_reserves_populated(self, paid_invoice, active_contract, project): # Without an agreed volume nothing is contracted, so the paid invoice is all the income. active_contract.volume = None - kpis = compute_kpis([paid_invoice], [active_contract], [project]) + kpis = compute_kpis([paid_invoice], [active_contract], [project], country="Germany") assert kpis.vat_reserve >= 0 assert kpis.income_tax_reserve >= 0 assert kpis.spendable_income <= kpis.total_revenue_ytd @@ -914,13 +914,13 @@ def test_tax_reserves_populated(self, paid_invoice, active_contract, project): def test_spendable_less_than_gross(self, paid_invoice, active_contract, project): active_contract.volume = None - kpis = compute_kpis([paid_invoice], [active_contract], [project]) + kpis = compute_kpis([paid_invoice], [active_contract], [project], country="Germany") if kpis.total_revenue_ytd > 0: # Revenue is net of VAT; what clients paid also includes the VAT. assert kpis.spendable_income < kpis.total_revenue_ytd + kpis.vat_reserve def test_empty_data_tax_reserves(self): - kpis = compute_kpis([], [], []) + kpis = compute_kpis([], [], [], country="Germany") assert kpis.vat_reserve == 0 assert kpis.income_tax_reserve == 0 assert kpis.spendable_income == 0 diff --git a/tuttle_tests/test_no_tax_figures_without_tax_model.py b/tuttle_tests/test_no_tax_figures_without_tax_model.py new file mode 100644 index 00000000..f183bb52 --- /dev/null +++ b/tuttle_tests/test_no_tax_figures_without_tax_model.py @@ -0,0 +1,164 @@ +"""Without a tax model there is no after-tax figure: no tax of zero passed off as an estimate.""" + +import datetime +from decimal import Decimal +from types import SimpleNamespace + +import pytest +from sqlmodel import Session, SQLModel, create_engine + +from tuttle.app.core.abstractions import SQLModelDataSourceMixin +from tuttle.app.tax.intent import TaxIntent +from tuttle.app_db import AppDatabase +from tuttle.forecasting import cash_flow_series +from tuttle.kpi import compute_kpis, monthly_spendable_breakdown +from tuttle.model import Address, Client, Contract, Invoice, InvoiceItem, Project, RecurringExpense +from tuttle.tax import NoTaxRules +from tuttle.tax_reserves import compute_effective_salary, compute_income_tax_reserve, compute_spendable_income +from tuttle.time import Cycle, TimeUnit + +TODAY = datetime.date.today() +LAST_MONTH = TODAY.replace(day=1) - datetime.timedelta(days=1) + + +@pytest.fixture(autouse=True) +def euro_aggregates(): + """Keep every figure in EUR, whatever the country's own currency.""" + AppDatabase().set_setting("currency.primary", "EUR") + + +def _invoice(date: datetime.date, net: str, paid: bool = True) -> Invoice: + client = Client( + name="Central Services", + address=Address(street="Main Street", number="42", postal_code="0150", city="Oslo", country="Norway"), + ) + contract = Contract( + title="Heating", + client=client, + rate=Decimal("100"), + currency="EUR", + 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=paid) + InvoiceItem(invoice=inv, quantity=1, unit="flat", unit_price=Decimal(net), VAT_rate=Decimal("0.25"), description="Work") + return inv + + +def _expenses() -> list: + return [ + RecurringExpense(title="Rent", amount=Decimal("1000"), period=Cycle.monthly, tax_deductible=True), + RecurringExpense(title="Health", amount=Decimal(0), period=Cycle.monthly, rate=Decimal("10"), tax_deductible=True), + RecurringExpense(title="Savings", amount=Decimal(0), period=Cycle.monthly, rate=Decimal("10"), tax_deductible=False), + ] + + +def test_the_reason_names_what_is_missing(): + with pytest.raises(NoTaxRules, match=r"^No tax model for Norway\.$"): + compute_income_tax_reserve(Decimal(50000), "Norway") + with pytest.raises(NoTaxRules, match=r"^No operating country set\.$"): + compute_income_tax_reserve(Decimal(0), "") + + +def test_tax_view_keeps_pre_tax_figures_and_shows_nothing_after_tax(): + spending = compute_spendable_income( + [_invoice(TODAY.replace(day=1), "5000")], "Norway", expenses=_expenses(), currency="EUR" + ) + + assert spending.net_revenue_ytd == 5000 + assert spending.vat_reserve == 1250 + assert spending.business_expenses == 12000 + assert spending.tax_base == spending.taxable_profit - spending.dynamic_expenses_deductible + assert spending.income_tax_reserve is None + assert spending.dynamic_expenses_post_tax is None + assert spending.spendable is None + assert spending.tax_unavailable_reason == "No tax model for Norway." + lines = {line.title: line.amount for line in spending.dynamic_expenses} + assert lines["Health"] is not None + assert lines["Savings"] is None + + +def test_a_country_with_a_tax_model_still_gets_an_estimate(): + spending = compute_spendable_income([_invoice(TODAY.replace(day=1), "50000")], "Germany", currency="EUR") + assert spending.income_tax_reserve > 0 + assert spending.spendable is not None + assert spending.tax_unavailable_reason is None + + +def test_dashboard_shows_no_spendable_income(): + kpis = compute_kpis([_invoice(TODAY.replace(day=1), "5000")], [], [], country="Norway") + + assert kpis.total_revenue_ytd == 5000 + assert kpis.income_tax_reserve is None + assert kpis.spendable_income is None + assert not kpis.country_supported + assert kpis.tax_unavailable_reason == "No tax model for Norway." + rpc = kpis.to_rpc_dict() + assert rpc["spendable_income_formatted"] == "—" + assert rpc["income_tax_reserve_formatted"] == "—" + + +def test_salary_shows_no_salary_but_keeps_what_does_not_depend_on_tax(): + salary = compute_effective_salary([_invoice(LAST_MONTH, "5000")], _expenses(), "Norway", currency="EUR") + + assert salary.conservative_monthly is None + assert salary.optimistic_monthly is None + assert salary.income_tax_reserve_monthly is None + assert salary.tax_unavailable_reason == "No tax model for Norway." + assert salary.gross_monthly == 6250 + assert salary.vat_reserve_monthly == 1250 + assert salary.monthly_expenses == 1000 + lines = {line.title: line.amount for line in salary.dynamic_expenses} + assert lines["Health"] == 400 + assert lines["Savings"] is None + + +def test_cash_flow_reserves_no_tax_and_says_so(): + overdue = _invoice(TODAY - datetime.timedelta(days=60), "5000", paid=False) + cash = cash_flow_series([overdue], [], [], _expenses(), None, forecast_months=3, country="Norway", today=TODAY) + + assert cash["tax_included"] is False + assert cash["tax_unavailable_reason"] == "No tax model for Norway." + assert cash["left_out_with_tax"] == ["Savings"] + this_month, next_month = cash["buckets"][0], cash["buckets"][1] + assert this_month["inflow_invoiced"] == 5000 + # Rent and the deductible 10% on the 4,000 left after it; no tax, no savings. + assert this_month["outflow"] == 1400 + assert next_month["outflow"] == 1000 + + +def test_cash_flow_with_a_tax_model_includes_tax(): + overdue = _invoice(TODAY - datetime.timedelta(days=60), "5000", paid=False) + cash = cash_flow_series([overdue], [], [], _expenses(), None, forecast_months=3, country="Germany", today=TODAY) + + assert cash["tax_included"] is True + assert cash["tax_unavailable_reason"] is None + assert cash["left_out_with_tax"] == [] + + +def test_monthly_breakdown_shows_no_spendable_figure_this_year(): + months = monthly_spendable_breakdown([_invoice(TODAY.replace(day=1), "5000")], country="Norway", n_months=3) + this_month = next(m for m in months if m["month"] == f"{TODAY:%Y-%m}") + assert this_month["net_revenue"] == 5000 + assert this_month["income_tax_true_up"] is None + assert this_month["spendable"] is None + + +def test_tax_view_estimate_has_no_tax_reserve(monkeypatch): + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + monkeypatch.setattr(SQLModelDataSourceMixin, "create_session", lambda self: Session(engine)) + intent = TaxIntent() + intent._time_data_source = SimpleNamespace(get_data_frame=lambda: None) + monkeypatch.setattr(intent, "_get_country", lambda: "Norway") + + result = intent.get_income_tax_estimate() + + assert result.was_intent_successful + assert result.data["tax_reserve"] is None + assert result.data["brackets"] == [] + assert result.data["country_supported"] is False + assert result.data["tax_unavailable_reason"] == "No tax model for Norway." diff --git a/tuttle_tests/test_tax.py b/tuttle_tests/test_tax.py index 59bcf4ab..9a6c38f1 100644 --- a/tuttle_tests/test_tax.py +++ b/tuttle_tests/test_tax.py @@ -19,7 +19,7 @@ RecurringExpense, Timesheet, ) -from tuttle.tax import TaxSystem, available_years, get_tax_system, supported_countries +from tuttle.tax import NoTaxRules, TaxSystem, available_years, get_tax_system, supported_countries from tuttle.tax_reserves import ( _normalize_to_monthly, compute_income_tax_reserve, @@ -172,7 +172,7 @@ def test_get_tax_system_deutschland(self): assert system.country == "Germany" def test_get_tax_system_unsupported(self): - with pytest.raises(NotImplementedError, match="not yet implemented"): + with pytest.raises(NoTaxRules, match="No tax model for Narnia."): get_tax_system("Narnia") def test_supported_countries(self): diff --git a/ui/src/api/entity.ts b/ui/src/api/entity.ts index 7a8538bd..b7f7095d 100644 --- a/ui/src/api/entity.ts +++ b/ui/src/api/entity.ts @@ -4,8 +4,9 @@ import type { Entity } from "./types"; -/** One income-dependent recurring expense, already resolved to an amount. */ -export type DynamicLine = { title: string; rate: number; amount: number; tax_deductible: boolean }; +/** One income-dependent recurring expense, already resolved to an amount. + * The amount is null when it is charged on taxed income and the tax is unknown. */ +export type DynamicLine = { title: string; rate: number; amount: number | null; tax_deductible: boolean }; export function str(e: Entity, key: string): string { const v = e[key]; @@ -23,6 +24,11 @@ export function num(e: Entity, key: string): number { return 0; } +/** A number the core may leave out (null), e.g. a figure that needs a tax estimate. */ +export function maybeNum(e: Entity, key: string): number | null { + return e[key] == null ? null : num(e, key); +} + export function int(e: Entity, key: string): number { return Math.round(num(e, key)); } diff --git a/ui/src/components/dashboard/CashFlowChart.tsx b/ui/src/components/dashboard/CashFlowChart.tsx index a6f3a748..a9546e5a 100644 --- a/ui/src/components/dashboard/CashFlowChart.tsx +++ b/ui/src/components/dashboard/CashFlowChart.tsx @@ -34,6 +34,19 @@ export interface CashFlowSeries { total_inflow: number; total_outflow: number; net_cash_flow: number; + /** False without tax rules: outflows then hold no income tax, nor what is charged on taxed income. */ + tax_included: boolean; + tax_unavailable_reason: string | null; + left_out_with_tax: string[]; +} + +/** "Income tax and Pension not included: no tax model for Norway." — or "" when tax is included. */ +function taxNote(data: CashFlowSeries | null): string { + if (!data || data.tax_included !== false) return ""; + const items = ["Income tax", ...(data.left_out_with_tax ?? [])]; + const list = items.length > 1 ? `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}` : items[0]; + const reason = data.tax_unavailable_reason ?? ""; + return `${list} not included: ${reason.charAt(0).toLowerCase()}${reason.slice(1)}`; } type SeriesKey = "inflow_invoiced" | "inflow_planned" | "inflow_contracted" | "outflow" | "balance"; @@ -163,6 +176,7 @@ export function CashFlowChart() { visible.includes(k), ); const netCashFlow = data?.net_cash_flow ?? 0; + const note = taxNote(data); const isPositive = netCashFlow >= 0; const windowLabel = useMemo(() => { @@ -421,6 +435,8 @@ export function CashFlowChart() { ); })} + + {note &&

{note}

} ); } diff --git a/ui/src/components/salary/SalaryView.tsx b/ui/src/components/salary/SalaryView.tsx index dadb92fe..eae42f7c 100644 --- a/ui/src/components/salary/SalaryView.tsx +++ b/ui/src/components/salary/SalaryView.tsx @@ -5,7 +5,7 @@ import { EmptyStateIntro } from "../shared/EmptyStateIntro"; import { LoadError, LoadingState } from "../shared/LoadStates"; import { PageLayout } from "../shared/ToolbarButtons"; import type { Entity } from "../../api/types"; -import { num, str, type DynamicLine } from "../../api/entity"; +import { num, maybeNum, str, type DynamicLine } from "../../api/entity"; function fmtMonth(iso: string): string { const [year, month] = iso.split("-").map(Number); @@ -19,17 +19,19 @@ function fmt(value: number, currency = "EUR"): string { } type SalaryData = { - conservative: number; - optimistic: number; + // Null without tax rules: the core then gives no salary, and noTax says why. + conservative: number | null; + optimistic: number | null; grossMonthly: number; monthlyExpenses: number; dynamicLines: DynamicLine[]; - incomeTaxReserve: number; + incomeTaxReserve: number | null; vatReserve: number; conversionFee: number; periodStart: string; periodEnd: string; currency: string; + noTax: string; }; export function SalaryView() { @@ -37,8 +39,6 @@ export function SalaryView() { const [target, setTarget] = useState(null); const [loading, setLoading] = useState(true); const [loadFailed, setLoadFailed] = useState(false); - const [countrySupported, setCountrySupported] = useState(true); - const [taxCountry, setTaxCountry] = useState(""); useEffect(() => { load(); }, []); @@ -51,23 +51,22 @@ export function SalaryView() { const d = res.data as Entity; const sal = d.salary as Entity | undefined; const cur = (d.currency as string) || "EUR"; - setCountrySupported(d.country_supported !== false); - setTaxCountry((d.country as string) || ""); if (sal) { - const con = num(sal, "conservative_monthly"); - const opt = num(sal, "optimistic_monthly"); + const con = maybeNum(sal, "conservative_monthly"); + const opt = maybeNum(sal, "optimistic_monthly"); setSalary({ conservative: con, optimistic: opt, grossMonthly: num(sal, "gross_monthly"), monthlyExpenses: num(sal, "monthly_expenses"), dynamicLines: (sal.dynamic_expenses as DynamicLine[]) || [], - incomeTaxReserve: num(sal, "income_tax_reserve_monthly"), + incomeTaxReserve: maybeNum(sal, "income_tax_reserve_monthly"), vatReserve: num(sal, "vat_reserve_monthly"), conversionFee: num(sal, "conversion_fee_monthly"), periodStart: str(sal, "period_start"), periodEnd: str(sal, "period_end"), currency: cur, + noTax: str(sal, "tax_unavailable_reason"), }); if (target === null) setTarget(con); } @@ -87,16 +86,35 @@ export function SalaryView() { return ( - - + {salary.conservative === null || salary.optimistic === null ? ( + + ) : ( + + )} + ); } -function SalaryDial({ salary, target, onTargetChange }: { - salary: SalaryData; target: number; onTargetChange: (v: number) => void; +function NoSalary({ reason }: { reason: string }) { + return ( +
+
+ +

Monthly Salary

+
+
+
—
+
{reason}
+
+
+ ); +} + +function SalaryDial({ conservative, optimistic, currency, target, onTargetChange }: { + conservative: number; optimistic: number; currency: string; target: number; onTargetChange: (v: number) => void; }) { - const { conservative, optimistic, currency } = salary; const sliderMin = Math.min(conservative, 0); const sliderMax = Math.max(optimistic * 1.1, 1); @@ -160,17 +178,18 @@ function SalaryDial({ salary, target, onTargetChange }: { ); } -function MonthlyBreakdown({ salary, countrySupported, taxCountry }: { salary: SalaryData; countrySupported: boolean; taxCountry: string }) { +function MonthlyBreakdown({ salary }: { salary: SalaryData }) { const { optimistic, grossMonthly: gross, vatReserve, incomeTaxReserve, monthlyExpenses, dynamicLines, conversionFee, periodStart, periodEnd, currency } = salary; if (gross <= 0) return null; const from = periodStart ? fmtMonth(periodStart) : ""; const to = periodEnd ? fmtMonth(periodEnd) : ""; const period = from === to ? from : `${from} – ${to}`; - const items: { label: string; amount: number; color: string; bold?: boolean; muted?: boolean }[] = [ + // A null amount is a figure the core could not estimate: "—", no bar. + const items: { label: string; amount: number | null; color: string; bold?: boolean }[] = [ { label: "Gross Revenue / month", amount: gross, color: "var(--color-status-info)" }, { label: "VAT (to remit)", amount: vatReserve, color: "var(--color-status-warning)" }, - { label: "Est. Income Tax", amount: incomeTaxReserve, color: "var(--color-status-warning)", muted: !countrySupported }, + { label: "Est. Income Tax", amount: incomeTaxReserve, color: "var(--color-status-warning)" }, ...(monthlyExpenses > 0 ? [{ label: "Fixed Recurring Expenses", amount: monthlyExpenses, color: "var(--color-status-warning)" }] : []), @@ -182,7 +201,7 @@ function MonthlyBreakdown({ salary, countrySupported, taxCountry }: { salary: Sa ...(conversionFee > 0 ? [{ label: "Currency Conversion", amount: conversionFee, color: "var(--color-status-warning)" }] : []), - { label: "= Available Salary", amount: optimistic, color: optimistic >= 0 ? "var(--color-status-success)" : "var(--color-status-danger)", bold: true }, + { label: "= Available Salary", amount: optimistic, color: optimistic === null ? "var(--color-muted)" : optimistic >= 0 ? "var(--color-status-success)" : "var(--color-status-danger)", bold: true }, ]; return ( @@ -194,14 +213,14 @@ function MonthlyBreakdown({ salary, countrySupported, taxCountry }: { salary: Sa
{items.map((item, i) => { - const pct = Math.max(Math.abs(item.amount) / gross, 0.02); - const sign = item.amount < 0 ? "−" : ""; + const pct = item.amount === null ? 0 : Math.max(Math.abs(item.amount) / gross, 0.02); + const shown = item.amount === null ? "—" : `${item.amount < 0 ? "−" : ""}${fmt(Math.abs(item.amount), currency)}`; return (
{item.label} - {sign}{fmt(Math.abs(item.amount), currency)} + {shown}
diff --git a/ui/src/components/tax/TaxReservesView.tsx b/ui/src/components/tax/TaxReservesView.tsx index 1916134c..da5a3da5 100644 --- a/ui/src/components/tax/TaxReservesView.tsx +++ b/ui/src/components/tax/TaxReservesView.tsx @@ -5,7 +5,7 @@ import { EmptyStateIntro } from "../shared/EmptyStateIntro"; import { LoadError, LoadingState } from "../shared/LoadStates"; import { PageLayout } from "../shared/ToolbarButtons"; import type { Entity } from "../../api/types"; -import { str, num, bool, type DynamicLine } from "../../api/entity"; +import { str, num, maybeNum, bool, type DynamicLine } from "../../api/entity"; function fmt(value: number, currency = "EUR"): string { try { @@ -17,10 +17,6 @@ function fmtPct(value: number): string { return `${(value * 100).toFixed(1)}%`; } -function noTaxModel(country: string): string { - return `No tax model for ${country || "your country"} yet.`; -} - export function TaxReservesView() { const [spending, setSpending] = useState(null); const [taxEstimate, setTaxEstimate] = useState(null); @@ -80,8 +76,6 @@ export function TaxReservesView() { const sp = spending?.spending as Entity | undefined; const hasAnyIncome = sp && (num(sp, "gross_revenue_ytd") > 0 || num(sp, "planned_revenue") > 0 || num(sp, "contracted_revenue") > 0); - const countrySupported = taxEstimate ? taxEstimate.country_supported !== false : true; - const taxCountry = taxEstimate ? str(taxEstimate, "country") : ""; if (!hasAnyIncome && months.length === 0) { return l.tax_deductible); - const tax = num(sp!, "income_tax_reserve"); - const spendable = num(sp!, "spendable"); + // Without tax rules the core leaves out the tax and everything after it. + const tax = maybeNum(sp!, "income_tax_reserve"); + const spendable = maybeNum(sp!, "spendable"); + const noTax = str(sp!, "tax_unavailable_reason"); return (
@@ -136,9 +132,7 @@ export function TaxReservesView() { )} -
- -
+ {fixedPostTax > 0 && ( )} @@ -148,15 +142,15 @@ export function TaxReservesView() { {conversionFee > 0 && ( )} - {!countrySupported && ( -

{noTaxModel(taxCountry)}

+ {noTax && ( +

{noTax}

)} - = 0 ? "var(--color-status-success)" : "var(--color-status-danger)"} currency={currency} bold /> + = 0 ? "var(--color-status-success)" : "var(--color-status-danger)"} currency={currency} bold /> {totalBase > 0 && (
Effective reserve rate - {fmtPct((vat + tax) / totalBase)} of gross revenue + {tax === null ? "—" : `${fmtPct((vat + tax) / totalBase)} of gross revenue`}
)} @@ -220,10 +214,10 @@ function YearSelector({ years, selected, onChange }: { } function IncomeTaxSection({ data, currency }: { data: Entity; currency: string }) { - const tr = data.tax_reserve as Entity | undefined; + const tr = data.tax_reserve as Entity | null; const brackets = (data.brackets as Entity[]) || []; const country = str(data, "country"); - const supported = data.country_supported !== false; + const noTax = str(data, "tax_unavailable_reason"); const received = num(data, "received_net"); const outstanding = num(data, "outstanding_net"); @@ -248,12 +242,8 @@ function IncomeTaxSection({ data, currency }: { data: Entity; currency: string } {breakdown}
)} - {tr && ( -
- - -
- )} + + {brackets.length > 0 && (
Tax Brackets (marginal rates)
@@ -276,8 +266,8 @@ function IncomeTaxSection({ data, currency }: { data: Entity; currency: string }
)} - {!supported && ( -

{noTaxModel(country)} VAT reserves still apply.

+ {noTax && ( +

{noTax} VAT reserves still apply.

)}
@@ -296,18 +286,18 @@ function Section({ title, icon, className, children }: { title: string; icon?: R ); } +/** One row of the waterfall. A null amount is a figure the core could not estimate: "—", no bar. */ function WaterfallBar({ label, amount, total, color, currency, bold }: { - label: string; amount: number; total: number; color: string; currency: string; bold?: boolean; + label: string; amount: number | null; total: number; color: string; currency: string; bold?: boolean; }) { - const pct = total > 0 ? Math.max(Math.abs(amount) / total, 0.02) : 0; - const sign = amount < 0 ? "−" : ""; - const display = Math.abs(amount); + const pct = amount === null ? 0 : total > 0 ? Math.max(Math.abs(amount) / total, 0.02) : 0; + const shown = amount === null ? "—" : `${amount < 0 ? "−" : ""}${fmt(Math.abs(amount), currency)}`; return (
{label} - {sign}{fmt(display, currency)} + {shown}