Merge pull request #664 from officialasishkumar/feat/optional-exchanged-amounts

feat(currencies): make displaying exchanged amounts optional
This commit is contained in:
Herculino Trotta authored and GitHub committed 2026-11-11 10:26:39 -03:00
commit 9d36af78c4
16 files changed
+320 -39

No files matched your search

+2
View File
@@ -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(
@@ -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",
),
),
]
+21
View File
@@ -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:
+1
View File
@@ -80,6 +80,7 @@ class AccountSerializer(serializers.ModelSerializer):
"currency_id",
"exchange_currency",
"exchange_currency_id",
"show_exchanged_amounts",
"is_asset",
"is_investment",
"investment_class",
+2
View File
@@ -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:
@@ -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",
),
),
]
+9
View File
@@ -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,
+13
View File
@@ -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"),
)
)
+6 -6
View File
@@ -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,
+11 -7
View File
@@ -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:
+6 -6
View File
@@ -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,
+12 -15
View File
@@ -573,24 +573,21 @@ 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,
return self._exchanged_to(
self.account.exchange_currency or self.account.currency.exchange_currency
)
if converted_amount:
return {
"amount": converted_amount,
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
}
elif 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=self.account.currency.exchange_currency,
to_currency=exchange_currency,
from_currency=self.account.currency,
date=self.date,
)
@@ -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")
+9 -3
View File
@@ -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,6 +179,9 @@ def calculate_currency_totals(
converted_val if converted_val is not None else Decimal("0")
)
# 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:
@@ -255,6 +260,7 @@ def calculate_currency_totals(
converted_val if converted_val is not None else Decimal("0")
)
if db_currency.show_exchanged_amounts:
currency_data_for_db_currency["exchanged"] = (
exchanged_details_for_db_currency
)
@@ -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
)
@@ -34,7 +34,7 @@
:decimal_places="leg.account.currency.decimal_places"
:color="color"></c-amount.display>
{% if not dummy %}
{% with exchanged=leg.exchanged_amount %}
{% with exchanged=leg.displayed_exchanged_amount %}
{% if exchanged %}
<c-amount.display
:amount="exchanged.amount"
+1 -1
View File
@@ -142,7 +142,7 @@
</div>
{# Exchange Rate#}
{% if not dummy %}
{% with exchanged=transaction.exchanged_amount %}
{% with exchanged=transaction.displayed_exchanged_amount %}
{% if exchanged %}
<div class="exchanged-amount mb-2 lg:mb-0">
<c-amount.display