From d590dc39ab64f20f5f3a2570a52a0c59068a3a5b Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Wed, 11 Nov 2026 21:27:17 +0530 Subject: [PATCH 1/3] feat(net-worth): break consolidated totals down by currency share A consolidated total on the Net Worth page was a single number: it said what everything is worth in one currency, but not how much of it each currency makes up, which is what you want to know when you hold the same money in several currencies (or in currencies standing for gold, stocks or crypto). calculate_currency_totals now keeps, next to each consolidated total, the components it was built from: the currency's own total and the exchanged total of every currency that exchanges into it, all in the consolidated currency. calculate_consolidated_shares gives each of them its share of the total as a percentage, drops the ones that add nothing and sorts the rest from largest to smallest. Shares are taken over the absolute total, so a currency that adds to it always has a positive share and one that takes from it (debt) a negative one; with a zero total there's no share. Currencies that hide their exchanged amounts still show up, since they still count towards the consolidated total, and so do exchange targets that only exist through the deep search. On the Net Worth page, each Consolidated row now lists its components with their share and amount in the consolidated currency. Clicking one shows that currency's line on the charts, like the currency rows above. --- app/apps/net_worth/tests.py | 45 +++++ .../tests/test_consolidated_shares.py | 169 ++++++++++++++++++ app/apps/transactions/utils/calculations.py | 49 +++++ app/templates/net_worth/net_worth.html | 21 +++ 4 files changed, 284 insertions(+) create mode 100644 app/apps/transactions/tests/test_consolidated_shares.py diff --git a/app/apps/net_worth/tests.py b/app/apps/net_worth/tests.py index 36c96d46..f9d0ebd8 100644 --- a/app/apps/net_worth/tests.py +++ b/app/apps/net_worth/tests.py @@ -80,3 +80,48 @@ class NetWorthCurrencyChartTests(TestCase): 'Consolidated', html=False, ) + + def test_consolidated_total_lists_each_currency_share(self): + user = get_user_model().objects.create_user( + email="shares@example.com", password="password" + ) + usd = Currency.objects.create(code="USD", name="US Dollar", prefix="$ ") + eur = Currency.objects.create( + code="EUR", name="Euro", prefix="€ ", exchange_currency=usd + ) + ExchangeRate.objects.create( + from_currency=eur, + to_currency=usd, + rate=Decimal("2"), + date=timezone.now(), + ) + for currency, amount in ((usd, "300"), (eur, "50")): + Transaction.userless_all_objects.create( + account=Account.all_objects.create( + name=f"{currency.code} account", currency=currency, owner=user + ), + owner=user, + type=Transaction.Type.INCOME, + amount=Decimal(amount), + date=date(2026, 1, 15), + reference_date=date(2026, 1, 1), + is_paid=True, + ) + + self.client.force_login(user) + response = self.client.get(reverse("net_worth")) + + self.assertEqual(response.status_code, 200) + components = response.context["currency_net_worth"][usd.id]["consolidated"][ + "components" + ] + self.assertEqual( + [(part["currency"]["name"], part["share"]) for part in components], + [("US Dollar", Decimal("75")), ("Euro", Decimal("25"))], + ) + self.assertContains( + response, '75.0%', html=False + ) + self.assertContains( + response, '25.0%', html=False + ) diff --git a/app/apps/transactions/tests/test_consolidated_shares.py b/app/apps/transactions/tests/test_consolidated_shares.py new file mode 100644 index 00000000..ca7f9c5c --- /dev/null +++ b/app/apps/transactions/tests/test_consolidated_shares.py @@ -0,0 +1,169 @@ +from datetime import date, datetime, timezone as dt_timezone +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from apps.accounts.models import Account +from apps.common.middleware.thread_local import delete_current_user, write_current_user +from apps.currencies.models import Currency, ExchangeRate +from apps.transactions.models import Transaction +from apps.transactions.utils.calculations import ( + calculate_consolidated_shares, + calculate_currency_totals, +) + + +def component(name, total_final): + return {"currency": {"name": name}, "total_final": Decimal(total_final)} + + +class CalculateConsolidatedSharesTests(TestCase): + def test_shares_add_up_to_the_total(self): + shares = calculate_consolidated_shares( + [component("USD", "150"), component("EUR", "50")], Decimal("200") + ) + + self.assertEqual( + [(share["currency"]["name"], share["share"]) for share in shares], + [("USD", Decimal("75")), ("EUR", Decimal("25"))], + ) + + def test_sorted_from_largest_to_smallest(self): + shares = calculate_consolidated_shares( + [component("USD", "10"), component("BTC", "-5"), component("EUR", "20")], + Decimal("25"), + ) + + self.assertEqual( + [share["currency"]["name"] for share in shares], ["EUR", "USD", "BTC"] + ) + + def test_component_that_takes_from_the_total_has_a_negative_share(self): + shares = calculate_consolidated_shares( + [component("USD", "300"), component("EUR", "-100")], Decimal("200") + ) + + self.assertEqual( + [share["share"] for share in shares], [Decimal("150"), Decimal("-50")] + ) + + def test_sign_follows_the_component_when_the_total_is_negative(self): + shares = calculate_consolidated_shares( + [component("USD", "100"), component("EUR", "-300")], Decimal("-200") + ) + + self.assertEqual( + [share["share"] for share in shares], [Decimal("50"), Decimal("-150")] + ) + + def test_components_that_add_nothing_are_left_out(self): + shares = calculate_consolidated_shares( + [component("USD", "0"), component("EUR", "50")], Decimal("50") + ) + + self.assertEqual([share["currency"]["name"] for share in shares], ["EUR"]) + self.assertEqual(shares[0]["share"], Decimal("100")) + + def test_no_share_without_a_total(self): + shares = calculate_consolidated_shares( + [component("USD", "100"), component("EUR", "-100")], Decimal("0") + ) + + self.assertEqual([share["share"] for share in shares], [None, None]) + + +class CurrencyTotalsConsolidatedComponentsTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + email="shares@test.com", password="testpass123" + ) + write_current_user(self.user) + self.addCleanup(delete_current_user) + + self.usd = Currency.objects.create( + code="USD", name="US Dollar", decimal_places=2, prefix="$ " + ) + self.eur = Currency.objects.create( + code="EUR", name="Euro", decimal_places=2, exchange_currency=self.usd + ) + self.gbp = Currency.objects.create( + code="GBP", name="Pound", decimal_places=2, exchange_currency=self.usd + ) + for currency in (self.eur, self.gbp): + ExchangeRate.objects.create( + from_currency=currency, + to_currency=self.usd, + rate=Decimal("2"), + date=datetime(2026, 1, 1, tzinfo=dt_timezone.utc), + ) + + self._add(self.usd, Transaction.Type.INCOME, "150") + self._add(self.eur, Transaction.Type.INCOME, "50") + self._add(self.gbp, Transaction.Type.EXPENSE, "25") + + def _add(self, currency, transaction_type, amount): + Transaction.objects.create( + account=Account.objects.create( + name=f"{currency.code} account", currency=currency + ), + type=transaction_type, + is_paid=True, + date=date(2026, 3, 10), + reference_date=date(2026, 3, 1), + amount=Decimal(amount), + ) + + def _components(self, totals, currency): + return [ + (part["currency"]["name"], part["total_final"], part["share"]) + for part in totals[currency.id]["consolidated"]["components"] + ] + + def test_components_are_in_the_consolidated_currency(self): + totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True) + + self.assertEqual( + totals[self.usd.id]["consolidated"]["total_final"], Decimal("200") + ) + self.assertEqual( + self._components(totals, self.usd), + [ + ("US Dollar", Decimal("150"), Decimal("75")), + ("Euro", Decimal("100"), Decimal("50")), + ("Pound", Decimal("-50"), Decimal("-25")), + ], + ) + + def test_currency_without_sources_is_its_only_component(self): + totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True) + + self.assertEqual( + self._components(totals, self.eur), + [("Euro", Decimal("50"), Decimal("100"))], + ) + + def test_hidden_exchanged_amounts_still_count(self): + self.eur.show_exchanged_amounts = False + self.eur.save() + + totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True) + + self.assertNotIn("exchanged", totals[self.eur.id]) + self.assertIn( + ("Euro", Decimal("100"), Decimal("50")), + self._components(totals, self.usd), + ) + + def test_target_found_by_deep_search(self): + Transaction.objects.filter(account__currency=self.usd).delete() + + totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True) + + self.assertEqual( + self._components(totals, self.usd), + [ + ("Euro", Decimal("100"), Decimal("200")), + ("Pound", Decimal("-50"), Decimal("-100")), + ], + ) diff --git a/app/apps/transactions/utils/calculations.py b/app/apps/transactions/utils/calculations.py index fd2459ed..df292f16 100644 --- a/app/apps/transactions/utils/calculations.py +++ b/app/apps/transactions/utils/calculations.py @@ -303,12 +303,28 @@ def calculate_currency_totals( "total_projected": data_consolidated_currency["total_projected"], "total_final": data_consolidated_currency["total_final"], } + # What each currency adds to the consolidated total, in the + # consolidated currency, starting with the currency's own total + components = [ + { + "currency": data_consolidated_currency["currency"], + "total_final": data_consolidated_currency["total_final"], + } + ] if currency_id_consolidated in currencies_using_exchange: for original_currency_info in currencies_using_exchange[ currency_id_consolidated ]: exchanged_values_from_original = original_currency_info["exchanged"] + components.append( + { + "currency": result[original_currency_info["currency_id"]][ + "currency" + ], + "total_final": exchanged_values_from_original["total_final"], + } + ) for field in [ "expense_current", "expense_projected", @@ -323,6 +339,9 @@ def calculate_currency_totals( field ] + consolidated_data["components"] = calculate_consolidated_shares( + components, consolidated_data["total_final"] + ) result[currency_id_consolidated]["consolidated"] = consolidated_data # Sort currencies by their final_total or consolidated final_total, descending @@ -341,6 +360,36 @@ def calculate_currency_totals( return result +def calculate_consolidated_shares(components, consolidated_total): + """ + Give each component of a consolidated total its share of that total, as a + percentage. Components that add nothing are left out and the rest are sorted + from the largest to the smallest. + + Shares are taken over the absolute total, so a component that adds to it + always has a positive share and one that takes from it (a debt, for + example) a negative one. Without a total to divide by, the share is None. + """ + shares = [] + + for component in components: + if not component["total_final"]: + continue + + shares.append( + { + **component, + "share": ( + component["total_final"] / abs(consolidated_total) * 100 + if consolidated_total + else None + ), + } + ) + + return sorted(shares, key=lambda share: share["total_final"], reverse=True) + + def calculate_percentage_distribution(currency_totals): """ Calculate percentage distribution of financial metrics for each currency. diff --git a/app/templates/net_worth/net_worth.html b/app/templates/net_worth/net_worth.html index f882a956..c37d8ea4 100644 --- a/app/templates/net_worth/net_worth.html +++ b/app/templates/net_worth/net_worth.html @@ -92,6 +92,27 @@ text-end> + {% else %} From 4bf2c2552bd0856675d0f953bc344c15f28c4b54 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Wed, 11 Nov 2026 13:57:21 -0300 Subject: [PATCH 2/3] feat(net-worth): collapse consolidated breakdown and align share badges Consolidated rows are collapsible and remember their state per currency in localStorage. Share percentages render as badges on a shared subgrid column so they line up regardless of amount width. Currency and account menus drop their extra padding to align with the card title. --- app/templates/net_worth/net_worth.html | 35 +++++++++++++++++--------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/app/templates/net_worth/net_worth.html b/app/templates/net_worth/net_worth.html index c37d8ea4..0e95fcf0 100644 --- a/app/templates/net_worth/net_worth.html +++ b/app/templates/net_worth/net_worth.html @@ -51,7 +51,7 @@
-