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
9 changes: 3 additions & 6 deletions tuttle/app/tax/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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."""
Expand Down
17 changes: 17 additions & 0 deletions tuttle/forecasting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
9 changes: 8 additions & 1 deletion tuttle/kpi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions tuttle_tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -973,13 +973,16 @@ 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
assert kpis.spendable_income <= kpis.total_revenue_ytd
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.
Expand Down
58 changes: 58 additions & 0 deletions tuttle_tests/test_kpi_spendable_contracted.py
Original file line number Diff line number Diff line change
@@ -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
Loading