From 2bcae4851091bb3ceafd8afd61d753dbbbbfd74c Mon Sep 17 00:00:00 2001 From: Andreev Kirill Andreevich Date: Fri, 10 Jul 2026 10:53:55 +0300 Subject: [PATCH] fix: label outstanding/overdue amounts with the invoice's own currency The dashboard's Outstanding and Overdue KPI cards always formatted totals using the tax system's currency (EUR for Germany), regardless of the currency an invoice was actually issued in. An unpaid USD invoice was therefore shown with a EUR label, silently mixing currencies whenever invoices used more than one. compute_kpis now tracks outstanding/overdue totals per invoice currency (KPISummary.outstanding_by_currency / overdue_by_currency). to_rpc_dict formats using the invoice's real currency when only one is present, and shows each amount separately (joined) when multiple currencies are outstanding, instead of summing them under one wrong label. Fixes #400 --- tuttle/kpi.py | 53 ++++++++++++- tuttle_tests/test_dashboard.py | 140 +++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 3 deletions(-) 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):