From 97864eaf34a60085079063fe431c0101d367fa92 Mon Sep 17 00:00:00 2001 From: Christian Staudt <875194+clstaudt@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:32:18 +0200 Subject: [PATCH] Count Contracted revenue in the dashboard's Spendable Income The tax view estimates income tax and Safe to Spend on received, invoiced, planned and contracted revenue, but the dashboard left out Contracted. Its Spendable Income and income tax reserve came out lower than the tax view's whenever a contract had agreed work beyond the calendar. Both now take the year's contracted revenue from one function, so the dashboard and the tax view show the same figures. Co-Authored-By: Claude Opus 5.5 --- tuttle/app/tax/intent.py | 9 +-- tuttle/forecasting.py | 17 ++++++ tuttle/kpi.py | 9 ++- tuttle_tests/test_dashboard.py | 3 + tuttle_tests/test_kpi_spendable_contracted.py | 58 +++++++++++++++++++ 5 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 tuttle_tests/test_kpi_spendable_contracted.py diff --git a/tuttle/app/tax/intent.py b/tuttle/app/tax/intent.py index bae5c1db..e398e3cc 100644 --- a/tuttle/app/tax/intent.py +++ b/tuttle/app/tax/intent.py @@ -3,7 +3,7 @@ import datetime from decimal import Decimal -from ...forecasting import contracted_revenue, spread +from ...forecasting import contracted_this_year from ...fx import primary_currency from ...model import Invoice, Project, RecurringExpense, User from ...tax import ( @@ -45,12 +45,9 @@ def _get_tax_currency(self, country: str) -> str: def _contracted_this_year(self, year, invoices, projects, time_data, currency: str) -> Decimal: """Revenue committed contracts will still bring in from today to the end of the year.""" - today = datetime.date.today() - if year is not None and year != today.year: + if year is not None and year != datetime.date.today().year: return Decimal(0) - year_end = datetime.date(today.year, 12, 31) - slices = contracted_revenue(projects, invoices, time_data, year_end, currency=currency, today=today) - return Decimal(str(round(sum(spread(slices, "Y", today, year_end).values()), 2))) + return contracted_this_year(projects, invoices, time_data, currency=currency) def get_spendable_income(self, year: int | None = None) -> IntentResult: """Compute spendable income breakdown.""" diff --git a/tuttle/forecasting.py b/tuttle/forecasting.py index e30ac27f..db780119 100644 --- a/tuttle/forecasting.py +++ b/tuttle/forecasting.py @@ -435,6 +435,23 @@ def spread(slices: List[ContractedSlice], freq: str, start: datetime.date, end: return totals +def contracted_this_year( + projects: List[Project], + invoices: List[Invoice], + time_data: Optional[DataFrame], + currency: Optional[str] = None, + today: Optional[datetime.date] = None, +) -> Decimal: + """Net revenue committed contracts will still bring in from today to the end of the year. + + The Contracted part of the income every spendable-income figure is estimated on. + """ + today = today or datetime.date.today() + year_end = datetime.date(today.year, 12, 31) + slices = contracted_revenue(projects, invoices, time_data, year_end, currency=currency, today=today) + return Decimal(str(round(sum(spread(slices, "Y", today, year_end).values()), 2))) + + def cash_flow_projection( revenue_forecast: DataFrame, contracts: List[Contract], diff --git a/tuttle/kpi.py b/tuttle/kpi.py index 28f72fc2..e1f5f244 100644 --- a/tuttle/kpi.py +++ b/tuttle/kpi.py @@ -7,6 +7,7 @@ from pandas import DataFrame, Timestamp from .app.core.formatting import fmt_currency +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 @@ -135,7 +136,13 @@ def compute_kpis( # 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, expenses=expenses, currency=currency, projects=projects, time_data=time_data + invoices, + country, + expenses=expenses, + currency=currency, + projects=projects, + 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 diff --git a/tuttle_tests/test_dashboard.py b/tuttle_tests/test_dashboard.py index 41804c5d..1cf6d081 100644 --- a/tuttle_tests/test_dashboard.py +++ b/tuttle_tests/test_dashboard.py @@ -973,6 +973,8 @@ def test_empty_data(self): assert kpis.active_contracts == 0 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]) assert kpis.vat_reserve >= 0 assert kpis.income_tax_reserve >= 0 @@ -980,6 +982,7 @@ def test_tax_reserves_populated(self, paid_invoice, active_contract, project): assert kpis.tax_currency == "EUR" def test_spendable_less_than_gross(self, paid_invoice, active_contract, project): + active_contract.volume = None kpis = compute_kpis([paid_invoice], [active_contract], [project]) if kpis.total_revenue_ytd > 0: # Revenue is net of VAT; what clients paid also includes the VAT. diff --git a/tuttle_tests/test_kpi_spendable_contracted.py b/tuttle_tests/test_kpi_spendable_contracted.py new file mode 100644 index 00000000..c6233cc1 --- /dev/null +++ b/tuttle_tests/test_kpi_spendable_contracted.py @@ -0,0 +1,58 @@ +"""The dashboard's Spendable Income is the tax view's Safe to Spend, Contracted revenue included.""" + +import datetime +from decimal import Decimal + +import pytest + +from tuttle.app.tax.intent import TaxIntent +from tuttle.kpi import compute_kpis +from tuttle.model import Address, Client, Contract, Project, RecurringExpense +from tuttle.tax_reserves import compute_spendable_income +from tuttle.time import Cycle, TimeUnit + +TODAY = datetime.date.today() + + +@pytest.fixture +def project(): + """An agreed contract with 200 h left at 100, running into next year.""" + client = Client( + name="Central Services", + address=Address(street="Main Street", number="42", postal_code="55555", city="Metro", country="Germany"), + ) + contract = Contract( + title="Heating", + client=client, + start_date=TODAY - datetime.timedelta(days=30), + end_date=datetime.date(TODAY.year + 1, 6, 30), + rate=Decimal("100.00"), + currency="EUR", + unit=TimeUnit.hour, + units_per_workday=8, + volume=200, + term_of_payment=14, + billing_cycle=Cycle.monthly, + ) + return Project( + title="Boiler", + tag="#boiler", + description="", + start_date=contract.start_date, + end_date=contract.end_date, + contract=contract, + ) + + +def test_dashboard_spendable_income_equals_the_tax_views(project): + expenses = [RecurringExpense(title="Rent", amount=Decimal("500"), period=Cycle.monthly, tax_deductible=True)] + contracted = TaxIntent.__new__(TaxIntent)._contracted_this_year(None, [], [project], None, "EUR") + tax_view = compute_spendable_income( + [], "Germany", expenses=expenses, currency="EUR", projects=[project], contracted=contracted + ) + + kpis = compute_kpis([], [project.contract], [project], country="Germany", expenses=expenses) + + assert contracted > 0 + assert kpis.spendable_income == tax_view.spendable + assert kpis.income_tax_reserve == tax_view.income_tax_reserve