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
3 changes: 1 addition & 2 deletions tuttle/app/salary/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from ...fx import primary_currency, validate_currency_code
from ...model import Invoice, RecurringExpense, User
from ...tax import has_tax_model
from ...tax_reserves import compute_effective_salary
from ..core.abstractions import Intent, SQLModelDataSourceMixin
from ..core.intent_result import IntentResult
Expand Down Expand Up @@ -51,7 +50,7 @@ def get_effective_salary(self) -> IntentResult:
"salary": salary,
"currency": currency,
"country": country,
"country_supported": has_tax_model(country) if country else False,
"country_supported": salary.tax_unavailable_reason is None,
},
)
except Exception as e:
Expand Down
26 changes: 14 additions & 12 deletions tuttle/app/tax/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ def get_spendable_income(self, year: int | None = None) -> IntentResult:
)

def get_income_tax_estimate(self, year: int | None = None) -> IntentResult:
"""Get detailed income tax estimate with bracket info."""
"""Get detailed income tax estimate with bracket info.

Without tax rules for the country and year there is no estimate:
``tax_reserve`` is None and ``tax_unavailable_reason`` says why.
"""
try:
today = datetime.date.today()

Expand All @@ -103,16 +107,13 @@ def get_income_tax_estimate(self, year: int | None = None) -> IntentResult:
# tax_base, not taxable_profit: deductible dynamic expenses have already
# been taken off, and charging them again here would double-count.
total_income = spending.tax_base
tax_reserve = compute_income_tax_reserve(total_income, country, year=year, currency=currency)

ref_date = datetime.date(year, 7, 1) if year else today
try:
tax_system = get_tax_system(country, date=ref_date)
bracket_data = self._compute_bracket_data(tax_system, total_income)
country_supported = True
except NotImplementedError:
bracket_data = []
country_supported = False
no_tax = spending.tax_unavailable_reason
tax_reserve = None
bracket_data = []
if no_tax is None:
tax_reserve = compute_income_tax_reserve(total_income, country, year=year, currency=currency)
ref_date = datetime.date(year, 7, 1) if year else today
bracket_data = self._compute_bracket_data(get_tax_system(country, date=ref_date), total_income)

data = {
"tax_reserve": tax_reserve,
Expand All @@ -124,7 +125,8 @@ def get_income_tax_estimate(self, year: int | None = None) -> IntentResult:
"deducted_expenses": spending.business_expenses + spending.dynamic_expenses_deductible,
"brackets": bracket_data,
"country": country,
"country_supported": country_supported,
"country_supported": no_tax is None,
"tax_unavailable_reason": no_tax,
"currency": currency,
}
return IntentResult(was_intent_successful=True, data=data)
Expand Down
23 changes: 18 additions & 5 deletions tuttle/forecasting.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,9 @@ def cash_flow_series(
- Inflows from contracted work (:func:`contracted_revenue`), shifted the same way.
- Outflows from normalized fixed recurring expenses, rate-based contributions,
and income tax reserves at the tax view's effective rate for the year.
Without tax rules there is no rate: ``tax_included`` is False, and the
income tax and the contributions charged on taxed income (named in
``left_out_with_tax``) are left out, never reserved at zero.
- Net flow and running cumulative balance across the forecast window.

Empty buckets are included so the time axis stays continuous.
Expand Down Expand Up @@ -882,7 +885,9 @@ def cash_flow_series(
time_data=time_data,
contracted=contracted_this_year(projects, invoices, time_data, currency=currency, today=today),
)
effective_tax_rate = spending.income_tax_reserve / spending.tax_base if spending.tax_base > 0 else Decimal(0)
no_tax = spending.tax_unavailable_reason
if no_tax is None:
effective_tax_rate = spending.income_tax_reserve / spending.tax_base if spending.tax_base > 0 else Decimal(0)

# 5. Net flow, cumulative balance, and summary totals
running_balance = 0.0
Expand All @@ -893,10 +898,14 @@ def cash_flow_series(
row = buckets[period]
month_tax_base, dyn_deductible = month_data[period]

monthly_tax = (month_tax_base * effective_tax_rate).quantize(Decimal("0.01"))
post_tax = max(Decimal(0), month_tax_base - monthly_tax - (monthly_fixed - monthly_fixed_deductible))
nondeductible_lines = _dynamic_lines(dynamic_expenses, post_tax, deductible=False)
dyn_nondeductible = _lines_total(nondeductible_lines)
if no_tax is None:
monthly_tax = (month_tax_base * effective_tax_rate).quantize(Decimal("0.01"))
post_tax = max(Decimal(0), month_tax_base - monthly_tax - (monthly_fixed - monthly_fixed_deductible))
dyn_nondeductible = _lines_total(_dynamic_lines(dynamic_expenses, post_tax, deductible=False))
else:
# Without tax rules neither the tax nor what is charged on taxed
# income is known: both are left out, and the result says so.
monthly_tax = dyn_nondeductible = Decimal(0)

total_outflow = monthly_fixed + dyn_deductible + monthly_tax + dyn_nondeductible
row["outflow"] = round(float(total_outflow), 2)
Expand All @@ -921,4 +930,8 @@ def cash_flow_series(
"total_inflow": total_inflow,
"total_outflow": total_outflow,
"net_cash_flow": net_cash_flow,
"tax_included": no_tax is None,
"tax_unavailable_reason": no_tax,
# Charged on taxed income, so left out with the tax.
"left_out_with_tax": [] if no_tax is None else [e.title for e in dynamic_expenses if not e.tax_deductible],
}
71 changes: 37 additions & 34 deletions tuttle/kpi.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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
from .tax import NoTaxRules, get_tax_system
from .tax_reserves import compute_spendable_income, convert_invoice
from .time import TimeUnit
from .timetracking import sum_hours_by_tag
Expand All @@ -31,11 +31,12 @@ class KPISummary(NamedTuple):
overdue_invoices: int
# Tax reserves
vat_reserve: Decimal
income_tax_reserve: Decimal
spendable_income: Decimal
income_tax_reserve: Optional[Decimal] # None without tax rules
spendable_income: Optional[Decimal] # None without tax rules
tax_currency: str = "EUR"
country_supported: bool = True
country_supported: bool = True # whether there are tax rules to estimate income tax with
country: str = ""
tax_unavailable_reason: Optional[str] = None

def to_rpc_dict(self) -> dict:
d = self._asdict()
Expand All @@ -44,8 +45,10 @@ def to_rpc_dict(self) -> dict:
d["outstanding_amount_formatted"] = fmt_currency(self.outstanding_amount, tc)
d["overdue_amount_formatted"] = fmt_currency(self.overdue_amount, tc)
d["vat_reserve_formatted"] = fmt_currency(self.vat_reserve, tc)
d["income_tax_reserve_formatted"] = fmt_currency(self.income_tax_reserve, tc)
d["spendable_income_formatted"] = fmt_currency(self.spendable_income, tc)
d["income_tax_reserve_formatted"] = (
fmt_currency(self.income_tax_reserve, tc) if self.income_tax_reserve is not None else "—"
)
d["spendable_income_formatted"] = fmt_currency(self.spendable_income, tc) if self.spendable_income is not None else "—"
d["effective_hourly_rate_formatted"] = (
fmt_currency(self.effective_hourly_rate, tc) if self.effective_hourly_rate is not None else "—"
)
Expand Down Expand Up @@ -134,7 +137,6 @@ def compute_kpis(
utilization_rate = float(total_hours / available_hours)

# 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,
Expand All @@ -144,10 +146,6 @@ def compute_kpis(
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
spendable_income = spending.spendable

return KPISummary(
total_revenue=total_revenue,
total_revenue_ytd=total_revenue_ytd,
Expand All @@ -159,12 +157,13 @@ def compute_kpis(
active_contracts=active_contracts,
unpaid_invoices=unpaid_invoices,
overdue_invoices=overdue_invoices,
vat_reserve=vat_reserve,
income_tax_reserve=income_tax_reserve,
spendable_income=spendable_income,
vat_reserve=spending.vat_reserve,
income_tax_reserve=spending.income_tax_reserve,
spendable_income=spending.spendable,
tax_currency=currency,
country_supported=country_supported,
country_supported=spending.tax_unavailable_reason is None,
country=country,
tax_unavailable_reason=spending.tax_unavailable_reason,
)


Expand Down Expand Up @@ -226,6 +225,9 @@ def monthly_spendable_breakdown(
- net_revenue: gross_revenue - vat_due
- income_tax_true_up: monthly delta in YTD tax reserve estimate
- spendable: net_revenue - income_tax_true_up

Without tax rules for a month of this year, its true-up and spendable
figure are None.
"""
today = datetime.date.today()
start = (today - datetime.timedelta(days=30 * n_months)).replace(day=1)
Expand Down Expand Up @@ -270,27 +272,28 @@ def monthly_spendable_breakdown(
m["net_revenue"] = m["gross_revenue"] - m["vat_due"]
year_str, month_str = key.split("-")
month_start = datetime.date(int(year_str), int(month_str), 1)
if month_start.year == today.year:
cumulative_net_ytd += m["net_revenue"]
taxable = cumulative_net_ytd - deductions
if taxable <= 0:
cumulative_reserve = Decimal(0)
else:
try:
month_end = (month_start + datetime.timedelta(days=32)).replace(day=1) - datetime.timedelta(days=1)
as_of = min(month_end, today)
tax_system = get_tax_system(country, date=as_of)
annual_tax = tax_system.income_tax(taxable)
annual_soli = tax_system.solidarity_surcharge(annual_tax)
cumulative_reserve = (annual_tax + annual_soli).quantize(Decimal("0.01"))
except NotImplementedError:
cumulative_reserve = Decimal(0)

m["income_tax_true_up"] = cumulative_reserve - previous_reserve
previous_reserve = cumulative_reserve
else:
if month_start.year != today.year:
m["income_tax_true_up"] = Decimal(0)
m["spendable"] = m["net_revenue"]
continue

cumulative_net_ytd += m["net_revenue"]
month_end = (month_start + datetime.timedelta(days=32)).replace(day=1) - datetime.timedelta(days=1)
try:
tax_system = get_tax_system(country, date=min(month_end, today))
except NoTaxRules:
# Without tax rules there is no true-up, and no spendable figure after it.
m["income_tax_true_up"] = m["spendable"] = None
continue
taxable = cumulative_net_ytd - deductions
cumulative_reserve = Decimal(0)
if taxable > 0:
annual_tax = tax_system.income_tax(taxable)
annual_soli = tax_system.solidarity_surcharge(annual_tax)
cumulative_reserve = (annual_tax + annual_soli).quantize(Decimal("0.01"))

m["income_tax_true_up"] = cumulative_reserve - previous_reserve
previous_reserve = cumulative_reserve
m["spendable"] = m["net_revenue"] - m["income_tax_true_up"]

return [months[k] for k in sorted_keys]
Expand Down
15 changes: 11 additions & 4 deletions tuttle/tax.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@
_DATA_DIR = Path(__file__).parent / "tax_data"


class NoTaxRules(NotImplementedError):
"""There are no tax rules to estimate income tax with.

Its message says what is missing, in one sentence fit to show the user.
Every figure after income tax depends on it, so callers show none rather
than one computed with a tax of zero.
"""


# ── TaxParams: one loaded year for one country ───────────────


Expand Down Expand Up @@ -87,9 +96,7 @@ def _resolve_country(country: str) -> str:
_load_all()
canonical = _ALIAS_MAP.get(country)
if canonical is None:
raise NotImplementedError(
f"Tax system for '{country}' not yet implemented. Supported: {', '.join(sorted(_ALIAS_MAP.keys()))}"
)
raise NoTaxRules(f"No tax model for {country}." if country else "No operating country set.")
return canonical


Expand Down Expand Up @@ -354,7 +361,7 @@ def get_tax_system(
Defaults to today.

Raises:
NotImplementedError: If the country has no data packages.
NoTaxRules: If the country has no data packages.
"""
if date is None:
date = datetime.date.today()
Expand Down
Loading
Loading