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..c5f8aca7 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, @@ -191,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/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/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 fc374175..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,7 +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", + exchange_currency_id=shown_exchange_currency("account__currency__"), ) .annotate( expense_total=Coalesce( @@ -139,6 +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"] + 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__" @@ -164,7 +166,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 +183,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..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,11 +26,11 @@ CURRENCY_FIELDS = ( "account__currency__decimal_places", "account__currency__prefix", "account__currency__suffix", - "account__currency__exchange_currency", ) # 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", @@ -78,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), @@ -120,11 +126,9 @@ def _build_currency_data(metric, showing): "total": currency_data[total], } - if metric["account__currency__exchange_currency"]: + 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 c0d63cfd..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,7 +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", + exchange_currency_id=shown_exchange_currency("account__currency__"), ) .annotate( expense_total=Coalesce( @@ -126,6 +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"] + 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__" @@ -151,7 +153,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 +170,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..e928b062 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -573,34 +573,31 @@ 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 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.""" + return self._exchanged_to(self.account.displayed_exchange_currency) + + def _exchanged_to(self, exchange_currency): + if not exchange_currency: + return 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 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..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 @@ -29,6 +30,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 +179,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 +260,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. @@ -399,7 +405,7 @@ def calculate_account_totals(transactions_queryset, ignore_empty=False): "account__currency__decimal_places", "account__currency__prefix", "account__currency__suffix", - "account__exchange_currency", + exchange_currency_id=shown_exchange_currency("account__"), ).annotate( expense_current=Coalesce( Sum( @@ -466,8 +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"] + Currency.objects.get(id=total["exchange_currency_id"]) + if total["exchange_currency_id"] 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 %}