From 2abdde37d3ea738523bdb5121f168be00fa4a5fe Mon Sep 17 00:00:00 2001 From: Christian Staudt <875194+clstaudt@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:11:41 +0200 Subject: [PATCH] Show no after-tax figures without a tax model Without a tax model for the operating country, the income tax estimate came out as zero. The Tax view's Safe to Spend and reserve rate, the Salary page and the cash flow's outflows were computed with that zero and shown as real figures; only the dashboard card showed a dash. A missing tax model is now reported instead of taken as zero. Figures up to the tax base stay. Income tax, the expenses charged on taxed income, Safe to Spend and the salary show a dash, with one line saying what is missing, such as "No tax model for Norway." The cash flow keeps its inflows and expenses and says that it leaves out the income tax. Co-Authored-By: Claude Opus 5.5 --- tuttle/app/salary/intent.py | 3 +- tuttle/app/tax/intent.py | 26 +-- tuttle/forecasting.py | 23 ++- tuttle/kpi.py | 71 ++++---- tuttle/tax.py | 15 +- tuttle/tax_reserves.py | 113 ++++++++---- tuttle_tests/test_dashboard.py | 6 +- .../test_no_tax_figures_without_tax_model.py | 164 ++++++++++++++++++ tuttle_tests/test_tax.py | 4 +- ui/src/api/entity.ts | 10 +- ui/src/components/dashboard/CashFlowChart.tsx | 16 ++ ui/src/components/salary/SalaryView.tsx | 65 ++++--- ui/src/components/tax/TaxReservesView.tsx | 52 +++--- 13 files changed, 412 insertions(+), 156 deletions(-) create mode 100644 tuttle_tests/test_no_tax_figures_without_tax_model.py 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}