Skip to content
Open
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
53 changes: 50 additions & 3 deletions tuttle/kpi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import datetime
from decimal import Decimal
from typing import List, Optional, NamedTuple
from typing import Dict, List, Optional, NamedTuple

from pandas import DataFrame

Expand Down Expand Up @@ -33,13 +33,22 @@ class KPISummary(NamedTuple):
income_tax_reserve: Decimal
spendable_income: Decimal
tax_currency: str = "EUR"
# Unpaid/overdue totals broken down by the invoice's own currency, since
# invoices are not necessarily denominated in the tax system's currency
# (e.g. tax_currency is EUR but an invoice was issued in USD).
outstanding_by_currency: Dict[str, Decimal] = {}
overdue_by_currency: Dict[str, Decimal] = {}

def to_rpc_dict(self) -> dict:
d = self._asdict()
tc = self.tax_currency or "EUR"
d["total_revenue_ytd_formatted"] = fmt_currency(self.total_revenue_ytd, tc)
d["outstanding_amount_formatted"] = fmt_currency(self.outstanding_amount, tc)
d["overdue_amount_formatted"] = fmt_currency(self.overdue_amount, tc)
d["outstanding_amount_formatted"] = _format_amount_by_currency(
self.outstanding_amount, self.outstanding_by_currency, tc
)
d["overdue_amount_formatted"] = _format_amount_by_currency(
self.overdue_amount, self.overdue_by_currency, 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)
Expand All @@ -56,6 +65,28 @@ def to_rpc_dict(self) -> dict:
return d


def _format_amount_by_currency(
total: Decimal, by_currency: Dict[str, Decimal], fallback_currency: str
) -> str:
"""Format a monetary total using its actual currency breakdown.

When all amounts share a single currency, that currency is used for
formatting (which may differ from ``fallback_currency``, e.g. the tax
system's currency). When multiple currencies are involved, each is
formatted separately and joined, rather than silently summing amounts
in different currencies under one (possibly wrong) currency label.
"""
non_zero = {c: v for c, v in by_currency.items() if v}
if not non_zero:
return fmt_currency(total, fallback_currency)
if len(non_zero) == 1:
(currency, value) = next(iter(non_zero.items()))
return fmt_currency(value, currency)
return " + ".join(
fmt_currency(value, currency) for currency, value in sorted(non_zero.items())
)


def compute_kpis(
invoices: List[Invoice],
contracts: List[Contract],
Expand All @@ -80,11 +111,19 @@ def compute_kpis(
unpaid_invoices = 0
overdue_invoices = 0
paid_revenue = Decimal(0)
outstanding_by_currency: Dict[str, Decimal] = {}
overdue_by_currency: Dict[str, Decimal] = {}

for inv in invoices:
if inv.cancelled:
continue
inv_total = inv.total
# Use the invoice's own currency (via its contract), matching
# Invoice.total_formatted, rather than assuming the tax system's
# currency applies to every invoice.
inv_currency = (
inv.contract.currency if inv.contract and inv.contract.currency else "EUR"
)

if inv.paid:
total_revenue += inv_total
Expand All @@ -93,9 +132,15 @@ def compute_kpis(
paid_revenue += inv_total
else:
outstanding_amount += inv_total
outstanding_by_currency[inv_currency] = (
outstanding_by_currency.get(inv_currency, Decimal(0)) + inv_total
)
unpaid_invoices += 1
if inv.due_date and inv.due_date < today:
overdue_amount += inv_total
overdue_by_currency[inv_currency] = (
overdue_by_currency.get(inv_currency, Decimal(0)) + inv_total
)
overdue_invoices += 1

# Total tracked hours from calendar data
Expand Down Expand Up @@ -165,6 +210,8 @@ def compute_kpis(
income_tax_reserve=income_tax_reserve,
spendable_income=spendable_income,
tax_currency=tax_currency,
outstanding_by_currency=outstanding_by_currency,
overdue_by_currency=overdue_by_currency,
)


Expand Down
140 changes: 140 additions & 0 deletions tuttle_tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
monthly_revenue_breakdown,
monthly_spendable_breakdown,
project_budget_status,
_format_amount_by_currency,
)


Expand Down Expand Up @@ -179,6 +180,65 @@ def unpaid_invoice(active_contract, project):
return inv


@pytest.fixture
def usd_contract(client):
today = datetime.date.today()
return Contract(
title="USD Contract",
client=client,
signature_date=today - datetime.timedelta(days=60),
start_date=today - datetime.timedelta(days=30),
end_date=today + datetime.timedelta(days=180),
rate=Decimal("100.00"),
currency="USD",
unit=TimeUnit.hour,
units_per_workday=8,
volume=500,
term_of_payment=14,
billing_cycle=Cycle.monthly,
)


@pytest.fixture
def usd_project(usd_contract):
today = datetime.date.today()
return Project(
title="USD Project",
tag="#USDProject",
description="A USD-billed project",
start_date=today - datetime.timedelta(days=30),
end_date=today + datetime.timedelta(days=180),
contract=usd_contract,
)


@pytest.fixture
def unpaid_usd_invoice(usd_contract, usd_project):
today = datetime.date.today()
inv = Invoice(
number="2026-USD-001",
date=today - datetime.timedelta(days=5),
contract=usd_contract,
project=usd_project,
sent=True,
paid=False,
rendered=True,
)
inv.items.append(
InvoiceItem(
invoice=inv,
start_date=today - datetime.timedelta(days=10),
end_date=today - datetime.timedelta(days=5),
quantity=20,
unit="hour",
unit_price=Decimal("100.00"),
description="US client work",
VAT_rate=Decimal("0.19"),
)
)
return inv


# ── Forecasting Tests ─────────────────────────────────────────


Expand Down Expand Up @@ -306,6 +366,86 @@ def test_empty_data_tax_reserves(self):
assert kpis.income_tax_reserve == 0
assert kpis.spendable_income == 0

def test_outstanding_currency_matches_invoice_currency(
self, unpaid_usd_invoice, usd_contract, usd_project
):
"""A USD invoice's outstanding amount must be labeled USD, not EUR.

Regression test for https://github.com/tuttle-dev/tuttle/issues/400:
the dashboard previously formatted the outstanding amount using the
tax system's currency (EUR for Germany) regardless of the invoice's
actual currency.
"""
kpis = compute_kpis(
[unpaid_usd_invoice], [usd_contract], [usd_project], country="Germany"
)
assert kpis.tax_currency == "EUR"
assert kpis.outstanding_by_currency == {"USD": unpaid_usd_invoice.total}
formatted = kpis.to_rpc_dict()["outstanding_amount_formatted"]
assert "$" in formatted
assert "€" not in formatted

def test_outstanding_amounts_in_different_currencies_not_mixed(
self,
unpaid_invoice,
active_contract,
project,
unpaid_usd_invoice,
usd_contract,
usd_project,
):
"""Outstanding amounts in different currencies must not be summed together."""
kpis = compute_kpis(
[unpaid_invoice, unpaid_usd_invoice],
[active_contract, usd_contract],
[project, usd_project],
country="Germany",
)
assert kpis.outstanding_by_currency == {
"EUR": unpaid_invoice.total,
"USD": unpaid_usd_invoice.total,
}
formatted = kpis.to_rpc_dict()["outstanding_amount_formatted"]
assert "$" in formatted
assert "€" in formatted

def test_overdue_currency_matches_invoice_currency(
self, unpaid_usd_invoice, usd_contract, usd_project
):
"""Overdue amounts follow the same per-currency labeling as outstanding."""
# usd_contract.term_of_payment is 14 days, so an invoice issued 20
# days ago has a due date 6 days in the past, i.e. it's overdue.
unpaid_usd_invoice.date = datetime.date.today() - datetime.timedelta(days=20)
kpis = compute_kpis(
[unpaid_usd_invoice], [usd_contract], [usd_project], country="Germany"
)
assert kpis.overdue_by_currency == {"USD": unpaid_usd_invoice.total}
formatted = kpis.to_rpc_dict()["overdue_amount_formatted"]
assert "$" in formatted
assert "€" not in formatted


class TestFormatAmountByCurrency:
def test_single_currency_uses_that_currency(self):
result = _format_amount_by_currency(
Decimal("100"), {"USD": Decimal("100")}, "EUR"
)
assert "$" in result
assert "€" not in result

def test_no_breakdown_uses_fallback(self):
result = _format_amount_by_currency(Decimal("0"), {}, "EUR")
assert "€" in result

def test_multiple_currencies_are_not_summed(self):
result = _format_amount_by_currency(
Decimal("300"),
{"USD": Decimal("100"), "EUR": Decimal("200")},
"EUR",
)
assert "$" in result
assert "€" in result


class TestMonthlyRevenueBreakdown:
def test_empty_invoices(self):
Expand Down
Loading