Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 42 additions & 52 deletions tuttle/tax_reserves.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]:
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
)


Expand Down
3 changes: 2 additions & 1 deletion tuttle_tests/test_dynamic_expenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tuttle_tests/test_fx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions tuttle_tests/test_tax.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]),
Expand Down
100 changes: 100 additions & 0 deletions tuttle_tests/test_tax_view_full_year_expenses.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 1 addition & 3 deletions ui/src/components/tax/TaxReservesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") : "";

Expand All @@ -94,7 +92,7 @@ export function TaxReservesView() {
<PageLayout title="Tax & Reserves" className="grid gap-6 items-start @4xl:grid-cols-2 @4xl:grid-rows-[auto_1fr]"
right={yearSelector}>
{/* Revenue Waterfall */}
<Section title={`Revenue Breakdown (${periodLabel})`} icon={<BarChart3 size={16} />}>
<Section title={`Revenue Breakdown (${selectedYear})`} icon={<BarChart3 size={16} />}>
{!hasAnyIncome ? (
<p className="text-sm text-muted">No revenue data yet.</p>
) : (() => {
Expand Down
Loading