Merge pull request #669 from officialasishkumar/feat/net-worth-consolidated-shares

feat(net-worth): break consolidated totals down by currency share
This commit is contained in:
Herculino Trotta authored and GitHub committed 2026-11-11 15:25:44 -03:00
commit ca09f37b51
4 files changed
+296 -5

No files matched your search

+41
View File
@@ -80,3 +80,44 @@ class NetWorthCurrencyChartTests(TestCase):
'<span class="text-start shrink">Consolidated</span>',
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%")
@@ -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")),
],
)
@@ -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.
+37 -5
View File
@@ -51,7 +51,7 @@
<div>
<c-ui.info-card color="yellow" icon="fa-solid fa-coins" title="{% trans "By currency" %}"
title_css_classes="cursor-pointer" _="on click showAllDatasetsCurrency()">
<ul class="menu bg-base-100 w-full rounded-box">
<ul class="menu bg-base-100 rounded-box p-0 -mx-3 w-[calc(100%+1.5rem)]">
{% for currency in currency_net_worth.values %}
<li>
{% if currency.consolidated and currency.consolidated.total_final != currency.total_final %}
@@ -80,8 +80,14 @@
</a>
<ul>
<li>
<a class="cursor-pointer text-base-content/60 select-auto flex justify-between items-center w-full"
{# Collapsed by default; each currency's open state is remembered per browser #}
<details _="init if localStorage.getItem('net-worth-consolidated-{{ currency.currency.id }}') is 'open' set my.open to true end
on toggle if my.open call localStorage.setItem('net-worth-consolidated-{{ currency.currency.id }}', 'open')
else call localStorage.setItem('net-worth-consolidated-{{ currency.currency.id }}', 'closed') end
catch e log e end">
<summary class="cursor-pointer text-base-content/60 select-auto flex"
_="on click showOnlyCurrencyDataset('{{ currency.currency.name }} {% trans "Consolidated" %}', '{{ currency.currency.name }}')">
<span class="flex justify-between items-center w-full">
<span class="text-start shrink">{% trans "Consolidated" %}</span>
<span class="text-end shrink-0">
<c-amount.display :amount="currency.consolidated.total_final"
@@ -90,8 +96,34 @@
:decimal_places="currency.consolidated.currency.decimal_places"
color="{% if currency.consolidated.total_final > 0 %}green{% elif currency.consolidated.total_final < 0 %}red{% endif %}"
text-end></c-amount.display>
</span>
</a>
</span>
</span>
</summary>
{# Rows share the ul's columns via subgrid so badges line up regardless of amount width #}
<ul class="grid grid-cols-[minmax(0,1fr)_auto_auto]">
{% for component in currency.consolidated.components %}
<li class="col-span-3 grid grid-cols-subgrid">
<a class="col-span-3 grid grid-cols-subgrid items-center cursor-pointer text-base-content/60 select-auto text-sm"
_="on click showOnlyCurrencyDataset('{{ component.currency.name }}', '{{ component.currency.name }}')">
<span class="currency-name text-start font-mono truncate">{{ component.currency.name }}</span>
<span class="justify-self-start">
{% if component.share is not None %}
<span class="badge badge-sm badge-ghost tabular-nums">{{ component.share|floatformat:1 }}%</span>
{% endif %}
</span>
<span class="text-end whitespace-nowrap">
<c-amount.display :amount="component.total_final"
:prefix="currency.consolidated.currency.prefix"
:suffix="currency.consolidated.currency.suffix"
:decimal_places="currency.consolidated.currency.decimal_places"
color="grey"
divless></c-amount.display>
</span>
</a>
</li>
{% endfor %}
</ul>
</details>
</li>
</ul>
{% else %}
@@ -162,7 +194,7 @@
<div>
<c-ui.info-card color="blue" icon="fa-solid fa-wallet" title="{% trans "By account" %}"
title_css_classes="cursor-pointer" _="on click showAllDatasetsAccount()">
<ul class="menu bg-base-100 w-full rounded-box">
<ul class="menu bg-base-100 rounded-box p-0 -mx-3 w-[calc(100%+1.5rem)]">
{% regroup account_net_worth.values by account.group as account_data %}
{% for data in account_data %}
{% if data.grouper %}