diff --git a/app/apps/net_worth/tests.py b/app/apps/net_worth/tests.py index 36c96d46..a733c68b 100644 --- a/app/apps/net_worth/tests.py +++ b/app/apps/net_worth/tests.py @@ -80,3 +80,44 @@ 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%") + self.assertContains(response, "25.0%") 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..0e95fcf0 100644 --- a/app/templates/net_worth/net_worth.html +++ b/app/templates/net_worth/net_worth.html @@ -51,7 +51,7 @@