From b325a9970f3e1349cb2472dff82d9a271823efda Mon Sep 17 00:00:00 2001 From: Christian Staudt <875194+clstaudt@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:39:46 +0200 Subject: [PATCH] Deduct a full year of expenses in the tax estimate The tax view's income runs to the end of the year: invoices so far plus the planned and contracted work until December. Its expenses ran only to the current month, so the estimate taxed November's planned work without deducting November's rent, and Safe to Spend counted income from months whose costs it ignored. The breakdown was labelled year to date though it covers the whole year. Now the recurring expenses are counted for all twelve months, the same span as the income, and the breakdown is labelled with the year. Dynamic expenses keep their earned-income base, now the average month invoiced so far, charged for every month of the year. Issued invoices dated later in the year count too, since their timesheets already take the work out of Planned. Past years come out as before. Co-Authored-By: Claude Opus 5.5 --- tuttle/tax_reserves.py | 94 ++++++++-------- tuttle_tests/test_dynamic_expenses.py | 3 +- tuttle_tests/test_fx.py | 2 +- tuttle_tests/test_tax.py | 4 +- .../test_tax_view_full_year_expenses.py | 100 ++++++++++++++++++ ui/src/components/tax/TaxReservesView.tsx | 4 +- 6 files changed, 148 insertions(+), 59 deletions(-) create mode 100644 tuttle_tests/test_tax_view_full_year_expenses.py diff --git a/tuttle/tax_reserves.py b/tuttle/tax_reserves.py index bcdc62dd..8d1ba29c 100644 --- a/tuttle/tax_reserves.py +++ b/tuttle/tax_reserves.py @@ -53,8 +53,8 @@ class DynamicExpenseLine(NamedTuple): class SpendableIncome(NamedTuple): """What the freelancer can actually spend.""" - gross_revenue_ytd: Decimal # total invoiced amount (incl. VAT) - business_expenses: Decimal # tax-deductible fixed recurring expenses prorated to period + gross_revenue_ytd: Decimal # total invoiced amount of the year (incl. VAT) + business_expenses: Decimal # tax-deductible fixed recurring expenses for the year net_revenue_ytd: Decimal # gross minus VAT taxable_profit: Decimal # net_revenue - business_expenses vat_reserve: Decimal # VAT to set aside @@ -73,7 +73,7 @@ class SpendableIncome(NamedTuple): dynamic_expenses_post_tax: Decimal = Decimal(0) # charged on post-tax money 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 prorated to period + fixed_expenses_post_tax: Decimal = Decimal(0) # non-deductible fixed expenses for the year def _invoice_currency(inv: Invoice) -> Optional[str]: @@ -298,9 +298,11 @@ def compute_spendable_income( This answers the freelancer's core question: "How much of this money is mine?" - Income basis = received (paid) + outstanding (sent, unpaid) + planned - (calendar) + contracted (committed contract work for the rest of the year). - Drafts are not invoiced yet: their tracked time counts as planned. + Income basis = the year's received (paid) + outstanding (sent, unpaid) + invoices + planned (calendar) + contracted (committed contract work until + December). Drafts are not invoiced yet: their tracked time counts as + planned. The income spans the whole year, so the recurring expenses are + counted for all twelve months too. Fixed expenses marked tax deductible (operating costs, insurance, etc.) are deducted before estimating income tax, yielding the taxable profit; the others are paid out of taxed income. @@ -322,15 +324,11 @@ def compute_spendable_income( ignore it. """ today = datetime.date.today() - # Any year but the current one is a whole year, without planned revenue. + # Any year but the current one is over: it has no planned or contracted work. other_year = year is not None and year != today.year - - if other_year: - year_start = datetime.date(year, 1, 1) - year_end = datetime.date(year, 12, 31) - else: - year_start = today.replace(month=1, day=1) - year_end = today + year_start = datetime.date(year or today.year, 1, 1) + year_end = datetime.date(year or today.year, 12, 31) + cent = Decimal("0.01") ref_date = datetime.date(year, 7, 1) if year is not None else today @@ -373,68 +371,60 @@ def compute_spendable_income( if other_year: contracted = Decimal(0) - gross_ytd = received_gross + outstanding_gross - vat_ytd = received_vat + outstanding_vat - net_ytd = received_net + outstanding_net - - if other_year: - months_elapsed = 12 - else: - months_elapsed = max( - (today.year - year_start.year) * 12 + today.month - year_start.month + 1, - 1, - ) + gross_invoiced = received_gross + outstanding_gross + vat_invoiced = received_vat + outstanding_vat + net_invoiced = received_net + outstanding_net - # Prorate the fixed-amount recurring expenses to the period + # The income spans the whole year, so do the expenses: twelve months of + # each recurring one, whichever month this is. fixed_expenses = [e for e in (expenses or []) if e.rate is None] dynamic_expenses = [e for e in (expenses or []) if e.rate is not None] deductible_monthly = _monthly_expenses_total([e for e in fixed_expenses if e.tax_deductible], currency) post_tax_monthly = _monthly_expenses_total([e for e in fixed_expenses if not e.tax_deductible], currency) - biz_expenses_ytd = (deductible_monthly * months_elapsed).quantize(Decimal("0.01")) - fixed_post_tax_ytd = (post_tax_monthly * months_elapsed).quantize(Decimal("0.01")) + biz_expenses = (deductible_monthly * 12).quantize(cent) + fixed_post_tax = (post_tax_monthly * 12).quantize(cent) - taxable_profit = net_ytd + planned + contracted - biz_expenses_ytd + taxable_profit = net_invoiced + planned + contracted - biz_expenses - # Dynamic expenses are charged on income actually earned so far, so planned - # and contracted revenue stay out of their base even though they are taxed. - dynamic_base_monthly = (net_ytd - biz_expenses_ytd) / months_elapsed + # Dynamic expenses are charged on income actually earned, so planned and + # contracted revenue stay out of their base even though they are taxed: + # every month is charged on the average month invoiced so far. + months_invoiced = 12 if other_year else today.month + earned_monthly = net_invoiced / months_invoiced - deductible_monthly - deductible_lines = _dynamic_lines(dynamic_expenses, dynamic_base_monthly, deductible=True) - dyn_deductible = (_lines_total(deductible_lines) * months_elapsed).quantize(Decimal("0.01")) + deductible_lines = _dynamic_lines(dynamic_expenses, earned_monthly, deductible=True) + dyn_deductible = (_lines_total(deductible_lines) * 12).quantize(cent) # 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_ytd + 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. - earned_base = net_ytd - biz_expenses_ytd - dyn_deductible + # 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) - earned_post_tax = earned_base - earned_tax - fixed_post_tax_ytd - post_tax_lines = _dynamic_lines(dynamic_expenses, earned_post_tax / months_elapsed, deductible=False) - dyn_post_tax = (_lines_total(post_tax_lines) * months_elapsed).quantize(Decimal("0.01")) - - # The per-expense lines are monthly; the aggregates above cover the period. - dynamic_lines = [ - line._replace(amount=(line.amount * months_elapsed).quantize(Decimal("0.01"))) - for line in deductible_lines + post_tax_lines - ] + 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(Decimal("0.01")) + conversion_fee = (foreign_net * fx_haircut() / 100).quantize(cent) spendable = post_tax - dyn_post_tax - conversion_fee return SpendableIncome( - gross_revenue_ytd=gross_ytd, - business_expenses=biz_expenses_ytd, - net_revenue_ytd=net_ytd, + gross_revenue_ytd=gross_invoiced, + business_expenses=biz_expenses, + net_revenue_ytd=net_invoiced, taxable_profit=taxable_profit, - vat_reserve=vat_ytd, + vat_reserve=vat_invoiced, income_tax_reserve=tax_reserve.ytd_reserve, spendable=spendable, received_gross=received_gross, @@ -448,7 +438,7 @@ def compute_spendable_income( dynamic_expenses_post_tax=dyn_post_tax, tax_base=tax_base, dynamic_expenses=dynamic_lines, - fixed_expenses_post_tax=fixed_post_tax_ytd, + fixed_expenses_post_tax=fixed_post_tax, ) diff --git a/tuttle_tests/test_dynamic_expenses.py b/tuttle_tests/test_dynamic_expenses.py index bd58d958..cd768a9d 100644 --- a/tuttle_tests/test_dynamic_expenses.py +++ b/tuttle_tests/test_dynamic_expenses.py @@ -105,8 +105,9 @@ def test_non_deductible_expense_is_charged_on_post_tax_money(self, invoices): assert dynamic.income_tax_reserve == plain.income_tax_reserve assert dynamic.dynamic_expenses_deductible == 0 + # Charged every month of the year on the average month invoiced so far. post_tax = dynamic.taxable_profit - dynamic.income_tax_reserve - expected = post_tax * Decimal("0.196") + expected = post_tax / datetime.date.today().month * 12 * Decimal("0.196") assert abs(dynamic.dynamic_expenses_post_tax - expected) < 1 # per-month rounding assert dynamic.spendable == post_tax - dynamic.dynamic_expenses_post_tax diff --git a/tuttle_tests/test_fx.py b/tuttle_tests/test_fx.py index fa8e4201..cfdc9159 100644 --- a/tuttle_tests/test_fx.py +++ b/tuttle_tests/test_fx.py @@ -142,7 +142,7 @@ def test_foreign_currency_expense_is_converted(): spending = compute_spendable_income(invoices, "Germany", expenses=[expense], currency="EUR") assert salary.monthly_expenses == Decimal("90.00") - assert spending.business_expenses == Decimal("90.00") * datetime.date.today().month + assert spending.business_expenses == Decimal("90.00") * 12 class TestCurrencyValidation: diff --git a/tuttle_tests/test_tax.py b/tuttle_tests/test_tax.py index e3e9e173..59bcf4ab 100644 --- a/tuttle_tests/test_tax.py +++ b/tuttle_tests/test_tax.py @@ -578,11 +578,11 @@ def test_non_deductible_expense_is_paid_from_taxed_income(self): assert result.business_expenses == 0 assert result.taxable_profit == plain.taxable_profit assert result.income_tax_reserve == plain.income_tax_reserve - assert result.fixed_expenses_post_tax == Decimal(500) * today.month + assert result.fixed_expenses_post_tax == Decimal(500) * 12 assert result.spendable == plain.spendable - result.fixed_expenses_post_tax def test_spendable_with_yearly_expense(self): - """Yearly expenses are normalized to monthly before YTD proration.""" + """Yearly expenses are normalized to monthly before they are counted for the year.""" today = datetime.date.today() invoices = [ _make_invoice(today.replace(day=1), [(100, 100, 0.19)]), diff --git a/tuttle_tests/test_tax_view_full_year_expenses.py b/tuttle_tests/test_tax_view_full_year_expenses.py new file mode 100644 index 00000000..d9367cf4 --- /dev/null +++ b/tuttle_tests/test_tax_view_full_year_expenses.py @@ -0,0 +1,100 @@ +"""The tax estimate counts the year's expenses for the same months as its income: all twelve.""" + +import datetime +from decimal import Decimal + +import pandas +import pytest + +from tuttle.model import Address, Client, Contract, Invoice, InvoiceItem, Project, RecurringExpense, Timesheet +from tuttle.tax_reserves import compute_spendable_income +from tuttle.time import Cycle, TimeUnit + +TODAY = datetime.date.today() +DECEMBER = datetime.date(TODAY.year, 12, 1) + + +@pytest.fixture +def project(): + 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=datetime.date(TODAY.year, 1, 1), + rate=Decimal("100.00"), + currency="EUR", + unit=TimeUnit.hour, + units_per_workday=8, + term_of_payment=14, + billing_cycle=Cycle.monthly, + ) + return Project(title="Boiler", tag="#boiler", description="", start_date=contract.start_date, contract=contract) + + +def _work(tag: str, day: datetime.date) -> pandas.DataFrame: + """40 h of work, worth 4,000 at 100 an hour.""" + begin = datetime.datetime(day.year, day.month, day.day, 9) + df = pandas.DataFrame( + { + "begin": [begin], + "end": [begin + datetime.timedelta(hours=40)], + "title": ["Work"], + "tag": [tag], + "description": [""], + "all_day": [False], + } + ) + df["duration"] = df["end"] - df["begin"] + return df.set_index("begin") + + +def _invoice(project, date: datetime.date, net: str, work_day: datetime.date = None) -> Invoice: + inv = Invoice(number="2026-001", date=date, contract=project.contract, project=project, sent=True) + InvoiceItem(invoice=inv, quantity=1, unit="flat", unit_price=Decimal(net), VAT_rate=Decimal("0.19"), description="Work") + if work_day: + Timesheet(title="ts", date=date, period_start=work_day, period_end=work_day, project=project, invoice=inv) + return inv + + +def _rent(amount="1000"): + return RecurringExpense(title="Rent", amount=Decimal(amount), period=Cycle.monthly, tax_deductible=True) + + +def test_december_work_is_taxed_with_december_rent_deducted(project): + spending = compute_spendable_income( + [], "Germany", expenses=[_rent()], currency="EUR", projects=[project], time_data=_work(project.tag, DECEMBER) + ) + assert spending.planned_revenue == 4000 + assert spending.business_expenses == 12000 + assert spending.taxable_profit == 4000 - 12000 + + +def test_an_invoice_dated_later_this_year_still_counts(project): + """Its timesheet takes the work out of Planned, so the invoice must count instead.""" + later = _invoice(project, datetime.date(TODAY.year, 12, 31), "4000", work_day=DECEMBER) + spending = compute_spendable_income( + [later], "Germany", currency="EUR", projects=[project], time_data=_work(project.tag, DECEMBER) + ) + assert spending.planned_revenue == 0 + assert spending.net_revenue_ytd == 4000 + + +def test_dynamic_expenses_are_charged_every_month_on_the_average_month_invoiced(project): + health = RecurringExpense(title="Health", amount=Decimal(0), rate=Decimal("10"), period=Cycle.monthly, tax_deductible=True) + january = _invoice(project, datetime.date(TODAY.year, 1, 1), "12000") + spending = compute_spendable_income([january], "Germany", expenses=[health], currency="EUR") + + monthly = (Decimal(12000) / TODAY.month * Decimal("0.10")).quantize(Decimal("0.01")) + assert spending.dynamic_expenses_deductible == monthly * 12 + + +def test_a_past_year_counts_its_twelve_months(project): + year = TODAY.year - 1 + invoice = _invoice(project, datetime.date(year, 6, 1), "24000") + invoice.paid = True + spending = compute_spendable_income([invoice], "Germany", expenses=[_rent()], currency="EUR", year=year) + assert spending.business_expenses == 12000 + assert spending.taxable_profit == 24000 - 12000 diff --git a/ui/src/components/tax/TaxReservesView.tsx b/ui/src/components/tax/TaxReservesView.tsx index c529ed09..1916134c 100644 --- a/ui/src/components/tax/TaxReservesView.tsx +++ b/ui/src/components/tax/TaxReservesView.tsx @@ -80,8 +80,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 isCurrentYear = selectedYear === new Date().getFullYear(); - const periodLabel = isCurrentYear ? "YTD" : `${selectedYear}`; const countrySupported = taxEstimate ? taxEstimate.country_supported !== false : true; const taxCountry = taxEstimate ? str(taxEstimate, "country") : ""; @@ -94,7 +92,7 @@ export function TaxReservesView() { {/* Revenue Waterfall */} -
}> +
}> {!hasAnyIncome ? (

No revenue data yet.

) : (() => {