diff --git a/tuttle/kpi.py b/tuttle/kpi.py index db1f0928..0651b1ef 100644 --- a/tuttle/kpi.py +++ b/tuttle/kpi.py @@ -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 @@ -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) @@ -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], @@ -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 @@ -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 @@ -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, ) diff --git a/tuttle_tests/test_dashboard.py b/tuttle_tests/test_dashboard.py index a6d40924..f2a01e00 100644 --- a/tuttle_tests/test_dashboard.py +++ b/tuttle_tests/test_dashboard.py @@ -28,6 +28,7 @@ monthly_revenue_breakdown, monthly_spendable_breakdown, project_budget_status, + _format_amount_by_currency, ) @@ -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 ───────────────────────────────────────── @@ -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):