mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
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:
4 files changed
+296
-5
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in new issue
Block a user