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.

) : (() => {