From d3f29f5ad18024ca323e66695f3b38343309d23b Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Wed, 11 Nov 2026 15:26:04 +0530 Subject: [PATCH 1/2] feat(currencies): make displaying exchanged amounts optional Currencies and accounts get a "Show exchanged amounts" switch, on by default. Turning it off hides the amounts converted to the exchange currency without removing the exchange currency, so consolidated totals on the Net Worth page keep working. The switch belongs to whichever one provides the exchange currency: - Transactions (including both legs of combined transfers) use the account's switch when the account has its own exchange currency, and the currency's switch otherwise, matching how the exchanged amount is picked. - Account cards and Net Worth account rows follow the account. - Currency cards, the monthly summary and daily allowance, Net Worth currency rows and the month-by-month and year-by-year insights follow the currency. Currency totals still convert hidden currencies, because the consolidated totals are built from those values; the setting only leaves them out of the displayed data. The API keeps returning exchanged_amount for transactions, and the account serializer exposes the new field (currencies already serialize all fields). Closes #486 --- app/apps/accounts/forms.py | 2 + .../0022_account_show_exchanged_amounts.py | 22 +++ app/apps/accounts/models.py | 8 + app/apps/api/serializers/accounts.py | 1 + app/apps/currencies/forms.py | 2 + .../0028_currency_show_exchanged_amounts.py | 22 +++ app/apps/currencies/models.py | 9 + app/apps/insights/utils/month_by_month.py | 15 +- app/apps/insights/utils/overview.py | 9 +- app/apps/insights/utils/year_by_year.py | 15 +- app/apps/transactions/models.py | 13 ++ .../tests/test_exchanged_amounts.py | 182 ++++++++++++++++++ app/apps/transactions/utils/calculations.py | 15 +- .../cotton/transaction/_transfer_leg.html | 2 +- app/templates/cotton/transaction/item.html | 2 +- 15 files changed, 301 insertions(+), 18 deletions(-) create mode 100644 app/apps/accounts/migrations/0022_account_show_exchanged_amounts.py create mode 100644 app/apps/currencies/migrations/0028_currency_show_exchanged_amounts.py create mode 100644 app/apps/transactions/tests/test_exchanged_amounts.py diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index abb3cf8d..f4bf50a9 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -103,6 +103,7 @@ class AccountForm(forms.ModelForm): "group", "currency", "exchange_currency", + "show_exchanged_amounts", "is_asset", "is_archived", "is_investment", @@ -161,6 +162,7 @@ class AccountForm(forms.ModelForm): Switch("is_archived"), "currency", "exchange_currency", + Switch("show_exchanged_amounts"), Switch("is_investment"), # Only relevant for investments: shown while the switch above is on Div( diff --git a/app/apps/accounts/migrations/0022_account_show_exchanged_amounts.py b/app/apps/accounts/migrations/0022_account_show_exchanged_amounts.py new file mode 100644 index 00000000..b2fd36e8 --- /dev/null +++ b/app/apps/accounts/migrations/0022_account_show_exchanged_amounts.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.17 on 2026-11-11 09:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0021_alter_account_income_categories_help"), + ] + + operations = [ + migrations.AddField( + model_name="account", + name="show_exchanged_amounts", + field=models.BooleanField( + default=True, + help_text="Show amounts converted to this account's exchange currency next to its transactions and totals.", + verbose_name="Show exchanged amounts", + ), + ), + ] diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index 58dd0a70..5a4d4079 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -104,6 +104,14 @@ class Account(SharedObject): blank=True, help_text=_("Default currency for exchange calculations"), ) + show_exchanged_amounts = models.BooleanField( + default=True, + verbose_name=_("Show exchanged amounts"), + help_text=_( + "Show amounts converted to this account's exchange currency next to its " + "transactions and totals." + ), + ) is_asset = models.BooleanField( default=False, diff --git a/app/apps/api/serializers/accounts.py b/app/apps/api/serializers/accounts.py index 3345187e..be38f91a 100644 --- a/app/apps/api/serializers/accounts.py +++ b/app/apps/api/serializers/accounts.py @@ -80,6 +80,7 @@ class AccountSerializer(serializers.ModelSerializer): "currency_id", "exchange_currency", "exchange_currency_id", + "show_exchanged_amounts", "is_asset", "is_investment", "investment_class", diff --git a/app/apps/currencies/forms.py b/app/apps/currencies/forms.py index 526368a2..393716e4 100644 --- a/app/apps/currencies/forms.py +++ b/app/apps/currencies/forms.py @@ -29,6 +29,7 @@ class CurrencyForm(forms.ModelForm): "suffix", "code", "exchange_currency", + "show_exchanged_amounts", "is_archived", ] widgets = { @@ -49,6 +50,7 @@ class CurrencyForm(forms.ModelForm): "prefix", "suffix", "exchange_currency", + Switch("show_exchanged_amounts"), ) if self.instance and self.instance.pk: diff --git a/app/apps/currencies/migrations/0028_currency_show_exchanged_amounts.py b/app/apps/currencies/migrations/0028_currency_show_exchanged_amounts.py new file mode 100644 index 00000000..139c0d25 --- /dev/null +++ b/app/apps/currencies/migrations/0028_currency_show_exchanged_amounts.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.17 on 2026-11-11 09:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("currencies", "0027_exchangerateservice_retention"), + ] + + operations = [ + migrations.AddField( + model_name="currency", + name="show_exchanged_amounts", + field=models.BooleanField( + default=True, + help_text="Show amounts converted to the exchange currency next to transactions and totals in this currency. Accounts with their own exchange currency use their own setting, and consolidated totals are calculated either way.", + verbose_name="Show exchanged amounts", + ), + ), + ] diff --git a/app/apps/currencies/models.py b/app/apps/currencies/models.py index 6cf9c173..33ee4648 100644 --- a/app/apps/currencies/models.py +++ b/app/apps/currencies/models.py @@ -31,6 +31,15 @@ class Currency(models.Model): blank=True, help_text=_("Default currency for exchange calculations"), ) + show_exchanged_amounts = models.BooleanField( + default=True, + verbose_name=_("Show exchanged amounts"), + help_text=_( + "Show amounts converted to the exchange currency next to transactions and " + "totals in this currency. Accounts with their own exchange currency use " + "their own setting, and consolidated totals are calculated either way." + ), + ) is_archived = models.BooleanField( default=False, diff --git a/app/apps/insights/utils/month_by_month.py b/app/apps/insights/utils/month_by_month.py index fc374175..ce5d97fd 100644 --- a/app/apps/insights/utils/month_by_month.py +++ b/app/apps/insights/utils/month_by_month.py @@ -95,6 +95,7 @@ def get_month_by_month_data(year=None, group_by="categories"): "account__currency__prefix", "account__currency__suffix", "account__currency__exchange_currency", + "account__currency__show_exchanged_amounts", ) .annotate( expense_total=Coalesce( @@ -139,6 +140,12 @@ def get_month_by_month_data(year=None, group_by="categories"): item_name = metric[name_field] month = metric["reference_date__month"] currency_id = metric["account__currency"] + # Currencies can hide their exchanged amounts + exchange_currency_id = ( + metric["account__currency__exchange_currency"] + if metric["account__currency__show_exchanged_amounts"] + else None + ) # Use a consistent key for None (uncategorized/untagged/no entity) item_key = item_id if item_id is not None else "__none__" @@ -164,7 +171,7 @@ def get_month_by_month_data(year=None, group_by="categories"): "decimal_places": metric["account__currency__decimal_places"], "prefix": metric["account__currency__prefix"], "suffix": metric["account__currency__suffix"], - "exchange_currency_id": metric["account__currency__exchange_currency"], + "exchange_currency_id": exchange_currency_id, } currency_data = { @@ -181,11 +188,9 @@ def get_month_by_month_data(year=None, group_by="categories"): } # Handle currency conversion if exchange currency is set - if metric["account__currency__exchange_currency"]: + if exchange_currency_id: from_currency = Currency.objects.get(id=currency_id) - exchange_currency = Currency.objects.get( - id=metric["account__currency__exchange_currency"] - ) + exchange_currency = Currency.objects.get(id=exchange_currency_id) converted_amount, prefix, suffix, decimal_places = convert( amount=final_total, diff --git a/app/apps/insights/utils/overview.py b/app/apps/insights/utils/overview.py index 4b2f4472..8325356c 100644 --- a/app/apps/insights/utils/overview.py +++ b/app/apps/insights/utils/overview.py @@ -26,10 +26,12 @@ CURRENCY_FIELDS = ( "account__currency__prefix", "account__currency__suffix", "account__currency__exchange_currency", + "account__currency__show_exchanged_amounts", ) # Aggregated amounts, plus the totals derived from them. Every one of these is -# converted when the account's currency has an exchange currency. +# converted when the account's currency has an exchange currency and shows +# exchanged amounts. AGGREGATED_FIELDS = ( "expense_current", "expense_projected", @@ -120,7 +122,10 @@ def _build_currency_data(metric, showing): "total": currency_data[total], } - if metric["account__currency__exchange_currency"]: + if ( + metric["account__currency__exchange_currency"] + and metric["account__currency__show_exchanged_amounts"] + ): from_currency = Currency.objects.get(id=metric["account__currency"]) exchange_currency = Currency.objects.get( id=metric["account__currency__exchange_currency"] diff --git a/app/apps/insights/utils/year_by_year.py b/app/apps/insights/utils/year_by_year.py index c0d63cfd..6d5dfc1f 100644 --- a/app/apps/insights/utils/year_by_year.py +++ b/app/apps/insights/utils/year_by_year.py @@ -84,6 +84,7 @@ def get_year_by_year_data(group_by="categories"): "account__currency__prefix", "account__currency__suffix", "account__currency__exchange_currency", + "account__currency__show_exchanged_amounts", ) .annotate( expense_total=Coalesce( @@ -126,6 +127,12 @@ def get_year_by_year_data(group_by="categories"): item_name = metric[name_field] year = metric["reference_date__year"] currency_id = metric["account__currency"] + # Currencies can hide their exchanged amounts + exchange_currency_id = ( + metric["account__currency__exchange_currency"] + if metric["account__currency__show_exchanged_amounts"] + else None + ) # Use a consistent key for None (uncategorized/untagged/no entity) item_key = item_id if item_id is not None else "__none__" @@ -151,7 +158,7 @@ def get_year_by_year_data(group_by="categories"): "decimal_places": metric["account__currency__decimal_places"], "prefix": metric["account__currency__prefix"], "suffix": metric["account__currency__suffix"], - "exchange_currency_id": metric["account__currency__exchange_currency"], + "exchange_currency_id": exchange_currency_id, } currency_data = { @@ -168,11 +175,9 @@ def get_year_by_year_data(group_by="categories"): } # Handle currency conversion if exchange currency is set - if metric["account__currency__exchange_currency"]: + if exchange_currency_id: from_currency = Currency.objects.get(id=currency_id) - exchange_currency = Currency.objects.get( - id=metric["account__currency__exchange_currency"] - ) + exchange_currency = Currency.objects.get(id=exchange_currency_id) converted_amount, prefix, suffix, decimal_places = convert( amount=final_total, diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index 3129244a..ff066cdc 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -604,6 +604,19 @@ class Transaction(OwnedObject): return None + def displayed_exchanged_amount(self): + """The exchanged amount to display, or None when it's hidden. + + The account's exchange currency takes precedence over its currency's, so + whichever one provides it also decides whether it's shown. + """ + if self.account.exchange_currency_id: + show = self.account.show_exchanged_amounts + else: + show = self.account.currency.show_exchanged_amounts + + return self.exchanged_amount() if show else None + def __str__(self): type_display = self.get_type_display() frmt_date = date(self.date, "SHORT_DATE_FORMAT") diff --git a/app/apps/transactions/tests/test_exchanged_amounts.py b/app/apps/transactions/tests/test_exchanged_amounts.py new file mode 100644 index 00000000..1c1a8a1f --- /dev/null +++ b/app/apps/transactions/tests/test_exchanged_amounts.py @@ -0,0 +1,182 @@ +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, override_settings +from django.urls import reverse + +from apps.accounts.models import Account, AccountGroup +from apps.common.middleware.thread_local import delete_current_user, write_current_user +from apps.currencies.models import Currency, ExchangeRate +from apps.insights.utils.month_by_month import get_month_by_month_data +from apps.insights.utils.year_by_year import get_year_by_year_data +from apps.transactions.models import Transaction, TransactionCategory +from apps.transactions.utils.calculations import ( + calculate_account_totals, + calculate_currency_totals, +) + + +@override_settings( + STORAGES={ + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" + }, + }, + WHITENOISE_AUTOREFRESH=True, +) +class ExchangedAmountsTestBase(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + email="exchanged@test.com", password="testpass123" + ) + self.client.login(username="exchanged@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, + prefix="€ ", + exchange_currency=self.usd, + ) + ExchangeRate.objects.create( + from_currency=self.eur, + to_currency=self.usd, + rate=Decimal("2"), + date=datetime(2026, 1, 1, tzinfo=dt_timezone.utc), + ) + self.account = Account.objects.create( + name="Euro Account", + group=AccountGroup.objects.create(name="Group"), + currency=self.eur, + ) + self.transaction = Transaction.objects.create( + account=self.account, + type=Transaction.Type.INCOME, + is_paid=True, + date=date(2026, 3, 10), + reference_date=date(2026, 3, 1), + amount=Decimal("100"), + description="Salary", + category=TransactionCategory.objects.create(name="Work"), + ) + + def _hide_for(self, obj): + obj.show_exchanged_amounts = False + obj.save() + + +class TransactionExchangedAmountTests(ExchangedAmountsTestBase): + def test_shown_by_default(self): + exchanged = self.transaction.displayed_exchanged_amount() + + self.assertEqual(exchanged["amount"], Decimal("200")) + self.assertEqual(exchanged["prefix"], "$ ") + + def test_currency_can_hide_it(self): + self._hide_for(self.eur) + self.transaction.refresh_from_db() + + self.assertIsNone(self.transaction.displayed_exchanged_amount()) + # Only hidden from display, the API keeps returning it + self.assertEqual(self.transaction.exchanged_amount()["amount"], Decimal("200")) + + def test_account_exchange_currency_follows_the_account_setting(self): + self.account.exchange_currency = self.usd + self.account.save() + self._hide_for(self.eur) + self.transaction.refresh_from_db() + + self.assertEqual( + self.transaction.displayed_exchanged_amount()["amount"], Decimal("200") + ) + + self._hide_for(self.account) + self.transaction.refresh_from_db() + + self.assertIsNone(self.transaction.displayed_exchanged_amount()) + + def test_monthly_list_hides_it(self): + url = reverse("monthly_transactions_list", kwargs={"month": 3, "year": 2026}) + + response = self.client.get(url, HTTP_HX_REQUEST="true") + self.assertContains(response, "exchanged-amount") + + self._hide_for(self.eur) + response = self.client.get(url, HTTP_HX_REQUEST="true") + self.assertContains(response, f'data-transaction-id="{self.transaction.id}"') + self.assertNotContains(response, "exchanged-amount") + + +class TotalsExchangedAmountTests(ExchangedAmountsTestBase): + def test_currency_totals_hide_it_but_keep_consolidating(self): + self._hide_for(self.eur) + + totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True) + + self.assertNotIn("exchanged", totals[self.eur.id]) + self.assertEqual( + totals[self.usd.id]["consolidated"]["total_final"], Decimal("200") + ) + + def test_currency_totals_hide_it_for_currencies_found_by_deep_search(self): + brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + self.usd.exchange_currency = brl + self.usd.save() + + totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True) + self.assertIn("exchanged", totals[self.usd.id]) + + self._hide_for(self.usd) + totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True) + self.assertNotIn("exchanged", totals[self.usd.id]) + + def test_account_totals_follow_the_account_setting(self): + self.account.exchange_currency = self.usd + self.account.save() + + totals = calculate_account_totals(Transaction.objects.all()) + self.assertEqual( + totals[self.account.id]["exchanged"]["total_final"], Decimal("200") + ) + + self._hide_for(self.account) + totals = calculate_account_totals(Transaction.objects.all()) + self.assertNotIn("exchanged", totals[self.account.id]) + + def test_insights_hide_it(self): + def exchanged_totals(): + month_data = get_month_by_month_data(year=2026) + year_data = get_year_by_year_data() + return [ + month_data["grand_total"]["currencies"][self.eur.id], + month_data["month_totals"][3]["currencies"][self.eur.id], + year_data["grand_total"]["currencies"][self.eur.id], + year_data["year_totals"][2026]["currencies"][self.eur.id], + ] + + for total in exchanged_totals(): + self.assertEqual(total["exchanged"]["final_total"], Decimal("200")) + + self._hide_for(self.eur) + for total in exchanged_totals(): + self.assertNotIn("exchanged", total) + + def test_net_worth_keeps_the_consolidated_total(self): + self._hide_for(self.eur) + + response = self.client.get(reverse("net_worth"), {"view_type": "current"}) + + currency_net_worth = response.context["currency_net_worth"] + self.assertNotIn("exchanged", currency_net_worth[self.eur.id]) + self.assertEqual( + currency_net_worth[self.usd.id]["consolidated"]["total_final"], + Decimal("200"), + ) + self.assertContains(response, "Consolidated") diff --git a/app/apps/transactions/utils/calculations.py b/app/apps/transactions/utils/calculations.py index 0a6b3907..889c63d5 100644 --- a/app/apps/transactions/utils/calculations.py +++ b/app/apps/transactions/utils/calculations.py @@ -29,6 +29,7 @@ def calculate_currency_totals( "account__currency__exchange_currency__decimal_places", "account__currency__exchange_currency__prefix", "account__currency__exchange_currency__suffix", + "account__currency__show_exchanged_amounts", ) .annotate( expense_current=Coalesce( @@ -177,7 +178,10 @@ def calculate_currency_totals( converted_val if converted_val is not None else Decimal("0") ) - currency_data["exchanged"] = exchanged_details + # Consolidated totals below need the exchanged values either way; + # the currency's setting only hides them from display + if total["account__currency__show_exchanged_amounts"]: + currency_data["exchanged"] = exchanged_details if exchange_currency_obj_for_this_total.id not in currencies_using_exchange: currencies_using_exchange[exchange_currency_obj_for_this_total.id] = [] @@ -255,9 +259,10 @@ def calculate_currency_totals( converted_val if converted_val is not None else Decimal("0") ) - currency_data_for_db_currency["exchanged"] = ( - exchanged_details_for_db_currency - ) + if db_currency.show_exchanged_amounts: + currency_data_for_db_currency["exchanged"] = ( + exchanged_details_for_db_currency + ) # Ensure its own exchange_currency is registered in currencies_using_exchange # and add it to the queue if it hasn't been processed yet for deep add. @@ -400,6 +405,7 @@ def calculate_account_totals(transactions_queryset, ignore_empty=False): "account__currency__prefix", "account__currency__suffix", "account__exchange_currency", + "account__show_exchanged_amounts", ).annotate( expense_current=Coalesce( Sum( @@ -468,6 +474,7 @@ def calculate_account_totals(transactions_queryset, ignore_empty=False): exchange_currency = ( Currency.objects.get(id=total["account__exchange_currency"]) if total["account__exchange_currency"] + and total["account__show_exchanged_amounts"] else None ) diff --git a/app/templates/cotton/transaction/_transfer_leg.html b/app/templates/cotton/transaction/_transfer_leg.html index a3cd8e50..21c9bdef 100644 --- a/app/templates/cotton/transaction/_transfer_leg.html +++ b/app/templates/cotton/transaction/_transfer_leg.html @@ -34,7 +34,7 @@ :decimal_places="leg.account.currency.decimal_places" :color="color"> {% if not dummy %} - {% with exchanged=leg.exchanged_amount %} + {% with exchanged=leg.displayed_exchanged_amount %} {% if exchanged %} {# Exchange Rate#} {% if not dummy %} - {% with exchanged=transaction.exchanged_amount %} + {% with exchanged=transaction.displayed_exchanged_amount %} {% if exchanged %}
Date: Wed, 11 Nov 2026 10:22:50 -0300 Subject: [PATCH 2/2] refactor(currencies): keep the exchanged amounts display rule in one place Account.displayed_exchange_currency holds the account/currency precedence for transactions, and shown_exchange_currency() expresses the same rule as a query expression for the insights and account totals, replacing the per-module checks. --- app/apps/accounts/models.py | 13 +++++ app/apps/currencies/utils/display.py | 13 +++++ app/apps/insights/utils/month_by_month.py | 11 +--- app/apps/insights/utils/overview.py | 19 +++---- app/apps/insights/utils/year_by_year.py | 11 +--- app/apps/transactions/models.py | 62 ++++++++------------- app/apps/transactions/utils/calculations.py | 9 ++- 7 files changed, 68 insertions(+), 70 deletions(-) create mode 100644 app/apps/currencies/utils/display.py diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index 5a4d4079..c5f8aca7 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -199,6 +199,19 @@ class Account(SharedObject): user = get_current_user() return self.untracked_by.filter(pk=user.pk).exists() + @property + def displayed_exchange_currency(self): + """The exchange currency to show amounts in, or None when they're hidden. + + The account's exchange currency takes precedence over its currency's, so + whichever one provides it also decides whether it's shown. + """ + if self.exchange_currency_id: + return self.exchange_currency if self.show_exchanged_amounts else None + if self.currency.show_exchanged_amounts: + return self.currency.exchange_currency + return None + def clean(self): super().clean() if self.exchange_currency == self.currency: diff --git a/app/apps/currencies/utils/display.py b/app/apps/currencies/utils/display.py new file mode 100644 index 00000000..e2102725 --- /dev/null +++ b/app/apps/currencies/utils/display.py @@ -0,0 +1,13 @@ +from django.db.models import Case, F, When + + +def shown_exchange_currency(path): + """Query expression for the exchange currency id at ``path`` (an account or a + currency lookup, such as ``"account__currency__"``), or NULL when that object + hides its exchanged amounts.""" + return Case( + When( + **{f"{path}show_exchanged_amounts": True}, + then=F(f"{path}exchange_currency"), + ) + ) diff --git a/app/apps/insights/utils/month_by_month.py b/app/apps/insights/utils/month_by_month.py index ce5d97fd..4d90984e 100644 --- a/app/apps/insights/utils/month_by_month.py +++ b/app/apps/insights/utils/month_by_month.py @@ -8,6 +8,7 @@ from django.utils import timezone from apps.currencies.models import Currency from apps.currencies.utils.convert import convert +from apps.currencies.utils.display import shown_exchange_currency from apps.transactions.models import Transaction @@ -94,8 +95,7 @@ def get_month_by_month_data(year=None, group_by="categories"): "account__currency__decimal_places", "account__currency__prefix", "account__currency__suffix", - "account__currency__exchange_currency", - "account__currency__show_exchanged_amounts", + exchange_currency_id=shown_exchange_currency("account__currency__"), ) .annotate( expense_total=Coalesce( @@ -140,12 +140,7 @@ def get_month_by_month_data(year=None, group_by="categories"): item_name = metric[name_field] month = metric["reference_date__month"] currency_id = metric["account__currency"] - # Currencies can hide their exchanged amounts - exchange_currency_id = ( - metric["account__currency__exchange_currency"] - if metric["account__currency__show_exchanged_amounts"] - else None - ) + exchange_currency_id = metric["exchange_currency_id"] # Use a consistent key for None (uncategorized/untagged/no entity) item_key = item_id if item_id is not None else "__none__" diff --git a/app/apps/insights/utils/overview.py b/app/apps/insights/utils/overview.py index 8325356c..29b9008f 100644 --- a/app/apps/insights/utils/overview.py +++ b/app/apps/insights/utils/overview.py @@ -6,6 +6,7 @@ from django.db.models.functions import Coalesce from apps.currencies.models import Currency from apps.currencies.utils.convert import convert +from apps.currencies.utils.display import shown_exchange_currency from apps.transactions.models import Transaction # Grouping levels the overview can be built from. Any ordering of these keys is @@ -25,8 +26,6 @@ CURRENCY_FIELDS = ( "account__currency__decimal_places", "account__currency__prefix", "account__currency__suffix", - "account__currency__exchange_currency", - "account__currency__show_exchanged_amounts", ) # Aggregated amounts, plus the totals derived from them. Every one of these is @@ -80,7 +79,12 @@ def _sum_of(transaction_type, is_paid): def _aggregate(transactions_queryset, group_fields, name_field): """Sum income/expense per currency, grouped by the given fields.""" return ( - transactions_queryset.values(*group_fields, name_field, *CURRENCY_FIELDS) + transactions_queryset.values( + *group_fields, + name_field, + *CURRENCY_FIELDS, + exchange_currency_id=shown_exchange_currency("account__currency__"), + ) .annotate( expense_current=_sum_of(Transaction.Type.EXPENSE, is_paid=True), expense_projected=_sum_of(Transaction.Type.EXPENSE, is_paid=False), @@ -122,14 +126,9 @@ def _build_currency_data(metric, showing): "total": currency_data[total], } - if ( - metric["account__currency__exchange_currency"] - and metric["account__currency__show_exchanged_amounts"] - ): + if metric["exchange_currency_id"]: from_currency = Currency.objects.get(id=metric["account__currency"]) - exchange_currency = Currency.objects.get( - id=metric["account__currency__exchange_currency"] - ) + exchange_currency = Currency.objects.get(id=metric["exchange_currency_id"]) exchanged = {} for field in AGGREGATED_FIELDS + TOTAL_FIELDS: diff --git a/app/apps/insights/utils/year_by_year.py b/app/apps/insights/utils/year_by_year.py index 6d5dfc1f..1bae0a86 100644 --- a/app/apps/insights/utils/year_by_year.py +++ b/app/apps/insights/utils/year_by_year.py @@ -7,6 +7,7 @@ from django.db.models.functions import Coalesce from apps.currencies.models import Currency from apps.currencies.utils.convert import convert +from apps.currencies.utils.display import shown_exchange_currency from apps.transactions.models import Transaction @@ -83,8 +84,7 @@ def get_year_by_year_data(group_by="categories"): "account__currency__decimal_places", "account__currency__prefix", "account__currency__suffix", - "account__currency__exchange_currency", - "account__currency__show_exchanged_amounts", + exchange_currency_id=shown_exchange_currency("account__currency__"), ) .annotate( expense_total=Coalesce( @@ -127,12 +127,7 @@ def get_year_by_year_data(group_by="categories"): item_name = metric[name_field] year = metric["reference_date__year"] currency_id = metric["account__currency"] - # Currencies can hide their exchanged amounts - exchange_currency_id = ( - metric["account__currency__exchange_currency"] - if metric["account__currency__show_exchanged_amounts"] - else None - ) + exchange_currency_id = metric["exchange_currency_id"] # Use a consistent key for None (uncategorized/untagged/no entity) item_key = item_id if item_id is not None else "__none__" diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index ff066cdc..e928b062 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -573,49 +573,33 @@ class Transaction(OwnedObject): super().delete(*args, **kwargs) def exchanged_amount(self): - if self.account.exchange_currency: - converted_amount, prefix, suffix, decimal_places = convert( - self.amount, - to_currency=self.account.exchange_currency, - from_currency=self.account.currency, - date=self.date, - ) - if converted_amount: - return { - "amount": converted_amount, - "prefix": prefix, - "suffix": suffix, - "decimal_places": decimal_places, - } - elif self.account.currency.exchange_currency: - converted_amount, prefix, suffix, decimal_places = convert( - self.amount, - to_currency=self.account.currency.exchange_currency, - from_currency=self.account.currency, - date=self.date, - ) - if converted_amount: - return { - "amount": converted_amount, - "prefix": prefix, - "suffix": suffix, - "decimal_places": decimal_places, - } - - return None + return self._exchanged_to( + self.account.exchange_currency or self.account.currency.exchange_currency + ) def displayed_exchanged_amount(self): - """The exchanged amount to display, or None when it's hidden. + """The exchanged amount to display, or None when it's hidden.""" + return self._exchanged_to(self.account.displayed_exchange_currency) - The account's exchange currency takes precedence over its currency's, so - whichever one provides it also decides whether it's shown. - """ - if self.account.exchange_currency_id: - show = self.account.show_exchanged_amounts - else: - show = self.account.currency.show_exchanged_amounts + def _exchanged_to(self, exchange_currency): + if not exchange_currency: + return None - return self.exchanged_amount() if show else None + converted_amount, prefix, suffix, decimal_places = convert( + self.amount, + to_currency=exchange_currency, + from_currency=self.account.currency, + date=self.date, + ) + if converted_amount: + return { + "amount": converted_amount, + "prefix": prefix, + "suffix": suffix, + "decimal_places": decimal_places, + } + + return None def __str__(self): type_display = self.get_type_display() diff --git a/app/apps/transactions/utils/calculations.py b/app/apps/transactions/utils/calculations.py index 889c63d5..fd2459ed 100644 --- a/app/apps/transactions/utils/calculations.py +++ b/app/apps/transactions/utils/calculations.py @@ -6,6 +6,7 @@ from django.db.models.functions import Coalesce from apps.transactions.models import Transaction from apps.currencies.utils.convert import convert +from apps.currencies.utils.display import shown_exchange_currency from apps.currencies.models import Currency @@ -404,8 +405,7 @@ def calculate_account_totals(transactions_queryset, ignore_empty=False): "account__currency__decimal_places", "account__currency__prefix", "account__currency__suffix", - "account__exchange_currency", - "account__show_exchanged_amounts", + exchange_currency_id=shown_exchange_currency("account__"), ).annotate( expense_current=Coalesce( Sum( @@ -472,9 +472,8 @@ def calculate_account_totals(transactions_queryset, ignore_empty=False): account_id = total["account"] currency = Currency.objects.get(id=total["account__currency__id"]) exchange_currency = ( - Currency.objects.get(id=total["account__exchange_currency"]) - if total["account__exchange_currency"] - and total["account__show_exchanged_amounts"] + Currency.objects.get(id=total["exchange_currency_id"]) + if total["exchange_currency_id"] else None )