From 2cd9238b185f23763433c8bedcb1e5884c2403d3 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Thu, 5 Nov 2026 00:14:41 -0300 Subject: [PATCH 01/26] feat(transactions): link an existing expense and income as a transfer Adds Transaction.link_transfer() and transfer_link_error(). Only the link changes: dates, amounts and rules are left alone. --- app/apps/transactions/models.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index 03255867..8093b2e1 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -509,6 +509,30 @@ class Transaction(OwnedObject): self.save() return to_transaction + def transfer_link_error(self, other): + """Why ``self`` and ``other`` can't be joined into a transfer, or None.""" + if self.linked_transaction_id == other.pk and other.linked_transaction_id == self.pk: + return None + if {self.type, other.type} != {self.Type.INCOME, self.Type.EXPENSE}: + return _("A transfer needs one expense and one income.") + if self.account_id == other.account_id: + return _("A transfer needs two different accounts.") + if self.linked_transaction_id not in (None, other.pk) or ( + other.linked_transaction_id not in (None, self.pk) + ): + return _("This transaction is already part of another transfer.") + return None + + def link_transfer(self, other): + """Joins an existing expense and income into a transfer.""" + error = self.transfer_link_error(other) + if error: + raise ValidationError(error) + # update(): only the link changes; dates, amounts and rules are left alone + Transaction.all_objects.filter(pk=self.pk).update(linked_transaction=other) + Transaction.all_objects.filter(pk=other.pk).update(linked_transaction=self) + self.linked_transaction, other.linked_transaction = other, self + @property def transfer_from(self): if self.linked_transaction_id and self.type == self.Type.INCOME: From 4d494549b7eca2bbca70b55fbeb0865e9b450b3e Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Thu, 5 Nov 2026 00:14:41 -0300 Subject: [PATCH 02/26] feat(dca): link entry transactions as transfers Transactions created from a DCA entry, or picked from existing ones, are now linked as a transfer, so investment tracking sees the purchase. A data migration links the pairs of existing entries when valid. --- app/apps/dca/forms.py | 12 +++ .../0005_link_entry_transactions.py | 39 ++++++++ app/apps/dca/tests/test_transfer_linking.py | 99 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 app/apps/dca/migrations/0005_link_entry_transactions.py create mode 100644 app/apps/dca/tests/test_transfer_linking.py diff --git a/app/apps/dca/forms.py b/app/apps/dca/forms.py index 39b73aca..a435766a 100644 --- a/app/apps/dca/forms.py +++ b/app/apps/dca/forms.py @@ -289,6 +289,14 @@ class DCAEntryForm(forms.ModelForm): raise forms.ValidationError( _("From and To accounts must be different.") ) + else: + # Both sides of a purchase are linked as a transfer on save + expense = cleaned_data.get("expense_transaction") + income = cleaned_data.get("income_transaction") + if expense and income: + error = expense.transfer_link_error(income) + if error: + self.add_error("income_transaction", error) return cleaned_data @@ -334,6 +342,8 @@ class DCAEntryForm(forms.ModelForm): ) to_transaction.tags.set(self.cleaned_data.get("to_tags", [])) + from_transaction.link_transfer(to_transaction) + instance.expense_transaction = from_transaction instance.income_transaction = to_transaction else: @@ -343,6 +353,8 @@ class DCAEntryForm(forms.ModelForm): if instance.income_transaction: instance.income_transaction.amount = instance.amount_received instance.income_transaction.save() + if instance.expense_transaction and instance.income_transaction: + instance.expense_transaction.link_transfer(instance.income_transaction) instance.strategy = self.strategy instance.save() diff --git a/app/apps/dca/migrations/0005_link_entry_transactions.py b/app/apps/dca/migrations/0005_link_entry_transactions.py new file mode 100644 index 00000000..e440deeb --- /dev/null +++ b/app/apps/dca/migrations/0005_link_entry_transactions.py @@ -0,0 +1,39 @@ +from django.db import migrations + + +def link_entry_transactions(apps, schema_editor): + """Joins each DCA entry's expense and income into a transfer, so investment + tracking sees the purchase. Mirrors Transaction.transfer_link_error().""" + DCAEntry = apps.get_model("dca", "DCAEntry") + Transaction = apps.get_model("transactions", "Transaction") + + entries = DCAEntry.objects.filter( + expense_transaction__isnull=False, income_transaction__isnull=False + ).select_related("expense_transaction", "income_transaction") + for entry in entries: + expense, income = entry.expense_transaction, entry.income_transaction + if ( + expense.type != "EX" + or income.type != "IN" + or expense.account_id == income.account_id + or expense.linked_transaction_id + or income.linked_transaction_id + # a legacy one-way link already points at one of them + or Transaction.objects.filter( + linked_transaction_id__in=(expense.pk, income.pk) + ).exists() + ): + continue + Transaction.objects.filter(pk=expense.pk).update(linked_transaction=income) + Transaction.objects.filter(pk=income.pk).update(linked_transaction=expense) + + +class Migration(migrations.Migration): + dependencies = [ + ("dca", "0004_alter_dcastrategy_owner_and_more"), + ("transactions", "0052_transfer_templates"), + ] + + operations = [ + migrations.RunPython(link_entry_transactions, migrations.RunPython.noop), + ] diff --git a/app/apps/dca/tests/test_transfer_linking.py b/app/apps/dca/tests/test_transfer_linking.py new file mode 100644 index 00000000..c98c9e2a --- /dev/null +++ b/app/apps/dca/tests/test_transfer_linking.py @@ -0,0 +1,99 @@ +from datetime import date +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +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 +from apps.dca.forms import DCAEntryForm +from apps.dca.models import DCAStrategy +from apps.transactions.models import Transaction + +IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE + + +class DCATransferLinkingTests(TestCase): + def setUp(self): + # Form querysets are scoped to the current user + write_current_user(get_user_model().objects.create_user(email="d@test.com")) + self.addCleanup(delete_current_user) + brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + btc = Currency.objects.create(code="BTC", name="Bitcoin", decimal_places=8) + self.cash = Account.objects.create(name="Cash", currency=brl) + self.wallet = Account.objects.create(name="Wallet", currency=btc) + self.strategy = DCAStrategy.objects.create( + name="BTC", target_currency=btc, payment_currency=brl + ) + + def tx(self, account, type_, amount): + return Transaction.objects.create( + account=account, type=type_, amount=Decimal(amount), date=date(2026, 1, 5) + ) + + def entry_data(self, **extra): + return { + "date": "2026-01-05", + "amount_paid": "500", + "amount_received": "0.001", + **extra, + } + + def test_created_transactions_are_linked(self): + form = DCAEntryForm( + data=self.entry_data( + create_transaction="on", + from_account=self.cash.id, + to_account=self.wallet.id, + ), + strategy=self.strategy, + ) + self.assertTrue(form.is_valid(), form.errors) + entry = form.save() + self.assertEqual( + entry.expense_transaction.linked_transaction, entry.income_transaction + ) + self.assertEqual( + entry.income_transaction.linked_transaction, entry.expense_transaction + ) + + def test_existing_transactions_are_linked(self): + expense, income = self.tx(self.cash, EX, "500"), self.tx(self.wallet, IN, "0.001") + form = DCAEntryForm( + data=self.entry_data( + expense_transaction=str(expense.id), income_transaction=str(income.id) + ), + strategy=self.strategy, + ) + self.assertTrue(form.is_valid(), form.errors) + form.save() + expense.refresh_from_db() + income.refresh_from_db() + self.assertEqual(expense.linked_transaction, income) + self.assertEqual(income.linked_transaction, expense) + + def test_transaction_in_another_transfer_is_rejected(self): + expense, income = self.tx(self.cash, EX, "500"), self.tx(self.wallet, IN, "0.001") + other = self.tx(self.wallet, IN, "0.002") + expense.link_transfer(other) + form = DCAEntryForm( + data=self.entry_data( + expense_transaction=str(expense.id), income_transaction=str(income.id) + ), + strategy=self.strategy, + ) + self.assertFalse(form.is_valid()) + self.assertIn("income_transaction", form.errors) + + def test_link_transfer_rules(self): + expense = self.tx(self.cash, EX, "500") + with self.assertRaises(ValidationError): + expense.link_transfer(self.tx(self.cash, IN, "500")) # same account + with self.assertRaises(ValidationError): + expense.link_transfer(self.tx(self.wallet, EX, "1")) # two expenses + income = self.tx(self.wallet, IN, "0.001") + expense.link_transfer(income) + expense.link_transfer(income) # already linked to each other: no-op + self.assertEqual(Transaction.objects.get(pk=income.pk).linked_transaction, expense) From 1b9ea452d955513a6fe23ed6c626878e40ab981d Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Thu, 5 Nov 2026 00:14:41 -0300 Subject: [PATCH 03/26] feat(transactions): prefill add and transfer forms from query parameters transaction_add accepts account and entities; transactions_transfer accepts from_account and to_account. Applied only when present. --- app/apps/transactions/views/transactions.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/apps/transactions/views/transactions.py b/app/apps/transactions/views/transactions.py index a18c17bf..9ceab0a5 100644 --- a/app/apps/transactions/views/transactions.py +++ b/app/apps/transactions/views/transactions.py @@ -119,6 +119,15 @@ def transaction_attachment_delete(request, attachment_id): return response +def _prefill(request, *fields, entities=False): + """Form initial values passed as query parameters; only the named ones, and + only when present, so they never override other defaults.""" + initial = {name: request.GET[name] for name in fields if request.GET.get(name)} + if entities and request.GET.getlist("entities"): + initial["entities"] = request.GET.getlist("entities") # entity names + return initial + + @only_htmx @login_required @require_http_methods(["GET", "POST"]) @@ -204,6 +213,8 @@ def transaction_add(request): initial={ "date": expected_date, "type": transaction_type, + # Optional prefill, e.g. a dividend from the investments page + **_prefill(request, "account", entities=True), }, ) @@ -541,6 +552,8 @@ def transactions_transfer(request): initial={ "reference_date": expected_date, "date": expected_date, + # Optional prefill, e.g. buy/sell from the investments page + **_prefill(request, "from_account", "to_account"), }, ) From 359e5880cc886b0ded0c9ddeaa5a4c275cca7bdb Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Thu, 5 Nov 2026 00:14:42 -0300 Subject: [PATCH 04/26] feat(investments): add investment tracker Investments are accounts flagged as investments; performance is derived from their transactions. Linked transfers are contributions and withdrawals (average cost, realized gains), unlinked transactions are gains or losses, and income comes from the accounts' income entities. - Unit-priced (exchange currency) or value-tracked accounts - Money-weighted return (XIRR), annualized after a year - Totals converted along each currency's exchange chain, with currency effect - Investments list and detail pages; buy/sell/income use the transfer and transaction forms - GET /api/accounts//performance/ --- app/WYGIWYH/settings.py | 1 + app/WYGIWYH/urls.py | 1 + app/apps/accounts/forms.py | 30 +- ...unt_income_entity_account_is_investment.py | 25 ++ .../migrations/0018_income_entities.py | 43 ++ app/apps/accounts/models.py | 19 + app/apps/api/serializers/accounts.py | 45 ++ app/apps/api/views/accounts.py | 16 + app/apps/investments/__init__.py | 0 app/apps/investments/apps.py | 6 + app/apps/investments/services.py | 391 ++++++++++++++++++ app/apps/investments/tests/__init__.py | 0 app/apps/investments/tests/test_services.py | 243 +++++++++++ app/apps/investments/urls.py | 18 + app/apps/investments/views.py | 106 +++++ app/templates/includes/sidebar.html | 6 + app/templates/investments/fragments/_mwr.html | 1 + .../investments/fragments/_percentage.html | 1 + .../investments/fragments/_totals.html | 38 ++ .../investments/fragments/detail.html | 270 ++++++++++++ app/templates/investments/fragments/list.html | 93 +++++ app/templates/investments/pages/detail.html | 8 + app/templates/investments/pages/index.html | 8 + 23 files changed, 1366 insertions(+), 3 deletions(-) create mode 100644 app/apps/accounts/migrations/0017_account_income_entity_account_is_investment.py create mode 100644 app/apps/accounts/migrations/0018_income_entities.py create mode 100644 app/apps/investments/__init__.py create mode 100644 app/apps/investments/apps.py create mode 100644 app/apps/investments/services.py create mode 100644 app/apps/investments/tests/__init__.py create mode 100644 app/apps/investments/tests/test_services.py create mode 100644 app/apps/investments/urls.py create mode 100644 app/apps/investments/views.py create mode 100644 app/templates/investments/fragments/_mwr.html create mode 100644 app/templates/investments/fragments/_percentage.html create mode 100644 app/templates/investments/fragments/_totals.html create mode 100644 app/templates/investments/fragments/detail.html create mode 100644 app/templates/investments/fragments/list.html create mode 100644 app/templates/investments/pages/detail.html create mode 100644 app/templates/investments/pages/index.html diff --git a/app/WYGIWYH/settings.py b/app/WYGIWYH/settings.py index f7756b73..24393480 100644 --- a/app/WYGIWYH/settings.py +++ b/app/WYGIWYH/settings.py @@ -77,6 +77,7 @@ INSTALLED_APPS = [ "apps.rules.apps.RulesConfig", "apps.calendar_view.apps.CalendarViewConfig", "apps.dca.apps.DcaConfig", + "apps.investments.apps.InvestmentsConfig", "pwa", "allauth", "allauth.account", diff --git a/app/WYGIWYH/urls.py b/app/WYGIWYH/urls.py index 0a442852..5726903d 100644 --- a/app/WYGIWYH/urls.py +++ b/app/WYGIWYH/urls.py @@ -93,6 +93,7 @@ urlpatterns = [ path("", include("apps.rules.urls")), path("", include("apps.calendar_view.urls")), path("", include("apps.dca.urls")), + path("", include("apps.investments.urls")), path("", include("apps.mini_tools.urls")), path("", include("apps.import_app.urls")), path("", include("apps.export_app.urls")), diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index 59030301..d445d6ce 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -9,12 +9,16 @@ from apps.common.widgets.crispy.daisyui import Switch from apps.common.widgets.crispy.submit import NoClassSubmit from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput -from apps.common.widgets.tom_select import TomSelect +from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple from apps.currencies.models import Currency -from apps.transactions.models import TransactionCategory, TransactionTag +from apps.transactions.models import ( + TransactionCategory, + TransactionEntity, + TransactionTag, +) from crispy_forms.bootstrap import FormActions from crispy_forms.helper import FormHelper -from crispy_forms.layout import Column, Field, Layout, Row +from crispy_forms.layout import HTML, Column, Field, Layout, Row from django import forms from django.db.models import Q from django.utils import timezone @@ -68,16 +72,25 @@ class AccountForm(forms.ModelForm): "exchange_currency", "is_asset", "is_archived", + "is_investment", + "income_entities", ] widgets = { "currency": TomSelect(), "exchange_currency": TomSelect(), + "income_entities": TomSelectMultiple(), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["group"].queryset = AccountGroup.objects.all() + entities = Q(active=True) + if self.instance.id: + entities |= Q(investment_accounts=self.instance.id) + self.fields["income_entities"].queryset = TransactionEntity.objects.filter( + entities + ).distinct() if self.instance.id: qs = Currency.objects.filter( @@ -101,6 +114,17 @@ class AccountForm(forms.ModelForm): Switch("is_archived"), "currency", "exchange_currency", + Switch("is_investment"), + # Only relevant for investments: shown while the switch above is on + Field( + "income_entities", + wrapper_class="" if self.instance.is_investment else "hidden", + ), + HTML( + '' + ), ) if self.instance and self.instance.pk: diff --git a/app/apps/accounts/migrations/0017_account_income_entity_account_is_investment.py b/app/apps/accounts/migrations/0017_account_income_entity_account_is_investment.py new file mode 100644 index 00000000..56649971 --- /dev/null +++ b/app/apps/accounts/migrations/0017_account_income_entity_account_is_investment.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.17 on 2026-11-05 01:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0016_account_untracked_by'), + ('transactions', '0052_transfer_templates'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='income_entity', + field=models.ForeignKey(blank=True, help_text="Income transactions with this entity (dividends, interest, rent) count towards this investment's return.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='investment_accounts', to='transactions.transactionentity', verbose_name='Income entity'), + ), + migrations.AddField( + model_name='account', + name='is_investment', + field=models.BooleanField(default=False, help_text='Show this account in the Investments tracker. Set an exchange currency to price it by units (stocks, ETFs, crypto); leave it empty to track it by value (fixed income, property, pensions).', verbose_name='Investment'), + ), + ] diff --git a/app/apps/accounts/migrations/0018_income_entities.py b/app/apps/accounts/migrations/0018_income_entities.py new file mode 100644 index 00000000..fa4c91e7 --- /dev/null +++ b/app/apps/accounts/migrations/0018_income_entities.py @@ -0,0 +1,43 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_income_entity(apps, schema_editor): + Account = apps.get_model("accounts", "Account") + for account in Account.objects.exclude(income_entity=None): + account.income_entities.add(account.income_entity_id) + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0017_account_income_entity_account_is_investment"), + ("transactions", "0052_transfer_templates"), + ] + + operations = [ + # Free the "investment_accounts" reverse name for the new field + migrations.AlterField( + model_name="account", + name="income_entity", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="transactions.transactionentity", + ), + ), + migrations.AddField( + model_name="account", + name="income_entities", + field=models.ManyToManyField( + blank=True, + help_text="Income transactions with any of these entities (dividends, interest, rent) count towards this investment's return.", + related_name="investment_accounts", + to="transactions.transactionentity", + verbose_name="Income entities", + ), + ), + migrations.RunPython(copy_income_entity, migrations.RunPython.noop), + migrations.RemoveField(model_name="account", name="income_entity"), + ] diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index 55c064c0..4f676b29 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -57,6 +57,25 @@ class Account(SharedObject): "Asset accounts count towards your Net Worth, but not towards your month." ), ) + is_investment = models.BooleanField( + default=False, + verbose_name=_("Investment"), + help_text=_( + "Show this account in the Investments tracker. Set an exchange currency to " + "price it by units (stocks, ETFs, crypto); leave it empty to track it by " + "value (fixed income, property, pensions)." + ), + ) + income_entities = models.ManyToManyField( + "transactions.TransactionEntity", + blank=True, + related_name="investment_accounts", + verbose_name=_("Income entities"), + help_text=_( + "Income transactions with any of these entities (dividends, interest, " + "rent) count towards this investment's return." + ), + ) is_archived = models.BooleanField( default=False, verbose_name=_("Archived"), diff --git a/app/apps/api/serializers/accounts.py b/app/apps/api/serializers/accounts.py index db95f256..0506982d 100644 --- a/app/apps/api/serializers/accounts.py +++ b/app/apps/api/serializers/accounts.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.db.models import Q from rest_framework import serializers from rest_framework.permissions import IsAuthenticated @@ -50,6 +52,8 @@ class AccountSerializer(serializers.ModelSerializer): "exchange_currency", "exchange_currency_id", "is_asset", + "is_investment", + "income_entities", ] def __init__(self, *args, **kwargs): @@ -76,3 +80,44 @@ class AccountBalanceSerializer(serializers.Serializer): projected_balance = serializers.DecimalField(max_digits=20, decimal_places=10) currency = CurrencySerializer() + + +class InvestmentPerformanceSerializer(serializers.Serializer): + """Serializer for an investment account's performance.""" + + currency = CurrencySerializer() + priced = serializers.BooleanField() + quantity = serializers.DecimalField(max_digits=42, decimal_places=30) + price = serializers.DecimalField(max_digits=42, decimal_places=30, allow_null=True) + price_date = serializers.DateTimeField(allow_null=True) + average_cost = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + cost_basis = serializers.DecimalField(max_digits=42, decimal_places=30) + market_value = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + unrealized = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + realized = serializers.DecimalField(max_digits=42, decimal_places=30) + income = serializers.DecimalField(max_digits=42, decimal_places=30) + contributed = serializers.DecimalField(max_digits=42, decimal_places=30) + withdrawn = serializers.DecimalField(max_digits=42, decimal_places=30) + total_return = serializers.DecimalField(max_digits=42, decimal_places=30) + return_percentage = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + estimated = serializers.BooleanField() + money_weighted_return = serializers.SerializerMethodField( + help_text="Return weighted by when money went in and out, in percent: " + "annual (XIRR) when the first contribution is a year old or more, " + "otherwise over the period held." + ) + money_weighted_annualized = serializers.SerializerMethodField() + + def get_money_weighted_return(self, perf) -> Decimal | None: + return perf.money_weighted and perf.money_weighted["percentage"] + + def get_money_weighted_annualized(self, perf) -> bool | None: + return perf.money_weighted and perf.money_weighted["annualized"] diff --git a/app/apps/api/views/accounts.py b/app/apps/api/views/accounts.py index f7e44e1b..a62da741 100644 --- a/app/apps/api/views/accounts.py +++ b/app/apps/api/views/accounts.py @@ -11,7 +11,9 @@ from apps.api.serializers import ( AccountGroupSerializer, AccountSerializer, AccountBalanceSerializer, + InvestmentPerformanceSerializer, ) +from apps.investments.services import get_holding_performance class AccountGroupViewSet(viewsets.ModelViewSet): @@ -38,6 +40,11 @@ class AccountGroupViewSet(viewsets.ModelViewSet): description="Returns the current and projected balance for the account, along with currency data.", responses={200: AccountBalanceSerializer}, ), + performance=extend_schema( + summary="Get investment performance", + description="Returns quantity, cost basis, market value, realized/unrealized gains, income and total return, in the account's exchange currency (or its own currency when it has none).", + responses={200: InvestmentPerformanceSerializer}, + ), ) class AccountViewSet(viewsets.ModelViewSet): """ViewSet for managing accounts.""" @@ -51,6 +58,7 @@ class AccountViewSet(viewsets.ModelViewSet): "currency": ["exact"], "exchange_currency": ["exact", "isnull"], "is_asset": ["exact"], + "is_investment": ["exact"], "is_archived": ["exact"], "owner": ["exact"], } @@ -80,3 +88,11 @@ class AccountViewSet(viewsets.ModelViewSet): ) return Response(serializer.data) + + @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) + def performance(self, request, pk=None): + """Get investment performance (cost basis, value, returns) for an account.""" + account = self.get_object() + return Response( + InvestmentPerformanceSerializer(get_holding_performance(account)).data + ) diff --git a/app/apps/investments/__init__.py b/app/apps/investments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/apps/investments/apps.py b/app/apps/investments/apps.py new file mode 100644 index 00000000..4b50cd38 --- /dev/null +++ b/app/apps/investments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InvestmentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.investments" diff --git a/app/apps/investments/services.py b/app/apps/investments/services.py new file mode 100644 index 00000000..45d411b4 --- /dev/null +++ b/app/apps/investments/services.py @@ -0,0 +1,391 @@ +"""Investment performance, derived from an account's transactions. + +Rules (see the account's "Investment" help text): +- A linked transaction (a transfer) moves money in or out. Its other side says + what was paid or received, which is the cost or the proceeds. +- An unlinked transaction is a gain or a loss: a balance reconciliation, interest, + a tax withheld. On a unit-priced account an unlinked inflow (a gift, an opening + balance) has no known cost, so it is costed at the market price of that day + and the result is flagged as estimated. +- Cost is tracked with the average cost method. +""" + +import datetime +from dataclasses import dataclass, field +from decimal import Decimal +from functools import cached_property + +from django.db.models import Q +from django.utils import timezone + +from apps.currencies.models import ExchangeRate +from apps.currencies.utils.convert import convert, get_exchange_rate +from apps.transactions.models import Transaction + +ZERO = Decimal("0") + + +@dataclass +class HoldingPerformance: + account: object + currency: object # valuation currency + priced: bool # True: units x price. False: tracked by value. + quantity: Decimal = ZERO + cost_basis: Decimal = ZERO + price: Decimal | None = None + price_date: datetime.datetime | None = None + realized: Decimal = ZERO + income: Decimal = ZERO + contributed: Decimal = ZERO + withdrawn: Decimal = ZERO + estimated: bool = False + history: list = field(default_factory=list) + # (date, amount, kind) in the valuation currency: negative = money in ("in"), + # positive = money out ("out" sales/withdrawals, "income"). Used for + # money-weighted returns and currency conversion. + flows: list = field(default_factory=list) + as_of: datetime.date | None = None + + @cached_property + def money_weighted(self): + return money_weighted_return(self.flows, self.market_value, self.as_of) + + @property + def average_cost(self): + return self.cost_basis / self.quantity if self.quantity else None + + @property + def market_value(self): + if self.price is None: + return None + return self.quantity * self.price + + @property + def unrealized(self): + value = self.market_value + return None if value is None else value - self.cost_basis + + @property + def total_return(self): + return (self.unrealized or ZERO) + self.realized + self.income + + @property + def return_percentage(self): + if not self.contributed: + return None + return self.total_return / self.contributed * 100 + + @property + def unrealized_percentage(self): + if not self.cost_basis or self.unrealized is None: + return None + return self.unrealized / self.cost_basis * 100 + + +def _to(amount, from_currency, to_currency, date): + if from_currency == to_currency or not amount: + return amount + return convert(amount, from_currency, to_currency, date)[0] + + +def _price(account, valuation_currency, date): + if account.currency == valuation_currency: + return Decimal("1"), None + rate = get_exchange_rate(account.currency, valuation_currency, date) + if rate is None: + return None, None + return rate.effective_rate, rate.date + + +def income_entity_ids(account): + # The join table, not account.income_entities: that goes through the + # entities' per-user manager and comes back empty without a current user + return list( + account.income_entities.through.objects.filter(account=account).values_list( + "transactionentity_id", flat=True + ) + ) + + +def get_holding_performance(account, as_of=None, with_history=False): + """Performance of an investment account up to ``as_of`` (today by default), + in the account's exchange currency, or its own currency if it has none.""" + today = timezone.localdate() + as_of = as_of or today + valuation_currency = account.exchange_currency or account.currency + perf = HoldingPerformance( + account=account, + currency=valuation_currency, + priced=account.exchange_currency_id is not None, + ) + + transactions = ( + Transaction.objects.filter(account=account, is_paid=True, date__lte=as_of) + .select_related("linked_transaction__account__currency") + .order_by("date", "id") + ) + + month_ends = _month_ends(transactions, as_of) if with_history else [] + + for tx in transactions: + while month_ends and month_ends[0] < tx.date: + perf.history.append(_snapshot(perf, account, month_ends.pop(0))) + + # shortcut: only the forward link is read; WYGIWYH transfers link both ways. + partner = tx.linked_transaction + if partner and (partner.account_id == account.id or partner.deleted): + partner = None + partner_value = ( + _to(partner.amount, partner.account.currency, valuation_currency, tx.date) + if partner + else None + ) + + if tx.type == Transaction.Type.INCOME: + if partner_value is not None: + cost = partner_value + perf.contributed += cost + elif perf.priced or partner: + # No known cost: price it at that day's market value + cost = _to(tx.amount, account.currency, valuation_currency, tx.date) + perf.estimated = True + if partner: + perf.contributed += cost or ZERO + else: + cost = ZERO # value-tracked: an unlinked inflow is a gain + if cost: + perf.flows.append((tx.date, -cost, "in")) + perf.quantity += tx.amount + perf.cost_basis += cost or ZERO + else: + if perf.quantity > 0: + removed = perf.cost_basis * min(tx.amount, perf.quantity) / perf.quantity + else: + removed = ZERO + if partner: + proceeds = partner_value + if proceeds is None: + proceeds = _to(tx.amount, account.currency, valuation_currency, tx.date) or ZERO + perf.estimated = True + perf.realized += proceeds - removed + perf.withdrawn += proceeds + perf.flows.append((tx.date, proceeds, "out")) + perf.cost_basis -= removed + # unlinked outflow: a loss, tax or fee; cost stays, so value drops + perf.quantity -= tx.amount + if perf.quantity <= 0 and perf.cost_basis: + perf.realized -= perf.cost_basis + perf.cost_basis = ZERO + + for month_end in month_ends: + perf.history.append(_snapshot(perf, account, month_end)) + + perf.price, perf.price_date = _price( + account, + valuation_currency, + timezone.now() if as_of == today else as_of, + ) + + entity_ids = income_entity_ids(account) + if entity_ids: + income = Transaction.objects.filter( + entities__in=entity_ids, + type=Transaction.Type.INCOME, + is_paid=True, + date__lte=as_of, + ).exclude(account=account).distinct().select_related("account__currency") + for tx in income: + amount = _to(tx.amount, tx.account.currency, valuation_currency, tx.date) + if amount: + perf.income += amount + perf.flows.append((tx.date, amount, "income")) + + perf.as_of = as_of + return perf + + +def xirr(flows): + """Annual rate r where the dated flows' present value is zero, as a float + (0.138 = 13.8% a year). None when it can't be solved.""" + if not flows: + return None + start = min(flow[0] for flow in flows) + points = [((flow[0] - start).days / 365, float(flow[1])) for flow in flows] + if not (any(a < 0 for _, a in points) and any(a > 0 for _, a in points)): + return None + + def npv(rate): + return sum(a / (1 + rate) ** t for t, a in points) + + # Bisection: slower than Newton but can't diverge on odd cash flows + low, high = -0.9999, 100.0 + npv_low = npv(low) + if npv_low * npv(high) > 0: + return None + for _ in range(200): + mid = (low + high) / 2 + npv_mid = npv(mid) + if npv_low * npv_mid <= 0: + high = mid + else: + low, npv_low = mid, npv_mid + return (low + high) / 2 + + +def money_weighted_return(flows, market_value, as_of): + """Return that accounts for when money went in and out, as + ``{"percentage": Decimal, "annualized": bool}`` or None. + + Over a year or more it is annualized (XIRR). Below a year an annual rate + exaggerates, so it is turned back into the return over the period held.""" + if not flows or market_value is None: + return None + rate = xirr(flows + [(as_of, market_value)]) + if rate is None: + return None + days = (as_of - min(flow[0] for flow in flows)).days + if days >= 365: + return {"percentage": Decimal(str(round(rate * 100, 4))), "annualized": True} + period = (1 + rate) ** (days / 365) - 1 + return {"percentage": Decimal(str(round(period * 100, 4))), "annualized": False} + + +def _month_ends(transactions, as_of): + first = transactions.values_list("date", flat=True).first() + if not first: + return [] + ends = [] + year, month = first.year, first.month + while True: + next_first = datetime.date(year + month // 12, month % 12 + 1, 1) + end = next_first - datetime.timedelta(days=1) + if end >= as_of: + ends.append(as_of) + return ends + ends.append(end) + year, month = next_first.year, next_first.month + + +def _snapshot(perf, account, date): + price, _ = _price(account, perf.currency, date) + return { + "date": date, + "invested": perf.cost_basis, + "value": perf.quantity * price if price is not None else None, + } + + +def conversion_path(currency): + """``currency`` followed by its exchange currencies (USD -> BRL -> ...), for as + long as an exchange rate between each step exists.""" + path = [currency] + while True: + target = path[-1].exchange_currency + if ( + target is None + or target in path + or get_exchange_rate(path[-1], target, timezone.now()) is None + ): + return path + path.append(target) + + +def _along(amount, path, date): + for from_currency, to_currency in zip(path, path[1:]): + amount = _to(amount, from_currency, to_currency, date) + return amount + + +def _native(perf): + return { + "currency": perf.currency, + "market_value": perf.market_value, + "cost_basis": perf.cost_basis, + "income": perf.income, + "total_return": perf.total_return, + "currency_effect": ZERO, + "flows": perf.flows, + } + + +def in_home_currency(perf): + """The holding converted along its currency's exchange chain, each cash flow + at its own date's rate, so currency moves count towards the return. + None when its currency converts to nothing.""" + path = conversion_path(perf.currency) + if len(path) == 1: + return None + flows = [(d, _along(a, path, d), kind) for d, a, kind in perf.flows] + value = None + if perf.market_value is not None: + value = _along(perf.market_value, path, perf.as_of) + total_return = (value or ZERO) + sum((a for _, a, _ in flows), ZERO) + + put_in = -sum((a for _, a, k in flows if k == "in"), ZERO) + put_in_native = -sum((a for _, a, k in perf.flows if k == "in"), ZERO) + # shortcut: what you still hold is valued at the average rate you paid in, + # not lot by lot; exact for buy-and-hold, approximate after partial sales. + cost_basis = ( + perf.cost_basis * put_in / put_in_native + if put_in_native + else _along(perf.cost_basis, path, perf.as_of) + ) + return { + "currency": path[-1], + "path": path, + "market_value": value, + "cost_basis": cost_basis, + "income": sum((a for _, a, k in flows if k == "income"), ZERO), + "total_return": total_return, + # what the return gained or lost from the exchange rate moving + "currency_effect": total_return + - _along(perf.total_return, path, perf.as_of), + # With a single rate every date converts the same, so the effect reads 0 + "has_rate_history": all( + ExchangeRate.objects.filter( + Q(from_currency=a, to_currency=b) | Q(from_currency=b, to_currency=a) + )[:2].count() + > 1 + for a, b in zip(path, path[1:]) + ), + "flows": flows, + "money_weighted": money_weighted_return(flows, value, perf.as_of), + } + + +def summarize(performances, convert=False): + """Totals per currency. With ``convert``, holdings are first converted along + their exchange chain (see in_home_currency); the ones that can't be stay in + their own currency.""" + totals = {} + for perf in performances: + figures = (convert and in_home_currency(perf)) or _native(perf) + total = totals.setdefault( + figures["currency"].id, + { + "currency": figures["currency"], + "market_value": ZERO, + "cost_basis": ZERO, + "total_return": ZERO, + "income": ZERO, + "currency_effect": ZERO, + "converted_from": [], + "has_rate_history": True, + "flows": [], + "as_of": perf.as_of, + }, + ) + for key in ("cost_basis", "total_return", "income", "currency_effect"): + total[key] += figures[key] + total["has_rate_history"] &= figures.get("has_rate_history", True) + if figures["currency"] != perf.currency and perf.currency not in total["converted_from"]: + total["converted_from"].append(perf.currency) + # A holding without a price has no end value, so its flows would skew the rate + if figures["market_value"] is not None: + total["market_value"] += figures["market_value"] + total["flows"] += figures["flows"] + for total in totals.values(): + total["money_weighted"] = money_weighted_return( + total["flows"], total["market_value"], total["as_of"] + ) + return list(totals.values()) diff --git a/app/apps/investments/tests/__init__.py b/app/apps/investments/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/apps/investments/tests/test_services.py b/app/apps/investments/tests/test_services.py new file mode 100644 index 00000000..930b53be --- /dev/null +++ b/app/apps/investments/tests/test_services.py @@ -0,0 +1,243 @@ +import datetime +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 django.utils import timezone + +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.investments.services import get_holding_performance, summarize +from apps.transactions.models import Transaction, TransactionEntity + +D = Decimal +IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE + + +def tx(account, type_, amount, date, **kwargs): + return Transaction.objects.create( + account=account, type=type_, amount=D(amount), date=date, **kwargs + ) + + +def transfer(from_account, from_amount, to_account, to_amount, date): + out = tx(from_account, EX, from_amount, date) + inc = tx(to_account, IN, to_amount, date, linked_transaction=out) + out.linked_transaction = inc + out.save() + return out, inc + + +class HoldingPerformanceTests(TestCase): + def setUp(self): + self.eur = Currency.objects.create(code="EUR", name="Euro", decimal_places=2) + self.brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + self.vwce = Currency.objects.create( + code="VWCE.DE", name="VWCE", decimal_places=4, exchange_currency=self.eur + ) + self.cash = Account.objects.create(name="Cash", currency=self.eur) + self.etf = Account.objects.create( + name="VWCE", + currency=self.vwce, + exchange_currency=self.eur, + is_investment=True, + is_asset=True, + ) + self.d1 = datetime.date(2026, 1, 10) + self.d2 = datetime.date(2026, 2, 10) + self.d3 = datetime.date(2026, 3, 10) + + def rate(self, value, date): + ExchangeRate.objects.create( + from_currency=self.vwce, + to_currency=self.eur, + rate=D(value), + date=timezone.make_aware(datetime.datetime.combine(date, datetime.time())), + ) + + def test_priced_average_cost_realized_and_income(self): + transfer(self.cash, "1000", self.etf, "10", self.d1) + transfer(self.cash, "1200", self.etf, "10", self.d2) + transfer(self.etf, "5", self.cash, "700", self.d3) + self.rate("130", timezone.localdate()) + + entity = TransactionEntity.objects.create(name="Vanguard") + other = TransactionEntity.objects.create(name="Vanguard JCP") + self.etf.income_entities.set([entity, other]) + dividend = tx(self.cash, IN, "30", self.d3) + dividend.entities.add(entity, other) # matches twice, counted once + + perf = get_holding_performance(self.etf) + + self.assertTrue(perf.priced) + self.assertEqual(perf.quantity, D("15")) + self.assertEqual(perf.cost_basis, D("1650")) + self.assertEqual(perf.average_cost, D("110")) + self.assertEqual(perf.realized, D("150")) # 700 - 5 * 110 + self.assertEqual(perf.market_value, D("1950")) + self.assertEqual(perf.unrealized, D("300")) + self.assertEqual(perf.income, D("30")) + self.assertEqual(perf.contributed, D("2200")) + self.assertEqual(perf.withdrawn, D("700")) + self.assertEqual(perf.total_return, D("480")) + self.assertFalse(perf.estimated) + + def test_unlinked_inflow_on_priced_account_is_costed_at_market(self): + self.rate("90", self.d1) + tx(self.etf, IN, "2", self.d1) + + perf = get_holding_performance(self.etf) + + self.assertEqual(perf.cost_basis, D("180")) + self.assertTrue(perf.estimated) + self.assertEqual(perf.contributed, D("0")) + + def test_value_tracked_cdb_with_tax_on_redemption(self): + cdb = Account.objects.create( + name="CDB", currency=self.brl, is_investment=True, is_asset=True + ) + checking = Account.objects.create(name="Checking", currency=self.brl) + transfer(checking, "1000", cdb, "1000", self.d1) + tx(cdb, IN, "100", self.d2) # balance reconciliation: yield + tx(cdb, EX, "22.50", self.d3) # income tax withheld + + perf = get_holding_performance(cdb) + self.assertFalse(perf.priced) + self.assertEqual(perf.market_value, D("1077.50")) + self.assertEqual(perf.cost_basis, D("1000")) + self.assertEqual(perf.unrealized, D("77.50")) + + transfer(cdb, "1077.50", checking, "1077.50", self.d3) + perf = get_holding_performance(cdb) + self.assertEqual(perf.quantity, D("0")) + self.assertEqual(perf.cost_basis, D("0")) + self.assertEqual(perf.realized, D("77.50")) + self.assertEqual(perf.total_return, D("77.50")) + + def test_history_has_one_point_per_month_end(self): + transfer(self.cash, "1000", self.etf, "10", self.d1) + self.rate("100", self.d1) + self.rate("120", self.d3) + + perf = get_holding_performance( + self.etf, as_of=datetime.date(2026, 3, 15), with_history=True + ) + + self.assertEqual( + [p["date"] for p in perf.history], + [ + datetime.date(2026, 1, 31), + datetime.date(2026, 2, 28), + datetime.date(2026, 3, 15), + ], + ) + self.assertEqual(perf.history[-1]["value"], D("1200")) + self.assertEqual(perf.history[0]["invested"], D("1000")) + + def test_summarize_groups_by_currency(self): + transfer(self.cash, "1000", self.etf, "10", self.d1) + self.rate("110", timezone.localdate()) + totals = summarize([get_holding_performance(self.etf)]) + self.assertEqual(len(totals), 1) + self.assertEqual(totals[0]["market_value"], D("1100")) + self.assertEqual(totals[0]["total_return"], D("100")) + + def test_xirr_and_money_weighted_return(self): + from apps.investments.services import money_weighted_return, xirr + + start, year = datetime.date(2025, 1, 1), datetime.date(2026, 1, 1) + self.assertAlmostEqual(xirr([(start, D(-1000)), (year, D(1100))]), 0.10, places=6) + self.assertIsNone(xirr([(start, D(-1000))])) + + # A year or more: annualized + mw = money_weighted_return([(start, D(-1000), "in")], D(1100), year) + self.assertTrue(mw["annualized"]) + self.assertAlmostEqual(float(mw["percentage"]), 10, places=3) + + # Less than a year: the return over the period, not an inflated annual rate + half = datetime.date(2025, 7, 2) + mw = money_weighted_return([(start, D(-1000), "in")], D(1050), half) + self.assertFalse(mw["annualized"]) + self.assertAlmostEqual(float(mw["percentage"]), 5, places=3) + + # Money added late weighs less: +100 on 1000 held all year beats the + # simple +100 / 2000 = 5% when the second 1000 only came in at the end + flows = [(start, D(-1000), "in"), (datetime.date(2025, 12, 31), D(-1000), "in")] + mw = money_weighted_return(flows, D(2100), year) + self.assertGreater(float(mw["percentage"]), 9.9) + + def test_home_currency_counts_the_exchange_rate_move(self): + from apps.investments.services import in_home_currency + + usd = Currency.objects.create(code="USD", name="Dollar", decimal_places=2) + usd.exchange_currency = self.brl + usd.save() + etf = Currency.objects.create( + code="VT", name="VT", decimal_places=4, exchange_currency=usd + ) + cash = Account.objects.create(name="USD cash", currency=usd) + holding = Account.objects.create( + name="VT", currency=etf, exchange_currency=usd, is_investment=True + ) + aware = lambda d: timezone.make_aware(datetime.datetime.combine(d, datetime.time())) + ExchangeRate.objects.create(from_currency=usd, to_currency=self.brl, rate=D("5"), date=aware(self.d1)) + ExchangeRate.objects.create(from_currency=usd, to_currency=self.brl, rate=D("6"), date=aware(timezone.localdate())) + ExchangeRate.objects.create(from_currency=etf, to_currency=usd, rate=D("100"), date=aware(timezone.localdate())) + transfer(cash, "1000", holding, "10", self.d1) + + perf = get_holding_performance(holding) + self.assertEqual(perf.total_return, D("0")) # flat in dollars + + home = in_home_currency(perf) + self.assertEqual(home["currency"], self.brl) + self.assertEqual(home["market_value"], D("6000")) + self.assertEqual(home["cost_basis"], D("5000")) + self.assertEqual(home["total_return"], D("1000")) + self.assertEqual(home["currency_effect"], D("1000")) # all of it from the dollar + + totals = summarize([perf], convert=True) + self.assertEqual([t["currency"] for t in totals], [self.brl]) + self.assertEqual(totals[0]["converted_from"], [usd]) + # No rate to anything: stays in its own currency + self.assertEqual(summarize([get_holding_performance(self.etf)], convert=True)[0]["currency"], self.eur) + + +@override_settings( + STORAGES={ + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" + }, + }, + WHITENOISE_AUTOREFRESH=True, + DEMO=False, +) +class InvestmentViewTests(TestCase): + HTMX = {"HTTP_HX_REQUEST": "true"} + + def setUp(self): + User = get_user_model() + self.owner = User.objects.create_user(email="o@test.com", password="pw") + self.stranger = User.objects.create_user(email="s@test.com", password="pw") + brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + self.account = Account.all_objects.create( + name="CDB", currency=brl, is_investment=True, owner=self.owner + ) + + def test_owner_sees_list_and_detail(self): + self.client.force_login(self.owner) + response = self.client.get(reverse("investments_list"), **self.HTMX) + self.assertContains(response, "CDB") + response = self.client.get( + reverse("investment_detail", args=[self.account.id]), **self.HTMX + ) + self.assertEqual(response.status_code, 200) + + def test_stranger_cannot_see_detail(self): + self.client.force_login(self.stranger) + response = self.client.get( + reverse("investment_detail_index", args=[self.account.id]) + ) + self.assertEqual(response.status_code, 404) diff --git a/app/apps/investments/urls.py b/app/apps/investments/urls.py new file mode 100644 index 00000000..e3864d62 --- /dev/null +++ b/app/apps/investments/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("investments/", views.investments_index, name="investments_index"), + path("investments/list/", views.investments_list, name="investments_list"), + path( + "investments//", + views.investment_detail_index, + name="investment_detail_index", + ), + path( + "investments//details/", + views.investment_detail, + name="investment_detail", + ), +] diff --git a/app/apps/investments/views.py b/app/apps/investments/views.py new file mode 100644 index 00000000..339a951a --- /dev/null +++ b/app/apps/investments/views.py @@ -0,0 +1,106 @@ +import json + +from django.contrib.auth.decorators import login_required +from django.shortcuts import render +from django.utils.formats import date_format + +from apps.accounts.models import Account +from apps.common.decorators.htmx import only_htmx +from apps.common.functions.permissions import READ, get_shared_object_or_error +from apps.investments.services import ( + get_holding_performance, + in_home_currency, + income_entity_ids, + summarize, +) +from apps.transactions.models import Transaction + + +def _holdings(): + return Account.objects.filter(is_investment=True, is_archived=False).select_related( + "group", "currency", "exchange_currency" + ) + + +@login_required +def investments_index(request): + return render(request, "investments/pages/index.html") + + +@only_htmx +@login_required +def investments_list(request): + performances = [get_holding_performance(account) for account in _holdings()] + + groups = {} + for perf in performances: + group = perf.account.group + groups.setdefault(group.id if group else None, (group, []))[1].append(perf) + + return render( + request, + "investments/fragments/list.html", + { + "groups": [ + {"group": group, "holdings": items, "totals": summarize(items)} + for group, items in sorted( + groups.values(), key=lambda g: (g[0] is None, g[0] and g[0].name) + ) + ], + "totals": summarize(performances, convert=True), + }, + ) + + +@login_required +def investment_detail_index(request, account_id): + account = get_shared_object_or_error( + Account, request, id=account_id, level=READ, is_investment=True + ) + return render(request, "investments/pages/detail.html", {"account": account}) + + +@only_htmx +@login_required +def investment_detail(request, account_id): + account = get_shared_object_or_error( + Account, request, id=account_id, level=READ, is_investment=True + ) + perf = get_holding_performance(account, with_history=True) + transactions = ( + Transaction.objects.filter(account=account) + .select_related("linked_transaction__account__currency", "category") + .order_by("-date", "-id")[:50] + ) + income = [] + entity_ids = income_entity_ids(account) + if entity_ids: + income = ( + Transaction.objects.filter( + entities__in=entity_ids, type=Transaction.Type.INCOME + ) + .exclude(account=account) + .distinct() + .select_related("account__currency") + .order_by("-date", "-id")[:20] + ) + + chart = { + "labels": [date_format(p["date"], "M Y") for p in perf.history], + "value": [float(p["value"]) if p["value"] is not None else None for p in perf.history], + "invested": [float(p["invested"]) for p in perf.history], + } + + return render( + request, + "investments/fragments/detail.html", + { + "account": account, + "perf": perf, + "home": in_home_currency(perf), + "transactions": transactions, + "income": income, + "income_entities": list(account.income_entities.all()), + "chart": json.dumps(chart), + }, + ) diff --git a/app/templates/includes/sidebar.html b/app/templates/includes/sidebar.html index 1edaf52b..05d44934 100644 --- a/app/templates/includes/sidebar.html +++ b/app/templates/includes/sidebar.html @@ -114,6 +114,12 @@ + + {% include 'investments/fragments/_percentage.html' with value=mw.percentage %}{% if mw.annualized %}{% translate '/yr' %}{% endif %}{% endif %} diff --git a/app/templates/investments/fragments/_percentage.html b/app/templates/investments/fragments/_percentage.html new file mode 100644 index 00000000..3bc2415c --- /dev/null +++ b/app/templates/investments/fragments/_percentage.html @@ -0,0 +1 @@ +{% if value is not None %}{% if value > 0 %}+{% endif %}{{ value|floatformat:"2g" }}%{% endif %} diff --git a/app/templates/investments/fragments/_totals.html b/app/templates/investments/fragments/_totals.html new file mode 100644 index 00000000..4592309c --- /dev/null +++ b/app/templates/investments/fragments/_totals.html @@ -0,0 +1,38 @@ +{% load i18n %} +{% with c=total.currency %} +
+
+
+
+
{% translate 'Market value' %} · {{ c.name }}
+
+ +
+
+
+
{% translate 'Total return' %}
+
+ + {% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %} +
+
+
+
+
{% translate 'Invested' %}
+
{% translate 'Income' %}
+ {% if total.converted_from %} + {% if total.has_rate_history %} +
{% translate 'Currency effect' %}
+ {% else %} +
{% translate 'Currency effect' %}
+ {% endif %} + {% endif %} +
+ {% if total.converted_from %} +
+ {% translate 'Includes holdings in' %} {% for currency in total.converted_from %}{{ currency.name }}{% if not forloop.last %}, {% endif %}{% endfor %}, {% translate 'converted at the rate of each date' %} +
+ {% endif %} +
+
+{% endwith %} diff --git a/app/templates/investments/fragments/detail.html b/app/templates/investments/fragments/detail.html new file mode 100644 index 00000000..1861d10c --- /dev/null +++ b/app/templates/investments/fragments/detail.html @@ -0,0 +1,270 @@ +{% load i18n %} +{% with c=perf.currency a=account.currency %} +{# Buy/sell are transfers so the tracker sees them; income uses the account's income entity #} + + + + {% if income_entities %} + + {% endif %} + {% if not perf.priced %} + + {% endif %} + +
+
+
+
+ + {{ account.name }} + +
+
+ {% if account.group %}{{ account.group.name }} · {% endif %} + {% if perf.priced %}{{ a.name }} → {{ c.name }}{% else %}{% translate 'Tracked by value' %}{% endif %} + {% if perf.estimated %}{% endif %} +
+
+
+ +
+
+
+
+
{% translate 'Market value' %}
+
+ {% if perf.market_value is not None %}{% else %}-{% endif %} +
+
+ + {% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %} + {% translate 'total return' %} +
+ {% if home %} + {% with h=home.currency %} +
+ {% translate 'In' %} {{ h.name }}: + + · + + {% include 'investments/fragments/_mwr.html' with mw=home.money_weighted %} + {% if home.has_rate_history %} + · {% translate 'currency effect' %} + + + {% else %} + · {% translate 'currency effect' %} + {% endif %} +
+ {% endwith %} + {% endif %} +
+ {% if perf.priced %} +
+
+
+ {% if perf.price is not None %} + × + {% if perf.price_date %} · {{ perf.price_date|date:"SHORT_DATE_FORMAT" }}{% endif %} + {% else %} + {% translate "No exchange rate available" %} + {% endif %} +
+
+ {% endif %} +
+ +
+ +
+ +
+
+
{% translate 'Invested' %}
+
+
+
+
{% if perf.priced %}{% translate 'Average cost' %}{% else %}{% translate 'Put in / taken out' %}{% endif %}
+
+ {% if perf.priced %} + {% if perf.average_cost is not None %}{% else %}-{% endif %} + {% else %} + + / + + {% endif %} +
+
+
+
{% if perf.priced %}{% translate 'Unrealized' %}{% else %}{% translate 'Earnings' %}{% endif %}
+
+ {% if perf.unrealized is not None %} + + {% include 'investments/fragments/_percentage.html' with value=perf.unrealized_percentage css="text-xs" %} + {% else %}-{% endif %} +
+
+
+
{% translate 'Realized' %}
+
+
+
+
{% translate 'Income' %}{% if income_entities %} · {{ income_entities|join:", " }}{% endif %}
+
+
+ {% if perf.priced %} +
+
{% translate 'Put in / taken out' %}
+
+ + / + +
+
+ {% endif %} +
+
+
+ +
+
+
{% translate 'Movements' %}
+ {% if transactions %} +
+ + + {% for tx in transactions %} + {% with p=tx.linked_transaction %} + + + + + + + {% endwith %} + {% endfor %} + +
{{ tx.date|date:"SHORT_DATE_FORMAT" }} +
{{ tx.description }}
+
+ {% if p %} + {% if tx.type == 'IN' %}{% translate 'Bought / contributed' %}{% else %}{% translate 'Sold / withdrew' %}{% endif %} + · {{ p.account.name }} + {% else %} + {% if tx.type == 'IN' %}{% if perf.priced %}{% translate 'Gain' %}{% else %}{% translate 'Earnings' %}{% endif %}{% else %}{% translate 'Loss, tax or fee' %}{% endif %} + {% endif %} +
+
+ + {% if p and p.account.currency != a %} +
+ +
+ {% endif %} +
+ +
+
+ {% else %} + + {% endif %} +
+ {% if income %} +
+
{% translate 'Income' %}
+
+ + + {% for tx in income %} + + + + + + + {% endfor %} + +
{{ tx.date|date:"SHORT_DATE_FORMAT" }}{{ tx.description }}
{{ tx.account.name }}
+ +
+
+
+ {% endif %} +
+
+{% endwith %} diff --git a/app/templates/investments/fragments/list.html b/app/templates/investments/fragments/list.html new file mode 100644 index 00000000..c11add13 --- /dev/null +++ b/app/templates/investments/fragments/list.html @@ -0,0 +1,93 @@ +{% load i18n %} + + +
+
+ {% spaceless %} +
{% translate 'Investments' %}
+ {% endspaceless %} +
+ + {% if groups %} +
+ {% for total in totals %} + {% include 'investments/fragments/_totals.html' %} + {% endfor %} +
+ + {% for item in groups %} +
+
+ {% if item.group %}{{ item.group.name }}{% else %}{% translate 'Ungrouped' %}{% endif %} +
+ {% for total in item.totals %} +
+ + {% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %} +
+ {% endfor %} +
+
+
+ + + + + + + + + + + + + + + + + {% for perf in item.holdings %} + {% with c=perf.currency a=perf.account.currency %} + + + + + + + {% endwith %} + {% endfor %} + +
{% translate 'Investment' %}{% translate 'Value' %}
+
+ {{ perf.account.name }} + {% if perf.estimated %}{% endif %} +
+
+ {% if perf.priced %} + + {% if perf.price is not None %}× {% else %}· {% translate 'No exchange rate available' %}{% endif %} + {% else %} + {% translate 'Tracked by value' %} + {% endif %} +
+
+ {# Phones show value + return %, wider screens split them into columns #} +
{% if perf.market_value is not None %}{% else %}-{% endif %}
+ +
{% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %}
+
+
+
+ {% endfor %} + {% else %} + + {% endif %} +
diff --git a/app/templates/investments/pages/detail.html b/app/templates/investments/pages/detail.html new file mode 100644 index 00000000..20f0532f --- /dev/null +++ b/app/templates/investments/pages/detail.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{{ account.name }} :: {% translate 'Investments' %}{% endblock %} + +{% block content %} +
+{% endblock %} diff --git a/app/templates/investments/pages/index.html b/app/templates/investments/pages/index.html new file mode 100644 index 00000000..ac48ee38 --- /dev/null +++ b/app/templates/investments/pages/index.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{% translate 'Investments' %}{% endblock %} + +{% block content %} +
+{% endblock %} From 3e4e9f4220533c9c323b322937c54082cbbb3397 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Thu, 5 Nov 2026 11:07:47 -0300 Subject: [PATCH 05/26] fix(forms): show switch help text below the label --- app/templates/crispy-daisyui/layout/switch.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/crispy-daisyui/layout/switch.html b/app/templates/crispy-daisyui/layout/switch.html index f45e63dc..48ffbe29 100644 --- a/app/templates/crispy-daisyui/layout/switch.html +++ b/app/templates/crispy-daisyui/layout/switch.html @@ -11,7 +11,7 @@ {% crispy_field field 'class' 'toggle toggle-sm' %} {% endif %} {{ field.label }}{% if field.field.required %}*{% endif %} - {% include 'crispy-daisyui/layout/help_text_and_errors.html' %} + {% include 'crispy-daisyui/layout/help_text_and_errors.html' %} {% endif %} From 99c6ca08a2eb630b6ac7c4dadb72b8983d259123 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Thu, 5 Nov 2026 11:07:47 -0300 Subject: [PATCH 06/26] feat(currencies): configurable retention for automatic exchange rates Replace the single exchange rate option with a limit of rates kept per currency pair (oldest deleted first) and an option to keep one rate per day. Services that kept a single rate migrate to a limit of 1. --- app/apps/currencies/exchange_rates/fetcher.py | 93 +++++++------------ app/apps/currencies/forms.py | 6 +- .../0026_exchangerateservice_retention.py | 43 +++++++++ app/apps/currencies/models.py | 15 ++- .../tests/test_automatic_exchange_rates.py | 42 ++++++++- 5 files changed, 131 insertions(+), 68 deletions(-) create mode 100644 app/apps/currencies/migrations/0026_exchangerateservice_retention.py diff --git a/app/apps/currencies/exchange_rates/fetcher.py b/app/apps/currencies/exchange_rates/fetcher.py index d70f088b..a84fb4e5 100644 --- a/app/apps/currencies/exchange_rates/fetcher.py +++ b/app/apps/currencies/exchange_rates/fetcher.py @@ -165,6 +165,31 @@ class ExchangeRateFetcher: return target_currencies_qs, exchange_currencies + @staticmethod + def _store_rate(service, from_currency, to_currency, rate): + now = timezone.now() + rates = ExchangeRate.objects.filter( + automatic=True, from_currency=from_currency, to_currency=to_currency + ) + today = rates.filter(date__date=timezone.localdate(now)).order_by("-date") + existing = today.first() if service.one_per_day else None + if existing: + existing.rate, existing.date = rate, now + existing.save() + else: + ExchangeRate.objects.create( + automatic=True, + from_currency=from_currency, + to_currency=to_currency, + rate=rate, + date=now, + ) + if service.max_rates: + stale = rates.order_by("-date").values_list("id", flat=True)[ + service.max_rates : + ] + ExchangeRate.objects.filter(id__in=list(stale)).delete() + @staticmethod def _fetch_service_rates(service: ExchangeRateService) -> None: """Fetch rates for a specific service""" @@ -192,71 +217,15 @@ class ExchangeRateFetcher: processed_pairs = set() for from_currency, to_currency, rate in rates: - # Create a unique identifier for this currency pair + if provider.rates_inverted: + from_currency, to_currency = to_currency, from_currency pair_key = (from_currency.id, to_currency.id) if pair_key in processed_pairs: continue - - if provider.rates_inverted: - # If rates are inverted, we need to swap currencies - if service.singleton: - # Try to get the last automatically created exchange rate - exchange_rate = ( - ExchangeRate.objects.filter( - automatic=True, - from_currency=to_currency, - to_currency=from_currency, - ) - .order_by("-date") - .first() - ) - else: - exchange_rate = None - - if not exchange_rate: - ExchangeRate.objects.create( - automatic=True, - from_currency=to_currency, - to_currency=from_currency, - rate=rate, - date=timezone.now(), - ) - else: - exchange_rate.rate = rate - exchange_rate.date = timezone.now() - exchange_rate.save() - - processed_pairs.add((to_currency.id, from_currency.id)) - else: - # If rates are not inverted, we can use them as is - if service.singleton: - # Try to get the last automatically created exchange rate - exchange_rate = ( - ExchangeRate.objects.filter( - automatic=True, - from_currency=from_currency, - to_currency=to_currency, - ) - .order_by("-date") - .first() - ) - else: - exchange_rate = None - - if not exchange_rate: - ExchangeRate.objects.create( - automatic=True, - from_currency=from_currency, - to_currency=to_currency, - rate=rate, - date=timezone.now(), - ) - else: - exchange_rate.rate = rate - exchange_rate.date = timezone.now() - exchange_rate.save() - - processed_pairs.add((from_currency.id, to_currency.id)) + ExchangeRateFetcher._store_rate( + service, from_currency, to_currency, rate + ) + processed_pairs.add(pair_key) service.last_fetch = timezone.now() service.failure_count = 0 diff --git a/app/apps/currencies/forms.py b/app/apps/currencies/forms.py index 3c9ff64f..ea2fef05 100644 --- a/app/apps/currencies/forms.py +++ b/app/apps/currencies/forms.py @@ -107,7 +107,8 @@ class ExchangeRateServiceForm(forms.ModelForm): "fetch_interval", "target_currencies", "target_accounts", - "singleton", + "one_per_day", + "max_rates", ] def __init__(self, *args, **kwargs): @@ -120,7 +121,6 @@ class ExchangeRateServiceForm(forms.ModelForm): "name", "service_type", Switch("is_active"), - Switch("singleton"), "api_key", Row( Column("interval_type"), @@ -128,6 +128,8 @@ class ExchangeRateServiceForm(forms.ModelForm): ), "target_currencies", "target_accounts", + Switch("one_per_day"), + "max_rates", ) if self.instance and self.instance.pk: diff --git a/app/apps/currencies/migrations/0026_exchangerateservice_retention.py b/app/apps/currencies/migrations/0026_exchangerateservice_retention.py new file mode 100644 index 00000000..5c0bb8b6 --- /dev/null +++ b/app/apps/currencies/migrations/0026_exchangerateservice_retention.py @@ -0,0 +1,43 @@ +import django.core.validators +from django.db import migrations, models + + +def singleton_to_max_rates(apps, schema_editor): + Service = apps.get_model("currencies", "ExchangeRateService") + Service.objects.filter(singleton=True).update(max_rates=1) + + +def max_rates_to_singleton(apps, schema_editor): + Service = apps.get_model("currencies", "ExchangeRateService") + Service.objects.filter(max_rates=1).update(singleton=True) + + +class Migration(migrations.Migration): + dependencies = [ + ("currencies", "0025_alter_exchangerateservice_service_type"), + ] + + operations = [ + migrations.AddField( + model_name="exchangerateservice", + name="one_per_day", + field=models.BooleanField( + default=False, + help_text="Update the day's exchange rate on each fetch instead of adding a new one.", + verbose_name="One exchange rate per day", + ), + ), + migrations.AddField( + model_name="exchangerateservice", + name="max_rates", + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[django.core.validators.MinValueValidator(1)], + help_text="Keep at most this many automatic exchange rates per currency pair, deleting the oldest first. Leave empty to keep all.", + verbose_name="Exchange rates to keep", + ), + ), + migrations.RunPython(singleton_to_max_rates, max_rates_to_singleton), + migrations.RemoveField(model_name="exchangerateservice", name="singleton"), + ] diff --git a/app/apps/currencies/models.py b/app/apps/currencies/models.py index 89e4c583..791b02d1 100644 --- a/app/apps/currencies/models.py +++ b/app/apps/currencies/models.py @@ -160,11 +160,20 @@ class ExchangeRateService(models.Model): blank=True, ) - singleton = models.BooleanField( - verbose_name=_("Single exchange rate"), + one_per_day = models.BooleanField( + verbose_name=_("One exchange rate per day"), default=False, help_text=_( - "Create one exchange rate and keep updating it. Avoids database clutter." + "Update the day's exchange rate on each fetch instead of adding a new one." + ), + ) + max_rates = models.PositiveIntegerField( + verbose_name=_("Exchange rates to keep"), + null=True, + blank=True, + validators=[MinValueValidator(1)], + help_text=_( + "Keep at most this many automatic exchange rates per currency pair, deleting the oldest first. Leave empty to keep all." ), ) diff --git a/app/apps/currencies/tests/test_automatic_exchange_rates.py b/app/apps/currencies/tests/test_automatic_exchange_rates.py index c3e04d9b..8c10b874 100644 --- a/app/apps/currencies/tests/test_automatic_exchange_rates.py +++ b/app/apps/currencies/tests/test_automatic_exchange_rates.py @@ -1,10 +1,11 @@ +import datetime from decimal import Decimal from unittest.mock import patch, MagicMock from django.test import TestCase from django.utils import timezone -from apps.currencies.models import Currency, ExchangeRateService +from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher @@ -107,3 +108,42 @@ class ExchangeRateServiceFailureTrackingTests(TestCase): self.service.refresh_from_db() self.assertIsNotNone(self.service.last_fetch) self.assertEqual(self.service.failure_count, 0) + + +class ExchangeRateRetentionTests(TestCase): + def setUp(self): + self.usd = Currency.objects.create(code="USD", name="Dollar", decimal_places=2) + self.eur = Currency.objects.create( + code="EUR", name="Euro", decimal_places=2, exchange_currency=self.usd + ) + self.service = ExchangeRateService.objects.create( + name="S", service_type=ExchangeRateService.ServiceType.FRANKFURTER + ) + + def fetch(self, rate, when): + with patch("django.utils.timezone.now", return_value=when): + ExchangeRateFetcher._store_rate(self.service, self.eur, self.usd, Decimal(rate)) + + def rates(self): + return list( + ExchangeRate.objects.order_by("date").values_list("rate", flat=True) + ) + + def test_keeps_every_fetch_by_default(self): + for hour in (1, 2, 3): + self.fetch(hour, timezone.now().replace(hour=hour)) + self.assertEqual(len(self.rates()), 3) + + def test_max_rates_deletes_the_oldest(self): + self.service.max_rates = 2 + for hour in (1, 2, 3): + self.fetch(hour, timezone.now().replace(hour=hour)) + self.assertEqual(self.rates(), [Decimal(2), Decimal(3)]) + + def test_one_per_day_updates_the_days_rate(self): + self.service.one_per_day = True + day = timezone.localtime().replace(hour=10) + self.fetch(1, day) + self.fetch(2, day.replace(hour=11)) + self.fetch(3, day + datetime.timedelta(days=1)) + self.assertEqual(self.rates(), [Decimal(2), Decimal(3)]) From 1f9ba8eba8e60fde26cc8b66d90fe0901fe1e259 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sat, 7 Nov 2026 01:30:23 -0300 Subject: [PATCH 07/26] feat(investments): break converted totals down by currency Replace the note on converted totals with a collapsible per currency, and drop the uppercase labels from the summary cards. --- app/apps/investments/services.py | 11 +++++++++ .../investments/fragments/_totals.html | 23 +++++++++++++++---- .../investments/fragments/detail.html | 1 - 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/app/apps/investments/services.py b/app/apps/investments/services.py index 45d411b4..e2dbca10 100644 --- a/app/apps/investments/services.py +++ b/app/apps/investments/services.py @@ -373,8 +373,10 @@ def summarize(performances, convert=False): "has_rate_history": True, "flows": [], "as_of": perf.as_of, + "holdings": [], }, ) + total["holdings"].append(perf) for key in ("cost_basis", "total_return", "income", "currency_effect"): total[key] += figures[key] total["has_rate_history"] &= figures.get("has_rate_history", True) @@ -388,4 +390,13 @@ def summarize(performances, convert=False): total["money_weighted"] = money_weighted_return( total["flows"], total["market_value"], total["as_of"] ) + # The same holdings in their own currencies, for a converted total + total["breakdown"] = ( + sorted( + summarize(total["holdings"]), + key=lambda part: part["currency"] != total["currency"], + ) + if total["converted_from"] + else [] + ) return list(totals.values()) diff --git a/app/templates/investments/fragments/_totals.html b/app/templates/investments/fragments/_totals.html index 4592309c..8dbdbfa1 100644 --- a/app/templates/investments/fragments/_totals.html +++ b/app/templates/investments/fragments/_totals.html @@ -4,16 +4,15 @@
-
{% translate 'Market value' %} · {{ c.name }}
-
{% translate 'Total return' %}
{% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %} + {% translate 'total return' %}
@@ -28,9 +27,23 @@ {% endif %} {% endif %}
- {% if total.converted_from %} -
- {% translate 'Includes holdings in' %} {% for currency in total.converted_from %}{{ currency.name }}{% if not forloop.last %}, {% endif %}{% endfor %}, {% translate 'converted at the rate of each date' %} + {% if total.breakdown %} +
+ {% for part in total.breakdown %} + {% with p=part.currency %} +
+ + {{ p.name }} + + +
+
{% translate 'Return' %} {% include 'investments/fragments/_mwr.html' with mw=part.money_weighted %}
+
{% translate 'Invested' %}
+
{% translate 'Income' %}
+
+
+ {% endwith %} + {% endfor %}
{% endif %}
diff --git a/app/templates/investments/fragments/detail.html b/app/templates/investments/fragments/detail.html index 1861d10c..3adbeb74 100644 --- a/app/templates/investments/fragments/detail.html +++ b/app/templates/investments/fragments/detail.html @@ -54,7 +54,6 @@
-
{% translate 'Market value' %}
{% if perf.market_value is not None %}{% else %}-{% endif %}
From 0d7395cee6839e3f1f706817fc711e2bcf74dca8 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sat, 7 Nov 2026 01:30:24 -0300 Subject: [PATCH 08/26] fix(investments): tell outside money from yield on value-tracked accounts Unlinked transactions on value-tracked accounts counted as gains and losses, so a salary was yield and a bill was a loss. They now count as money in and out, unless they match the account's new income categories or tags, or its income entities. Those three define the account's result; only entities also match income paid into other accounts. --- app/apps/accounts/forms.py | 30 ++++++---- .../0019_income_categories_and_tags.py | 29 ++++++++++ app/apps/accounts/models.py | 25 ++++++++- app/apps/api/serializers/accounts.py | 2 + app/apps/investments/services.py | 56 ++++++++++++++----- app/apps/investments/tests/test_services.py | 52 ++++++++++++++++- .../investments/fragments/detail.html | 6 +- 7 files changed, 170 insertions(+), 30 deletions(-) create mode 100644 app/apps/accounts/migrations/0019_income_categories_and_tags.py diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index d445d6ce..945732d6 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -18,7 +18,7 @@ from apps.transactions.models import ( ) from crispy_forms.bootstrap import FormActions from crispy_forms.helper import FormHelper -from crispy_forms.layout import HTML, Column, Field, Layout, Row +from crispy_forms.layout import HTML, Column, Div, Field, Layout, Row from django import forms from django.db.models import Q from django.utils import timezone @@ -74,23 +74,30 @@ class AccountForm(forms.ModelForm): "is_archived", "is_investment", "income_entities", + "income_categories", + "income_tags", ] widgets = { "currency": TomSelect(), "exchange_currency": TomSelect(), "income_entities": TomSelectMultiple(), + "income_categories": TomSelectMultiple(), + "income_tags": TomSelectMultiple(), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["group"].queryset = AccountGroup.objects.all() - entities = Q(active=True) + selectable = Q(active=True) if self.instance.id: - entities |= Q(investment_accounts=self.instance.id) - self.fields["income_entities"].queryset = TransactionEntity.objects.filter( - entities - ).distinct() + selectable |= Q(investment_accounts=self.instance.id) + for name, model in ( + ("income_entities", TransactionEntity), + ("income_categories", TransactionCategory), + ("income_tags", TransactionTag), + ): + self.fields[name].queryset = model.objects.filter(selectable).distinct() if self.instance.id: qs = Currency.objects.filter( @@ -116,14 +123,17 @@ class AccountForm(forms.ModelForm): "exchange_currency", Switch("is_investment"), # Only relevant for investments: shown while the switch above is on - Field( + Div( "income_entities", - wrapper_class="" if self.instance.is_investment else "hidden", + "income_categories", + "income_tags", + css_id="investment_income", + css_class="" if self.instance.is_investment else "hidden", ), HTML( '' + "if #id_is_investment.checked remove .hidden from #investment_income " + 'else add .hidden to #investment_income end">
' ), ) diff --git a/app/apps/accounts/migrations/0019_income_categories_and_tags.py b/app/apps/accounts/migrations/0019_income_categories_and_tags.py new file mode 100644 index 00000000..9a30e0b2 --- /dev/null +++ b/app/apps/accounts/migrations/0019_income_categories_and_tags.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.17 on 2026-11-07 04:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0018_income_entities'), + ('transactions', '0052_transfer_templates'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='income_categories', + field=models.ManyToManyField(blank=True, help_text="Transactions in this account with any of these categories are its result (interest, taxes, fees). On accounts tracked by value, everything else that isn't a transfer counts as money coming in or going out.", related_name='investment_accounts', to='transactions.transactioncategory', verbose_name='Income categories'), + ), + migrations.AddField( + model_name='account', + name='income_tags', + field=models.ManyToManyField(blank=True, help_text='Same as income categories, for transactions with any of these tags.', related_name='investment_accounts', to='transactions.transactiontag', verbose_name='Income tags'), + ), + migrations.AlterField( + model_name='account', + name='income_entities', + field=models.ManyToManyField(blank=True, help_text="Transactions with any of these entities count towards this investment's return: in this account, and income paid into other accounts (dividends, rent).", related_name='investment_accounts', to='transactions.transactionentity', verbose_name='Income entities'), + ), + ] diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index 4f676b29..df1f45ef 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -72,8 +72,29 @@ class Account(SharedObject): related_name="investment_accounts", verbose_name=_("Income entities"), help_text=_( - "Income transactions with any of these entities (dividends, interest, " - "rent) count towards this investment's return." + "Transactions with any of these entities count towards this investment's " + "return: in this account, and income paid into other accounts (dividends, " + "rent)." + ), + ) + income_categories = models.ManyToManyField( + "transactions.TransactionCategory", + blank=True, + related_name="investment_accounts", + verbose_name=_("Income categories"), + help_text=_( + "Transactions in this account with any of these categories are its result " + "(interest, taxes, fees). On accounts tracked by value, everything else " + "that isn't a transfer counts as money coming in or going out." + ), + ) + income_tags = models.ManyToManyField( + "transactions.TransactionTag", + blank=True, + related_name="investment_accounts", + verbose_name=_("Income tags"), + help_text=_( + "Same as income categories, for transactions with any of these tags." ), ) is_archived = models.BooleanField( diff --git a/app/apps/api/serializers/accounts.py b/app/apps/api/serializers/accounts.py index 0506982d..5fb44229 100644 --- a/app/apps/api/serializers/accounts.py +++ b/app/apps/api/serializers/accounts.py @@ -54,6 +54,8 @@ class AccountSerializer(serializers.ModelSerializer): "is_asset", "is_investment", "income_entities", + "income_categories", + "income_tags", ] def __init__(self, *args, **kwargs): diff --git a/app/apps/investments/services.py b/app/apps/investments/services.py index e2dbca10..51f244b9 100644 --- a/app/apps/investments/services.py +++ b/app/apps/investments/services.py @@ -3,10 +3,13 @@ Rules (see the account's "Investment" help text): - A linked transaction (a transfer) moves money in or out. Its other side says what was paid or received, which is the cost or the proceeds. -- An unlinked transaction is a gain or a loss: a balance reconciliation, interest, - a tax withheld. On a unit-priced account an unlinked inflow (a gift, an opening - balance) has no known cost, so it is costed at the market price of that day - and the result is flagged as estimated. +- An unlinked transaction on an account tracked by value is money coming in or + going out (a salary, a bill), unless it has one of the account's income + categories, tags or entities: then it is a gain or a loss (a reconciled balance, + interest, a tax withheld). +- On a unit-priced account an unlinked outflow is a loss, and an unlinked inflow + (a gift, an opening balance) has no known cost, so it is costed at the market + price of that day and the result is flagged as estimated. - Cost is tracked with the average cost method. """ @@ -39,6 +42,8 @@ class HoldingPerformance: contributed: Decimal = ZERO withdrawn: Decimal = ZERO estimated: bool = False + # Unlinked transactions counted as gains or losses, on accounts tracked by value + result_ids: set = field(default_factory=set) history: list = field(default_factory=list) # (date, amount, kind) in the valuation currency: negative = money in ("in"), # positive = money out ("out" sales/withdrawals, "income"). Used for @@ -97,16 +102,33 @@ def _price(account, valuation_currency, date): return rate.effective_rate, rate.date -def income_entity_ids(account): - # The join table, not account.income_entities: that goes through the - # entities' per-user manager and comes back empty without a current user +def _linked_ids(account, field_name): + # The join table, not the related manager: that one is per-user and comes back + # empty without a current user + relation = account._meta.get_field(field_name) return list( - account.income_entities.through.objects.filter(account=account).values_list( - "transactionentity_id", flat=True + relation.remote_field.through.objects.filter(account=account).values_list( + relation.m2m_reverse_name(), flat=True ) ) +def income_entity_ids(account): + return _linked_ids(account, "income_entities") + + +def _result_ids(account): + return set( + Transaction.objects.filter(account=account) + .filter( + Q(category__in=_linked_ids(account, "income_categories")) + | Q(tags__in=_linked_ids(account, "income_tags")) + | Q(entities__in=income_entity_ids(account)) + ) + .values_list("id", flat=True) + ) + + def get_holding_performance(account, as_of=None, with_history=False): """Performance of an investment account up to ``as_of`` (today by default), in the account's exchange currency, or its own currency if it has none.""" @@ -126,6 +148,8 @@ def get_holding_performance(account, as_of=None, with_history=False): ) month_ends = _month_ends(transactions, as_of) if with_history else [] + if not perf.priced: + perf.result_ids = _result_ids(account) for tx in transactions: while month_ends and month_ends[0] < tx.date: @@ -140,9 +164,13 @@ def get_holding_performance(account, as_of=None, with_history=False): if partner else None ) + external = not (perf.priced or partner or tx.id in perf.result_ids) if tx.type == Transaction.Type.INCOME: - if partner_value is not None: + if external: + cost = tx.amount + perf.contributed += cost + elif partner_value is not None: cost = partner_value perf.contributed += cost elif perf.priced or partner: @@ -152,7 +180,7 @@ def get_holding_performance(account, as_of=None, with_history=False): if partner: perf.contributed += cost or ZERO else: - cost = ZERO # value-tracked: an unlinked inflow is a gain + cost = ZERO # value-tracked: a gain if cost: perf.flows.append((tx.date, -cost, "in")) perf.quantity += tx.amount @@ -162,8 +190,8 @@ def get_holding_performance(account, as_of=None, with_history=False): removed = perf.cost_basis * min(tx.amount, perf.quantity) / perf.quantity else: removed = ZERO - if partner: - proceeds = partner_value + if partner or external: + proceeds = tx.amount if external else partner_value if proceeds is None: proceeds = _to(tx.amount, account.currency, valuation_currency, tx.date) or ZERO perf.estimated = True @@ -171,7 +199,7 @@ def get_holding_performance(account, as_of=None, with_history=False): perf.withdrawn += proceeds perf.flows.append((tx.date, proceeds, "out")) perf.cost_basis -= removed - # unlinked outflow: a loss, tax or fee; cost stays, so value drops + # otherwise a loss, tax or fee: cost stays, so value drops perf.quantity -= tx.amount if perf.quantity <= 0 and perf.cost_basis: perf.realized -= perf.cost_basis diff --git a/app/apps/investments/tests/test_services.py b/app/apps/investments/tests/test_services.py index 930b53be..28e67ad4 100644 --- a/app/apps/investments/tests/test_services.py +++ b/app/apps/investments/tests/test_services.py @@ -10,7 +10,12 @@ 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.investments.services import get_holding_performance, summarize -from apps.transactions.models import Transaction, TransactionEntity +from apps.transactions.models import ( + Transaction, + TransactionCategory, + TransactionEntity, + TransactionTag, +) D = Decimal IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE @@ -99,9 +104,11 @@ class HoldingPerformanceTests(TestCase): name="CDB", currency=self.brl, is_investment=True, is_asset=True ) checking = Account.objects.create(name="Checking", currency=self.brl) + result = TransactionCategory.objects.create(name="Yield") + cdb.income_categories.set([result]) transfer(checking, "1000", cdb, "1000", self.d1) - tx(cdb, IN, "100", self.d2) # balance reconciliation: yield - tx(cdb, EX, "22.50", self.d3) # income tax withheld + tx(cdb, IN, "100", self.d2, category=result) # balance reconciliation + tx(cdb, EX, "22.50", self.d3, category=result) # income tax withheld perf = get_holding_performance(cdb) self.assertFalse(perf.priced) @@ -116,6 +123,41 @@ class HoldingPerformanceTests(TestCase): self.assertEqual(perf.realized, D("77.50")) self.assertEqual(perf.total_return, D("77.50")) + def test_value_tracked_salary_account_separates_money_from_yield(self): + account = Account.objects.create( + name="Salary", currency=self.brl, is_investment=True + ) + tag = TransactionTag.objects.create(name="Yield") + account.income_tags.set([tag]) + tx(account, IN, "5000", self.d1) # salary, from outside + tx(account, EX, "1000", self.d2) # rent, not a loss + tx(account, IN, "40", self.d3).tags.add(tag) # reconciled balance + + perf = get_holding_performance(account) + + self.assertEqual(perf.contributed, D("5000")) + self.assertEqual(perf.withdrawn, D("1000")) + self.assertEqual(perf.market_value, D("4040")) + self.assertEqual(perf.cost_basis, D("4000")) + self.assertEqual(perf.total_return, D("40")) + self.assertFalse(perf.estimated) + + def test_own_entity_is_a_result_but_only_income_counts_elsewhere(self): + account = Account.objects.create( + name="Savings", currency=self.brl, is_investment=True + ) + other = Account.objects.create(name="Other", currency=self.brl) + bank = TransactionEntity.objects.create(name="Bank") + account.income_entities.set([bank]) + tx(account, IN, "1000", self.d1) + tx(account, IN, "10", self.d2).entities.add(bank) + tx(other, EX, "99", self.d2).entities.add(bank) + + perf = get_holding_performance(account) + + self.assertEqual(perf.total_return, D("10")) + self.assertEqual(perf.income, D("0")) + def test_history_has_one_point_per_month_end(self): transfer(self.cash, "1000", self.etf, "10", self.d1) self.rate("100", self.d1) @@ -200,6 +242,10 @@ class HoldingPerformanceTests(TestCase): totals = summarize([perf], convert=True) self.assertEqual([t["currency"] for t in totals], [self.brl]) self.assertEqual(totals[0]["converted_from"], [usd]) + self.assertEqual(totals[0]["breakdown"][0]["currency"], usd) + self.assertEqual(totals[0]["breakdown"][0]["market_value"], D("1000")) + # A single-currency total has nothing to break down + self.assertEqual(summarize([perf])[0]["breakdown"], []) # No rate to anything: stays in its own currency self.assertEqual(summarize([get_holding_performance(self.etf)], convert=True)[0]["currency"], self.eur) diff --git a/app/templates/investments/fragments/detail.html b/app/templates/investments/fragments/detail.html index 3adbeb74..9495a6ab 100644 --- a/app/templates/investments/fragments/detail.html +++ b/app/templates/investments/fragments/detail.html @@ -214,7 +214,11 @@ {% if tx.type == 'IN' %}{% translate 'Bought / contributed' %}{% else %}{% translate 'Sold / withdrew' %}{% endif %} · {{ p.account.name }} {% else %} - {% if tx.type == 'IN' %}{% if perf.priced %}{% translate 'Gain' %}{% else %}{% translate 'Earnings' %}{% endif %}{% else %}{% translate 'Loss, tax or fee' %}{% endif %} + {% if not perf.priced and tx.id not in perf.result_ids %} + {% if tx.type == 'IN' %}{% translate 'Money in' %}{% else %}{% translate 'Money out' %}{% endif %} + {% else %} + {% if tx.type == 'IN' %}{% if perf.priced %}{% translate 'Gain' %}{% else %}{% translate 'Earnings' %}{% endif %}{% else %}{% translate 'Loss, tax or fee' %}{% endif %} + {% endif %} {% endif %}
From 6cf8cbf786c33888845918d1c98dffd9db537cf8 Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Sat, 7 Nov 2026 14:56:55 +0530 Subject: [PATCH 09/26] fix(import): stop hardcoding the Docker image's paths Import uploads were always written to /usr/src/app/temp, presets were read from /usr/src/app/import_presets and the caches lived in /var/tmp, so WYGIWYH only worked from the Docker image's layout. Running it as a systemd service with a strict sandbox, or on an immutable distro like NixOS, needs these folders to follow the app or be configurable. - Presets are read from IMPORT_PRESETS_ROOT, the import_presets folder next to the app. - Uploads wait in IMPORT_TEMP_ROOT, the temp folder next to the app by default, and movable with the IMPORT_TEMP_ROOT env var. The web views, the API and the import service all use it. - The file-based caches live under CACHE_ROOT, /var/tmp by default, and movable with the CACHE_ROOT env var. In the Docker image the app sits in /usr/src/app, so every default resolves to the same folder as before. The import service checked that a file was inside the temp folder with a plain string prefix, which also accepted sibling folders such as /usr/src/app/temp-other. It now compares paths. CI no longer needs to create /usr/src/app before running the tests. Fixes #472 --- .github/workflows/tests.yml | 6 - .gitignore | 1 + README.md | 2 + app/WYGIWYH/settings.py | 9 +- app/apps/api/views/imports.py | 3 +- app/apps/import_app/services/presets.py | 6 +- app/apps/import_app/services/v1.py | 12 +- app/apps/import_app/tests/test_paths.py | 151 +++++++++++++++++++ app/apps/import_app/tests/test_qif_import.py | 10 +- app/apps/import_app/views.py | 3 +- 10 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 app/apps/import_app/tests/test_paths.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2a704fa5..038881a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,12 +60,6 @@ jobs: - name: Install dependencies run: uv sync --frozen - # Import code reads/writes the Docker image's paths directly - - name: Create container paths used by the app - run: | - sudo mkdir -p /usr/src/app/temp /usr/src/app/import_presets - sudo chown -R "$(id -u):$(id -g)" /usr/src/app - - name: Check for missing migrations working-directory: app run: uv run python manage.py makemigrations --check --dry-run diff --git a/.gitignore b/.gitignore index 93ba54a4..7c4160ef 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,4 @@ postgres_data/ # Private local uploads app/attachments/ +app/temp/ diff --git a/README.md b/README.md index 10bde2d3..f757b874 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,8 @@ To create the first user, open the container's console using Unraid's UI, by cli | DJANGO_VITE_DEV_MODE | true\|false | false | Enables Vite dev server mode for frontend development. When true, assets are served from Vite's dev server instead of the build manifest. For development only! | | DJANGO_VITE_DEV_SERVER_PORT | int | 5173 | The port where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! | | DJANGO_VITE_DEV_SERVER_HOST | string | localhost | The host where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! | +| IMPORT_TEMP_ROOT | string | temp, in the app folder | Where uploaded import files wait to be processed. Must be writable and shared between the web and task processes. | +| CACHE_ROOT | string | /var/tmp | The writable folder the file-based caches are stored in. | ## OIDC Configuration diff --git a/app/WYGIWYH/settings.py b/app/WYGIWYH/settings.py index f7756b73..44f2e9e5 100644 --- a/app/WYGIWYH/settings.py +++ b/app/WYGIWYH/settings.py @@ -313,6 +313,9 @@ LOCALE_PATHS = [BASE_DIR / "locale"] STATIC_URL = "static/" STATIC_ROOT = BASE_DIR / "static_files" ATTACHMENT_MEDIA_ROOT = BASE_DIR / "attachments" +IMPORT_PRESETS_ROOT = BASE_DIR / "import_presets" +# Web and task workers must see the same folder: uploads wait here to be processed +IMPORT_TEMP_ROOT = Path(os.getenv("IMPORT_TEMP_ROOT") or BASE_DIR / "temp").resolve() STATICFILES_DIRS = [ ROOT_DIR / "frontend" / "build", @@ -324,15 +327,17 @@ STATICFILES_FINDERS = [ "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] +CACHE_ROOT = Path(os.getenv("CACHE_ROOT") or "/var/tmp").resolve() + CACHES = { "default": { "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", - "LOCATION": "/var/tmp/django_cache", + "LOCATION": CACHE_ROOT / "django_cache", }, # Kept apart from "default": cachalot fills it and FileBasedCache culls random entries past MAX_ENTRIES "update_check": { "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", - "LOCATION": "/var/tmp/django_cache_update_check", + "LOCATION": CACHE_ROOT / "django_cache_update_check", }, } diff --git a/app/apps/api/views/imports.py b/app/apps/api/views/imports.py index f8e6edb7..9abb945d 100644 --- a/app/apps/api/views/imports.py +++ b/app/apps/api/views/imports.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core.files.storage import FileSystemStorage from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view, inline_serializer @@ -130,7 +131,7 @@ class ImportViewSet(viewsets.ViewSet): uploaded_file = serializer.validated_data["file"] # Save file to temp location - fs = FileSystemStorage(location="/usr/src/app/temp") + fs = FileSystemStorage(location=settings.IMPORT_TEMP_ROOT) filename = fs.save(uploaded_file.name, uploaded_file) file_path = fs.path(filename) diff --git a/app/apps/import_app/services/presets.py b/app/apps/import_app/services/presets.py index 824a2468..e4cef311 100644 --- a/app/apps/import_app/services/presets.py +++ b/app/apps/import_app/services/presets.py @@ -1,17 +1,17 @@ import json from pathlib import Path +from django.conf import settings + from apps.import_app.models import ImportProfile class PresetService: - PRESET_PATH = "/usr/src/app/import_presets" - @classmethod def get_all_presets(cls): presets = [] - for folder in Path(cls.PRESET_PATH).iterdir(): + for folder in settings.IMPORT_PRESETS_ROOT.iterdir(): if folder.is_dir(): manifest_path = folder / "manifest.json" config_path = folder / "config.yml" diff --git a/app/apps/import_app/services/v1.py b/app/apps/import_app/services/v1.py index bed860ac..5aad0b9e 100644 --- a/app/apps/import_app/services/v1.py +++ b/app/apps/import_app/services/v1.py @@ -7,12 +7,14 @@ import zipfile from django.db import transaction from datetime import datetime, date from decimal import Decimal, InvalidOperation +from pathlib import Path from typing import Dict, Any, Literal, Union import openpyxl import xlrd import yaml from cachalot.api import cachalot_disabled +from django.conf import settings from django.core.exceptions import FieldDoesNotExist from django.utils import timezone from openpyxl.utils.exceptions import InvalidFileException @@ -39,8 +41,6 @@ logger = logging.getLogger(__name__) class ImportService: - TEMP_DIR = "/usr/src/app/temp" - def __init__(self, import_run: ImportRun): self.import_run: ImportRun = import_run self.profile: ImportProfile = import_run.profile @@ -54,7 +54,7 @@ class ImportService: self.mapping: Dict[str, version_1.ColumnMapping] = self.config.mapping # Ensure temp directory exists - os.makedirs(self.TEMP_DIR, exist_ok=True) + os.makedirs(settings.IMPORT_TEMP_ROOT, exist_ok=True) def _load_config(self) -> version_1.ImportProfileSchema: yaml_data = yaml.safe_load(self.profile.yaml_config) @@ -1132,8 +1132,10 @@ class ImportService: Returns the absolute path. """ abs_path = os.path.abspath(file_path) - if not abs_path.startswith(self.TEMP_DIR): - raise ValueError(f"Invalid file path. File must be in {self.TEMP_DIR}") + if not Path(abs_path).is_relative_to(settings.IMPORT_TEMP_ROOT): + raise ValueError( + f"Invalid file path. File must be in {settings.IMPORT_TEMP_ROOT}" + ) return abs_path def process_file(self, file_path: str): diff --git a/app/apps/import_app/tests/test_paths.py b/app/apps/import_app/tests/test_paths.py new file mode 100644 index 00000000..33c6d32d --- /dev/null +++ b/app/apps/import_app/tests/test_paths.py @@ -0,0 +1,151 @@ +"""Tests for the folders import code reads from and writes to.""" + +import json +import os +import runpy +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import SimpleTestCase, override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.import_app.models import ImportRun +from apps.import_app.services import PresetService +from apps.import_app.services.v1 import ImportService +from apps.import_app.tests.test_view_permissions import ( + HTMX, + ImportObjectPermissionTestCase, +) + + +def load_settings(**env): + """Evaluates the settings module from scratch under the given env vars.""" + with patch.dict(os.environ, env): + return runpy.run_path(str(Path(settings.BASE_DIR) / "WYGIWYH" / "settings.py")) + + +class PathSettingsTests(SimpleTestCase): + def test_defaults_match_the_docker_image_layout(self): + loaded = load_settings(IMPORT_TEMP_ROOT="", CACHE_ROOT="") + + base_dir = loaded["BASE_DIR"] + self.assertEqual(loaded["IMPORT_TEMP_ROOT"], base_dir / "temp") + self.assertEqual(loaded["IMPORT_PRESETS_ROOT"], base_dir / "import_presets") + self.assertEqual( + loaded["CACHES"]["default"]["LOCATION"], + Path("/var/tmp/django_cache").resolve(), + ) + self.assertEqual( + loaded["CACHES"]["update_check"]["LOCATION"], + Path("/var/tmp/django_cache_update_check").resolve(), + ) + + def test_env_vars_move_temp_and_cache_folders(self): + with tempfile.TemporaryDirectory() as root: + root = Path(root).resolve() + loaded = load_settings( + IMPORT_TEMP_ROOT=str(root / "uploads"), CACHE_ROOT=str(root / "cache") + ) + + self.assertEqual(loaded["IMPORT_TEMP_ROOT"], root / "uploads") + self.assertEqual( + loaded["CACHES"]["default"]["LOCATION"], root / "cache" / "django_cache" + ) + self.assertEqual( + loaded["CACHES"]["update_check"]["LOCATION"], + root / "cache" / "django_cache_update_check", + ) + + +class PresetServiceTests(SimpleTestCase): + def test_lists_the_presets_shipped_with_the_app(self): + folder_names = { + preset["folder_name"] for preset in PresetService.get_all_presets() + } + + self.assertIn("qif_standard", folder_names) + + def test_reads_presets_from_configured_folder(self): + with tempfile.TemporaryDirectory() as root: + preset = Path(root) / "my_bank" + preset.mkdir() + (preset / "manifest.json").write_text(json.dumps({"name": "My Bank"})) + (preset / "config.yml").write_text("settings: {}") + + with override_settings(IMPORT_PRESETS_ROOT=Path(root)): + presets = PresetService.get_all_presets() + + self.assertEqual([p["name"] for p in presets], ["My Bank"]) + + +class ImportTempRootTests(ImportObjectPermissionTestCase): + def setUp(self): + super().setUp() + self.temp_root = Path(tempfile.mkdtemp()).resolve() + self.addCleanup(shutil.rmtree, self.temp_root, ignore_errors=True) + temp_root_override = override_settings(IMPORT_TEMP_ROOT=self.temp_root) + temp_root_override.enable() + self.addCleanup(temp_root_override.disable) + + @patch("apps.import_app.views.process_import.defer") + def test_web_upload_is_stored_in_temp_root(self, mock_defer): + self.login(self.owner) + response = self.client.post( + reverse("import_run_add", kwargs={"profile_id": self.private_profile.id}), + {"file": SimpleUploadedFile("web.csv", b"date,amount\n")}, + **HTMX, + ) + + self.assertEqual(response.status_code, 204) + self.assertEqual( + mock_defer.call_args.kwargs["file_path"], str(self.temp_root / "web.csv") + ) + self.assertTrue((self.temp_root / "web.csv").is_file()) + + @patch("apps.api.views.imports.process_import.defer") + def test_api_upload_is_stored_in_temp_root(self, mock_defer): + client = APIClient() + client.force_authenticate(user=self.owner) + response = client.post( + "/api/import/import/", + { + "profile_id": self.private_profile.id, + "file": SimpleUploadedFile("api.csv", b"date,amount\n"), + }, + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual( + mock_defer.call_args.kwargs["file_path"], str(self.temp_root / "api.csv") + ) + self.assertTrue((self.temp_root / "api.csv").is_file()) + + def _service(self): + run = ImportRun.objects.create( + profile=self.private_profile, file_name="file.csv", owner=self.owner + ) + return ImportService(run) + + def test_service_accepts_files_inside_temp_root(self): + path = str(self.temp_root / "file.csv") + + self.assertEqual(self._service()._validate_file_path(path), path) + + def test_service_rejects_files_outside_temp_root(self): + service = self._service() + + for path in ( + "/etc/passwd", + self.temp_root / ".." / "file.csv", + # Shares the temp root as a string prefix, but is a different folder + f"{self.temp_root}-other/file.csv", + ): + with self.subTest(path=path), self.assertRaises(ValueError): + service._validate_file_path(str(path)) diff --git a/app/apps/import_app/tests/test_qif_import.py b/app/apps/import_app/tests/test_qif_import.py index a79662b3..d2b7e9db 100644 --- a/app/apps/import_app/tests/test_qif_import.py +++ b/app/apps/import_app/tests/test_qif_import.py @@ -1,7 +1,7 @@ from decimal import Decimal import os import shutil -from django.test import TestCase +from django.test import TestCase, override_settings from django.contrib.auth import get_user_model from apps.accounts.models import Account, AccountGroup from apps.currencies.models import Currency @@ -15,10 +15,11 @@ from apps.transactions.models import ( class QIFImportTests(TestCase): def setUp(self): - # Patch TEMP_DIR for testing - self.original_temp_dir = ImportService.TEMP_DIR + # Point the import temp folder somewhere disposable for testing self.test_dir = os.path.abspath("temp_test_import") - ImportService.TEMP_DIR = self.test_dir + temp_root_override = override_settings(IMPORT_TEMP_ROOT=self.test_dir) + temp_root_override.enable() + self.addCleanup(temp_root_override.disable) os.makedirs(self.test_dir, exist_ok=True) # Create user and set context @@ -41,7 +42,6 @@ class QIFImportTests(TestCase): def tearDown(self): delete_current_user() - ImportService.TEMP_DIR = self.original_temp_dir if os.path.exists(self.test_dir): shutil.rmtree(self.test_dir) diff --git a/app/apps/import_app/views.py b/app/apps/import_app/views.py index adef7679..45dc61ab 100644 --- a/app/apps/import_app/views.py +++ b/app/apps/import_app/views.py @@ -1,5 +1,6 @@ import shutil +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied @@ -204,7 +205,7 @@ def import_run_add(request, profile_id): if form.is_valid(): uploaded_file = request.FILES["file"] - fs = FileSystemStorage(location="/usr/src/app/temp") + fs = FileSystemStorage(location=settings.IMPORT_TEMP_ROOT) filename = fs.save(uploaded_file.name, uploaded_file) file_path = fs.path(filename) From 7902c4fc4efb44fa230fb1dd75008805d21027cf Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sun, 8 Nov 2026 02:26:40 -0300 Subject: [PATCH 10/26] fix(forms): resolve dynamic select values from the form's queryset A value the default manager hides, such as another user's object already set on a shared record, was taken as a new name and created a duplicate named after its id. --- app/apps/common/fields/forms/dynamic_select.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/apps/common/fields/forms/dynamic_select.py b/app/apps/common/fields/forms/dynamic_select.py index 3a49517a..4259c542 100644 --- a/app/apps/common/fields/forms/dynamic_select.py +++ b/app/apps/common/fields/forms/dynamic_select.py @@ -21,11 +21,19 @@ class DynamicModelChoiceField(forms.ModelChoiceField): super().__init__(queryset=self.queryset, *args, **kwargs) self._created_instance = None + def _get(self, **lookup): + # The form's queryset may hold choices the default manager hides, like + # another user's object already set on a shared record + try: + return self.queryset.get(**lookup) + except self.model.DoesNotExist: + return self.model.objects.get(**lookup) + def to_python(self, value): if value in self.empty_values: return None try: - return self.model.objects.get(**{self.to_field_name: value}) + return self._get(**{self.to_field_name: value}) except (ValueError, TypeError, self.model.DoesNotExist): return value # Return the raw value; we'll handle creation in clean() @@ -49,7 +57,7 @@ class DynamicModelChoiceField(forms.ModelChoiceField): try: if value.isdigit(): - return self.model.objects.get(id=value) + return self._get(id=value) else: raise self.model.DoesNotExist except self.model.DoesNotExist: From 7038ce8e183d89b17fbc4342f880231c1ecc2f9b Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sun, 8 Nov 2026 02:26:40 -0300 Subject: [PATCH 11/26] feat(investments): asset classes with target allocation, charts and rebalance - Asset classes (name, optional target %) managed under Management > Investments, chosen per account - Charts tab: allocation by asset class and currency, value against invested, return by investment; off-target classes flagged by the 5/25 rule - Rebalance tab: what to buy or sell per class to reach the targets - Investment page: price against average cost with trades, monthly earnings and income --- app/apps/accounts/forms.py | 68 +++++- .../migrations/0020_investment_classes.py | 38 ++++ app/apps/accounts/models.py | 49 ++++- app/apps/api/serializers/accounts.py | 1 + app/apps/investments/services.py | 85 +++++++ app/apps/investments/tests/test_services.py | 128 ++++++++++- app/apps/investments/urls.py | 31 +++ app/apps/investments/views.py | 207 +++++++++++++++++- app/templates/includes/sidebar.html | 10 +- .../investment_classes/fragments/form.html | 11 + .../investment_classes/fragments/list.html | 78 +++++++ .../investment_classes/pages/index.html | 8 + .../investments/fragments/_allocation.html | 18 ++ .../investments/fragments/charts.html | 149 +++++++++++++ .../investments/fragments/detail.html | 170 +++++++++----- app/templates/investments/fragments/list.html | 15 ++ .../investments/fragments/rebalance.html | 62 ++++++ frontend/src/js/charts.js | 20 ++ 18 files changed, 1085 insertions(+), 63 deletions(-) create mode 100644 app/apps/accounts/migrations/0020_investment_classes.py create mode 100644 app/templates/investment_classes/fragments/form.html create mode 100644 app/templates/investment_classes/fragments/list.html create mode 100644 app/templates/investment_classes/pages/index.html create mode 100644 app/templates/investments/fragments/_allocation.html create mode 100644 app/templates/investments/fragments/charts.html create mode 100644 app/templates/investments/fragments/rebalance.html diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index 945732d6..fbb3495a 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -1,6 +1,7 @@ import calendar -from apps.accounts.models import Account, AccountGroup +from apps.accounts.models import Account, AccountGroup, InvestmentClass +from apps.common.middleware.thread_local import get_current_user from apps.common.fields.forms.dynamic_select import ( DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -20,7 +21,8 @@ from crispy_forms.bootstrap import FormActions from crispy_forms.helper import FormHelper from crispy_forms.layout import HTML, Column, Div, Field, Layout, Row from django import forms -from django.db.models import Q +from django.db.models import Q, Sum +from django.template.defaultfilters import floatformat from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -55,6 +57,52 @@ class AccountGroupForm(forms.ModelForm): ) +class InvestmentClassForm(forms.ModelForm): + class Meta: + model = InvestmentClass + fields = ["name", "target"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.form_method = "post" + self.helper.layout = Layout( + "name", + "target", + FormActions( + NoClassSubmit( + "submit", + _("Update") if self.instance.pk else _("Add"), + css_class="btn btn-primary", + ), + ), + ) + + def clean_name(self): + name = self.cleaned_data["name"] + # owner isn't a form field, so Django skips the (owner, name) check + if InvestmentClass.objects.filter(name=name).exclude(pk=self.instance.pk).exists(): + raise forms.ValidationError(_("You already have an asset class with this name.")) + return name + + def clean_target(self): + target = self.cleaned_data["target"] + if target: + others = ( + InvestmentClass.objects.exclude(pk=self.instance.pk) + .aggregate(total=Sum("target"))["total"] + or 0 + ) + if others + target > 100: + raise forms.ValidationError( + _("Targets can't add up to more than 100%%. Other classes already " + "take %(taken)s%%.") % {"taken": floatformat(others, -2)} + ) + return target + + class AccountForm(forms.ModelForm): group = DynamicModelChoiceField( create_field="name", @@ -63,6 +111,14 @@ class AccountForm(forms.ModelForm): required=False, ) + investment_class = DynamicModelChoiceField( + create_field="name", + label=_("Asset class"), + model=InvestmentClass, + required=False, + help_text=Account._meta.get_field("investment_class").help_text, + ) + class Meta: model = Account fields = [ @@ -73,6 +129,7 @@ class AccountForm(forms.ModelForm): "is_asset", "is_archived", "is_investment", + "investment_class", "income_entities", "income_categories", "income_tags", @@ -89,6 +146,12 @@ class AccountForm(forms.ModelForm): super().__init__(*args, **kwargs) self.fields["group"].queryset = AccountGroup.objects.all() + self.fields["investment_class"].queryset = InvestmentClass.all_objects.filter( + Q(owner=get_current_user()) + | Q(owner=None) + # Keep a class from the account's owner when it's shared with us + | Q(accounts=self.instance.id or 0) + ).distinct() selectable = Q(active=True) if self.instance.id: selectable |= Q(investment_accounts=self.instance.id) @@ -124,6 +187,7 @@ class AccountForm(forms.ModelForm): Switch("is_investment"), # Only relevant for investments: shown while the switch above is on Div( + "investment_class", "income_entities", "income_categories", "income_tags", diff --git a/app/apps/accounts/migrations/0020_investment_classes.py b/app/apps/accounts/migrations/0020_investment_classes.py new file mode 100644 index 00000000..274abe34 --- /dev/null +++ b/app/apps/accounts/migrations/0020_investment_classes.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.17 on 2026-11-08 03:26 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0019_income_categories_and_tags'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='InvestmentClass', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('target', models.DecimalField(blank=True, decimal_places=2, help_text='Share of your investments you want in this class. Leave it empty to leave the class out of the targets.', max_digits=5, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Target allocation (%)')), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Asset class', + 'verbose_name_plural': 'Asset classes', + 'db_table': 'investment_classes', + 'ordering': ['name', 'id'], + 'unique_together': {('owner', 'name')}, + }, + ), + migrations.AddField( + model_name='account', + name='investment_class', + field=models.ForeignKey(blank=True, help_text='Groups investments in the allocation charts, e.g. Fixed income, Stocks, Real estate.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accounts', to='accounts.investmentclass', verbose_name='Asset class'), + ), + ] diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index df1f45ef..e30d9afd 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -1,10 +1,16 @@ from django.conf import settings from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ from apps.common.middleware.thread_local import get_current_user -from apps.common.models import SharedObject, SharedObjectManager +from apps.common.models import ( + OwnedObject, + OwnedObjectManager, + SharedObject, + SharedObjectManager, +) from apps.transactions.models import Transaction @@ -25,6 +31,35 @@ class AccountGroup(SharedObject): return self.name +class InvestmentClass(OwnedObject): + name = models.CharField(max_length=255, verbose_name=_("Name")) + target = models.DecimalField( + max_digits=5, + decimal_places=2, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(100)], + verbose_name=_("Target allocation (%)"), + help_text=_( + "Share of your investments you want in this class. Leave it empty to " + "leave the class out of the targets." + ), + ) + + objects = OwnedObjectManager() + all_objects = models.Manager() # Unfiltered manager + + class Meta: + verbose_name = _("Asset class") + verbose_name_plural = _("Asset classes") + db_table = "investment_classes" + unique_together = (("owner", "name"),) + ordering = ["name", "id"] + + def __str__(self): + return self.name + + class Account(SharedObject): name = models.CharField(max_length=255, verbose_name=_("Name")) group = models.ForeignKey( @@ -66,6 +101,18 @@ class Account(SharedObject): "value (fixed income, property, pensions)." ), ) + investment_class = models.ForeignKey( + InvestmentClass, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="accounts", + verbose_name=_("Asset class"), + help_text=_( + "Groups investments in the allocation charts, e.g. Fixed income, Stocks, " + "Real estate." + ), + ) income_entities = models.ManyToManyField( "transactions.TransactionEntity", blank=True, diff --git a/app/apps/api/serializers/accounts.py b/app/apps/api/serializers/accounts.py index 5fb44229..51df267d 100644 --- a/app/apps/api/serializers/accounts.py +++ b/app/apps/api/serializers/accounts.py @@ -53,6 +53,7 @@ class AccountSerializer(serializers.ModelSerializer): "exchange_currency_id", "is_asset", "is_investment", + "investment_class", "income_entities", "income_categories", "income_tags", diff --git a/app/apps/investments/services.py b/app/apps/investments/services.py index 51f244b9..a6371ada 100644 --- a/app/apps/investments/services.py +++ b/app/apps/investments/services.py @@ -45,6 +45,10 @@ class HoldingPerformance: # Unlinked transactions counted as gains or losses, on accounts tracked by value result_ids: set = field(default_factory=set) history: list = field(default_factory=list) + # (date, "buy" or "sell", unit price) for linked trades on unit-priced accounts + trades: list = field(default_factory=list) + # (date, signed amount) for the gains and losses of an account tracked by value + results: list = field(default_factory=list) # (date, amount, kind) in the valuation currency: negative = money in ("in"), # positive = money out ("out" sales/withdrawals, "income"). Used for # money-weighted returns and currency conversion. @@ -165,6 +169,12 @@ def get_holding_performance(account, as_of=None, with_history=False): else None ) external = not (perf.priced or partner or tx.id in perf.result_ids) + if perf.priced and partner_value is not None and tx.amount: + kind = "buy" if tx.type == Transaction.Type.INCOME else "sell" + perf.trades.append((tx.date, kind, partner_value / tx.amount)) + elif not (perf.priced or partner or external): + sign = 1 if tx.type == Transaction.Type.INCOME else -1 + perf.results.append((tx.date, sign * tx.amount)) if tx.type == Transaction.Type.INCOME: if external: @@ -300,6 +310,8 @@ def _snapshot(perf, account, date): "date": date, "invested": perf.cost_basis, "value": perf.quantity * price if price is not None else None, + "price": price, + "average_cost": perf.average_cost, } @@ -428,3 +440,76 @@ def summarize(performances, convert=False): else [] ) return list(totals.values()) + + +def portfolio_charts(performances): + """Chart data per total currency (as in summarize with ``convert``): value by + asset class and by currency, return by investment, and value against invested + at each month end. Classes are InvestmentClass objects, None for holdings + without one. Unpriced holdings are left out of the value figures.""" + charts = {} + for perf in performances: + figures = in_home_currency(perf) or _native(perf) + path = figures.get("path", [perf.currency]) + chart = charts.setdefault( + figures["currency"].id, + { + "currency": figures["currency"], + "classes": {}, + "currencies": {}, + "returns": [], + "history": {}, + }, + ) + value = figures["market_value"] + if value: + key = perf.account.investment_class + chart["classes"][key] = chart["classes"].get(key, ZERO) + value + name = perf.currency.name + chart["currencies"][name] = chart["currencies"].get(name, ZERO) + value + chart["returns"].append((perf.account.name, figures["total_return"])) + # shortcut: one rate lookup per holding, month and hop; cache rates per + # date if portfolios with long histories make this tab slow. + for point in perf.history: + totals = chart["history"].setdefault(point["date"], [ZERO, ZERO]) + for i, amount in enumerate((point["value"], point["invested"])): + if amount is not None: + totals[i] += _along(amount, path, point["date"]) or ZERO + for chart in charts.values(): + for key in ("classes", "currencies"): + chart[key] = sorted(chart[key].items(), key=lambda item: -item[1]) + chart["returns"].sort(key=lambda item: -item[1]) + chart["history"] = sorted(chart["history"].items()) + return list(charts.values()) + + +def drift_limit(target): + """How far a class may drift from its target, in percentage points: the + common 5/25 rule, 5 points or a quarter of the target, whichever is less.""" + return min(Decimal(5), target / 4) + + +def rebalance(classes, targeted): + """What to buy or sell so the classes in ``targeted`` (InvestmentClass with a + target) match their targets, given ``classes`` as (class, value) from + portfolio_charts. Holdings without a class or target are left out and their + value is returned as ``left_out``. ``move`` is positive to buy.""" + values = dict(classes) + total = sum((values.get(cls, ZERO) for cls in targeted), ZERO) + rows = [] + for cls in targeted: + value = values.get(cls, ZERO) + share = value / total * 100 if total else ZERO + move = total * cls.target / 100 - value + rows.append( + { + "class": cls, + "value": value, + "share": share, + "target": cls.target, + "move": move, + "off": abs(share - cls.target) > drift_limit(cls.target), + } + ) + left_out = sum(values.values(), ZERO) - total + return {"total": total, "rows": rows, "left_out": left_out} diff --git a/app/apps/investments/tests/test_services.py b/app/apps/investments/tests/test_services.py index 28e67ad4..5ac80688 100644 --- a/app/apps/investments/tests/test_services.py +++ b/app/apps/investments/tests/test_services.py @@ -6,7 +6,7 @@ from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone -from apps.accounts.models import Account +from apps.accounts.models import Account, InvestmentClass from apps.common.middleware.thread_local import delete_current_user, write_current_user from apps.currencies.models import Currency, ExchangeRate from apps.investments.services import get_holding_performance, summarize @@ -88,6 +88,10 @@ class HoldingPerformanceTests(TestCase): self.assertEqual(perf.withdrawn, D("700")) self.assertEqual(perf.total_return, D("480")) self.assertFalse(perf.estimated) + self.assertEqual( + perf.trades, + [(self.d1, "buy", D("100")), (self.d2, "buy", D("120")), (self.d3, "sell", D("140"))], + ) def test_unlinked_inflow_on_priced_account_is_costed_at_market(self): self.rate("90", self.d1) @@ -112,6 +116,7 @@ class HoldingPerformanceTests(TestCase): perf = get_holding_performance(cdb) self.assertFalse(perf.priced) + self.assertEqual(perf.results, [(self.d2, D("100")), (self.d3, D("-22.50"))]) self.assertEqual(perf.market_value, D("1077.50")) self.assertEqual(perf.cost_basis, D("1000")) self.assertEqual(perf.unrealized, D("77.50")) @@ -177,6 +182,8 @@ class HoldingPerformanceTests(TestCase): ) self.assertEqual(perf.history[-1]["value"], D("1200")) self.assertEqual(perf.history[0]["invested"], D("1000")) + self.assertEqual(perf.history[-1]["price"], D("120")) + self.assertEqual(perf.history[-1]["average_cost"], D("100")) def test_summarize_groups_by_currency(self): transfer(self.cash, "1000", self.etf, "10", self.d1) @@ -186,6 +193,67 @@ class HoldingPerformanceTests(TestCase): self.assertEqual(totals[0]["market_value"], D("1100")) self.assertEqual(totals[0]["total_return"], D("100")) + def test_portfolio_charts_group_by_asset_class(self): + from apps.investments.services import portfolio_charts + + stocks = InvestmentClass.objects.create(name="Stocks") + self.etf.investment_class = stocks + self.etf.save() + cdb = Account.objects.create(name="CDB", currency=self.eur, is_investment=True) + transfer(self.cash, "1000", self.etf, "10", self.d1) + transfer(self.cash, "500", cdb, "500", self.d1) + self.rate("110", self.d1) + + perfs = [get_holding_performance(a, with_history=True) for a in (self.etf, cdb)] + [chart] = portfolio_charts(perfs) + + self.assertEqual(chart["classes"], [(stocks, D("1100")), (None, D("500"))]) + # Priced in euros, so that is its currency exposure + self.assertEqual(chart["currencies"], [("Euro", D("1600"))]) + self.assertEqual(chart["returns"][0], ("VWCE", D("100"))) + self.assertEqual(chart["history"][-1][1], [D("1600"), D("1500")]) + + def test_rebalance_moves_to_targets(self): + from apps.investments.services import rebalance + + stocks = InvestmentClass.objects.create(name="Stocks", target=D("60")) + bonds = InvestmentClass.objects.create(name="Bonds", target=D("30")) + gold = InvestmentClass.objects.create(name="Gold", target=D("10")) + loose = InvestmentClass.objects.create(name="Loose") # no target + + plan = rebalance( + [(stocks, D("700")), (bonds, D("300")), (loose, D("50")), (None, D("25"))], + [stocks, bonds, gold], + ) + + self.assertEqual(plan["total"], D("1000")) + self.assertEqual(plan["left_out"], D("75")) + moves = {row["class"].name: row["move"] for row in plan["rows"]} + self.assertEqual(moves, {"Stocks": D("-100"), "Bonds": D("0"), "Gold": D("100")}) + self.assertEqual(sum(moves.values()), 0) + # 70% vs 60%: past the 5 points; gold at 0% vs 10%: past 2.5 (a quarter) + off = {row["class"].name: row["off"] for row in plan["rows"]} + self.assertEqual(off, {"Stocks": True, "Bonds": False, "Gold": True}) + + def test_class_names_are_unique(self): + from apps.accounts.forms import InvestmentClassForm + + stocks = InvestmentClass.objects.create(name="Stocks") + self.assertFalse(InvestmentClassForm({"name": "Stocks"}).is_valid()) + self.assertTrue(InvestmentClassForm({"name": "Stocks"}, instance=stocks).is_valid()) + + def test_targets_cannot_pass_100(self): + from apps.accounts.forms import InvestmentClassForm + + InvestmentClass.objects.create(name="Stocks", target=D("70")) + bonds = InvestmentClass.objects.create(name="Bonds", target=D("30")) + + self.assertFalse(InvestmentClassForm({"name": "Gold", "target": "1"}).is_valid()) + # Editing a class doesn't count its own current target + self.assertTrue( + InvestmentClassForm({"name": "Bonds", "target": "30"}, instance=bonds).is_valid() + ) + def test_xirr_and_money_weighted_return(self): from apps.investments.services import money_weighted_return, xirr @@ -281,6 +349,64 @@ class InvestmentViewTests(TestCase): ) self.assertEqual(response.status_code, 200) + def test_shared_account_keeps_its_owners_class(self): + from apps.accounts.forms import AccountForm + + cls = InvestmentClass.all_objects.create(name="Stocks", owner=self.owner) + self.account.investment_class = cls + self.account.save() + self.account.shared_with.add(self.stranger) + + write_current_user(self.stranger) + try: + form = AccountForm( + {"name": "CDB", "currency": self.account.currency_id, + "is_investment": "on", "investment_class": str(cls.id)}, + instance=self.account, + ) + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(form.cleaned_data["investment_class"], cls) + finally: + delete_current_user() + # Not a new class named after the id + self.assertEqual(InvestmentClass.all_objects.count(), 1) + + def test_rebalance_and_class_pages_render(self): + self.client.force_login(self.owner) + stocks = InvestmentClass.all_objects.create(name="Stocks", target=D("60"), owner=self.owner) + self.account.investment_class = stocks + self.account.save() + + # Targets short of 100%: a warning instead of amounts + response = self.client.get(reverse("investments_rebalance"), **self.HTMX) + self.assertContains(response, "alert-warning") + + response = self.client.post( + reverse("investment_class_add"), {"name": "Bonds", "target": "40"}, **self.HTMX + ) + self.assertEqual(response.status_code, 204) + response = self.client.get(reverse("investments_rebalance"), **self.HTMX) + self.assertContains(response, "Bonds") + self.assertNotContains(response, "alert-warning") + + response = self.client.get(reverse("investment_classes_list"), **self.HTMX) + self.assertContains(response, "Stocks") + response = self.client.get( + reverse("investment_class_edit", args=[stocks.id]), **self.HTMX + ) + self.assertContains(response, "Stocks") + # Someone else's class is out of reach + self.client.force_login(self.stranger) + response = self.client.get( + reverse("investment_class_edit", args=[stocks.id]), **self.HTMX + ) + self.assertEqual(response.status_code, 404) + + def test_charts_render(self): + self.client.force_login(self.owner) + response = self.client.get(reverse("investments_charts"), **self.HTMX) + self.assertContains(response, "investment-charts-0") + def test_stranger_cannot_see_detail(self): self.client.force_login(self.stranger) response = self.client.get( diff --git a/app/apps/investments/urls.py b/app/apps/investments/urls.py index e3864d62..25c52f19 100644 --- a/app/apps/investments/urls.py +++ b/app/apps/investments/urls.py @@ -5,6 +5,37 @@ from . import views urlpatterns = [ path("investments/", views.investments_index, name="investments_index"), path("investments/list/", views.investments_list, name="investments_list"), + path("investments/charts/", views.investments_charts, name="investments_charts"), + path( + "investments/rebalance/", + views.investments_rebalance, + name="investments_rebalance", + ), + path( + "investments/classes/", + views.investment_classes_index, + name="investment_classes_index", + ), + path( + "investments/classes/list/", + views.investment_classes_list, + name="investment_classes_list", + ), + path( + "investments/classes/add/", + views.investment_class_add, + name="investment_class_add", + ), + path( + "investments/classes//edit/", + views.investment_class_edit, + name="investment_class_edit", + ), + path( + "investments/classes//delete/", + views.investment_class_delete, + name="investment_class_delete", + ), path( "investments//", views.investment_detail_index, diff --git a/app/apps/investments/views.py b/app/apps/investments/views.py index 339a951a..db0dec6c 100644 --- a/app/apps/investments/views.py +++ b/app/apps/investments/views.py @@ -1,16 +1,23 @@ -import json - +from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.shortcuts import render +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, render from django.utils.formats import date_format +from django.utils.translation import gettext as _ +from django.views.decorators.http import require_http_methods -from apps.accounts.models import Account +from apps.accounts.forms import InvestmentClassForm +from apps.accounts.models import Account, InvestmentClass from apps.common.decorators.htmx import only_htmx +from apps.common.functions.format import get_format from apps.common.functions.permissions import READ, get_shared_object_or_error from apps.investments.services import ( + ZERO, get_holding_performance, in_home_currency, income_entity_ids, + portfolio_charts, + rebalance, summarize, ) from apps.transactions.models import Transaction @@ -52,6 +59,123 @@ def investments_list(request): ) +def _money(currency): + """What chartMoney (charts.js) needs to format amounts like the rest of the app.""" + return { + "prefix": currency.prefix, + "suffix": currency.suffix, + "decimals": currency.decimal_places, + "decimal": get_format("DECIMAL_SEPARATOR"), + "thousand": get_format("THOUSAND_SEPARATOR"), + } + + +def _number(amount): + return None if amount is None else float(amount) + + +def _per_month(history, entries): + """Sums (date, amount) entries into the months of ``history``, zero when empty.""" + sums = {(p["date"].year, p["date"].month): ZERO for p in history} + for date, amount in entries: + if (date.year, date.month) in sums: + sums[(date.year, date.month)] += amount + return [float(amount) for amount in sums.values()] + + +def _trades_per_month(history, trades, kind): + """The last trade price of each month, None in months without one.""" + prices = {(p["date"].year, p["date"].month): None for p in history} + for date, trade_kind, price in trades: + if trade_kind == kind and (date.year, date.month) in prices: + prices[(date.year, date.month)] = float(price) + return list(prices.values()) + + +# Categorical slots in fixed order; past the last one, the rest fold into "Other" +PALETTE = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6"] +OTHER_COLOR = "#898781" + + +def _shares(items): + colors = PALETTE[: len(items)] + if len(items) > len(PALETTE): + rest = sum(amount for _name, amount in items[len(PALETTE) - 1 :]) + items = items[: len(PALETTE) - 1] + [(_("Other"), rest)] + colors = PALETTE[:-1] + [OTHER_COLOR] + total = sum(amount for _name, amount in items) + return [ + { + "key": name, + "name": str(name), + "amount": amount, + "share": amount / total * 100 if total else 0, + "color": color, + } + for (name, amount), color in zip(items, colors) + ] + + +@only_htmx +@login_required +def investments_charts(request): + performances = [ + get_holding_performance(account, with_history=True) for account in _holdings() + ] + charts = portfolio_charts(performances) + targeted = _targeted() + # Shares only mean something against targets that add up to 100% + if sum(cls.target for cls in targeted) != 100: + targeted = [] + for i, chart in enumerate(charts): + chart["data_id"] = f"investment-charts-{i}" + chart["returns_height"] = max(128, 32 * len(chart["returns"])) + plan = {row["class"]: row for row in rebalance(chart["classes"], targeted)["rows"]} + chart["classes"] = _shares( + [(cls or _("Unclassified"), amount) for cls, amount in chart["classes"]] + ) + for share in chart["classes"]: + share["plan"] = plan.get(share["key"]) + chart["currencies"] = _shares(chart["currencies"]) + chart["data"] = { + "currency": _money(chart["currency"]), + "classes": [[s["name"], float(s["amount"]), s["color"]] for s in chart["classes"]], + "currencies": [[s["name"], float(s["amount"]), s["color"]] for s in chart["currencies"]], + "returns": [[name, float(amount)] for name, amount in chart["returns"]], + "history": { + "labels": [date_format(d, "M Y") for d, _t in chart["history"]], + "value": [float(t[0]) for _d, t in chart["history"]], + "invested": [float(t[1]) for _d, t in chart["history"]], + }, + } + return render(request, "investments/fragments/charts.html", {"charts": charts}) + + +def _targeted(): + return list(InvestmentClass.objects.filter(target__isnull=False)) + + +@only_htmx +@login_required +def investments_rebalance(request): + targeted = _targeted() + total_target = sum(cls.target for cls in targeted) + plans = [] + for chart in portfolio_charts([get_holding_performance(a) for a in _holdings()]): + plan = rebalance(chart["classes"], targeted) + plan["currency"] = chart["currency"] + for row in plan["rows"]: + # Fractions of a cent shouldn't show as "Buy 0.00" + row["move"] = round(row["move"], chart["currency"].decimal_places) + row["size"] = abs(row["move"]) + plans.append(plan) + return render( + request, + "investments/fragments/rebalance.html", + {"plans": plans, "total_target": total_target, "targeted": targeted}, + ) + + @login_required def investment_detail_index(request, account_id): account = get_shared_object_or_error( @@ -85,10 +209,19 @@ def investment_detail(request, account_id): .order_by("-date", "-id")[:20] ) + history = perf.history + income_flows = [(d, amount) for d, amount, kind in perf.flows if kind == "income"] chart = { - "labels": [date_format(p["date"], "M Y") for p in perf.history], - "value": [float(p["value"]) if p["value"] is not None else None for p in perf.history], - "invested": [float(p["invested"]) for p in perf.history], + "currency": _money(perf.currency), + "labels": [date_format(p["date"], "M Y") for p in history], + "value": [_number(p["value"]) for p in history], + "invested": [float(p["invested"]) for p in history], + "price": [_number(p["price"]) for p in history] if perf.priced else None, + "average_cost": [_number(p["average_cost"]) for p in history], + "buys": _trades_per_month(history, perf.trades, "buy"), + "sells": _trades_per_month(history, perf.trades, "sell"), + "earnings": _per_month(history, perf.results) if perf.results else None, + "income": _per_month(history, income_flows) if income_flows else None, } return render( @@ -101,6 +234,64 @@ def investment_detail(request, account_id): "transactions": transactions, "income": income, "income_entities": list(account.income_entities.all()), - "chart": json.dumps(chart), + "chart": chart, }, ) + + +@login_required +@require_http_methods(["GET"]) +def investment_classes_index(request): + return render(request, "investment_classes/pages/index.html") + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def investment_classes_list(request): + classes = InvestmentClass.objects.all() + return render( + request, + "investment_classes/fragments/list.html", + { + "classes": classes, + "total_target": sum(cls.target or 0 for cls in classes), + }, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def investment_class_add(request): + return _investment_class_form(request, InvestmentClass(), _("Asset class added successfully")) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def investment_class_edit(request, pk): + cls = get_object_or_404(InvestmentClass, pk=pk) + return _investment_class_form(request, cls, _("Asset class updated successfully")) + + +def _investment_class_form(request, instance, success): + form = InvestmentClassForm(request.POST or None, instance=instance) + if request.method == "POST" and form.is_valid(): + form.save() + messages.success(request, success) + return HttpResponse(status=204, headers={"HX-Trigger": "updated, hide_offcanvas"}) + return render( + request, + "investment_classes/fragments/form.html", + {"form": form, "object": instance}, + ) + + +@only_htmx +@login_required +@require_http_methods(["DELETE"]) +def investment_class_delete(request, pk): + get_object_or_404(InvestmentClass, pk=pk).delete() + messages.success(request, _("Asset class deleted successfully")) + return HttpResponse(status=204, headers={"HX-Trigger": "updated, hide_offcanvas"}) diff --git a/app/templates/includes/sidebar.html b/app/templates/includes/sidebar.html index 05d44934..0abea173 100644 --- a/app/templates/includes/sidebar.html +++ b/app/templates/includes/sidebar.html @@ -150,7 +150,7 @@ + active="tags_index||entities_index||categories_index||accounts_index||account_groups_index||investment_classes_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index"> + + + + + {% crispy form %} + +{% endblock %} diff --git a/app/templates/investment_classes/fragments/list.html b/app/templates/investment_classes/fragments/list.html new file mode 100644 index 00000000..e40ba58c --- /dev/null +++ b/app/templates/investment_classes/fragments/list.html @@ -0,0 +1,78 @@ +{% load i18n %} + + +
+
+ {% spaceless %} +
{% translate 'Asset classes' %}
+ {% endspaceless %} +
+ +
+
+ {% if classes %} + {% if total_target and total_target != 100 %} + + {% endif %} +
+ + + + + + + + + + + {% for cls in classes %} + + + + + + + {% endfor %} + + {% if total_target %} + + + + + + + + + {% endif %} +
{% translate 'Name' %}{% translate 'Target' %}{% translate 'Investments' %}
+
+ + + +
+
{{ cls.name }}{% if cls.target is not None %}{{ cls.target|floatformat:"-2" }}%{% else %}-{% endif %}{{ cls.accounts.count }}
{% translate 'Total' %}{{ total_target|floatformat:"-2" }}%
+
+ {% else %} + + {% endif %} +
+
+
diff --git a/app/templates/investment_classes/pages/index.html b/app/templates/investment_classes/pages/index.html new file mode 100644 index 00000000..5e86b514 --- /dev/null +++ b/app/templates/investment_classes/pages/index.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{% translate 'Asset classes' %}{% endblock %} + +{% block content %} +
+{% endblock %} diff --git a/app/templates/investments/fragments/_allocation.html b/app/templates/investments/fragments/_allocation.html new file mode 100644 index 00000000..3e21bc23 --- /dev/null +++ b/app/templates/investments/fragments/_allocation.html @@ -0,0 +1,18 @@ +{% load i18n %} +
+
+
    + {% for share in shares %} +
  • + + {{ share.name }} + {% if share.plan.off %} + + {% endif %} + {{ share.share|floatformat:1 }}% + +
  • + {% endfor %} +
+
diff --git a/app/templates/investments/fragments/charts.html b/app/templates/investments/fragments/charts.html new file mode 100644 index 00000000..76268512 --- /dev/null +++ b/app/templates/investments/fragments/charts.html @@ -0,0 +1,149 @@ +{% load i18n %} +{% for chart in charts %} + {% with c=chart.currency %} + {% if charts|length > 1 %}
{{ c.name }}
{% endif %} +
+ {{ chart.data|json_script:chart.data_id }} + +
+
+
{% translate 'By asset class' %}
+ {% include 'investments/fragments/_allocation.html' with shares=chart.classes kind='classes' %} +
+
+ +
+
+
{% translate 'By currency' %}
+ {% include 'investments/fragments/_allocation.html' with shares=chart.currencies kind='currencies' %} +
+
+ +
+
+
{% translate 'Value and invested' %}
+
+
+
+ +
+
+
{% translate 'Return by investment' %}
+
+
+
+
+ {% endwith %} +{% empty %} + +{% endfor %} + + diff --git a/app/templates/investments/fragments/detail.html b/app/templates/investments/fragments/detail.html index 9495a6ab..98f1ba04 100644 --- a/app/templates/investments/fragments/detail.html +++ b/app/templates/investments/fragments/detail.html @@ -96,57 +96,127 @@ {% endif %}
-
- +
+ {{ chart|json_script:"investment-chart-data" }} + {% if chart.price or chart.earnings or chart.income %} +
+ + {% if chart.price %}{% endif %} + {% if chart.earnings %}{% endif %} + {% if chart.income %}{% endif %} +
+ {% endif %} +
+ {% if chart.price %}{% endif %} + {% if chart.earnings %}{% endif %} + {% if chart.income %}{% endif %}
+
diff --git a/app/templates/investments/fragments/list.html b/app/templates/investments/fragments/list.html index c11add13..4f6404c2 100644 --- a/app/templates/investments/fragments/list.html +++ b/app/templates/investments/fragments/list.html @@ -17,6 +17,9 @@ {% endfor %}
+
+ +
{% for item in groups %}
@@ -85,6 +88,18 @@
{% endfor %} +
+ +
+
+
+ +
+
+
+
{% else %} + +{% elif total_target != 100 %} + +{% else %} + {% for plan in plans %} + {% with c=plan.currency %} +
+ {% if plans|length > 1 %}
{{ c.name }}
{% endif %} +
+ + + + + + + + + + + + {% for row in plan.rows %} + + + + + + + + {% endfor %} + +
{% translate 'Asset class' %}{% translate 'Value' %}{% translate 'Now' %}{% translate 'Target' %}{% translate 'To reach the target' %}
+ {{ row.class.name }} + {% if row.off %}{% endif %} + {{ row.share|floatformat:1 }}%{{ row.target|floatformat:"-2" }}% + {% if row.move > 0 %} + {% translate 'Buy' %} + {% elif row.move < 0 %} + {% translate 'Sell' %} + {% else %} + - + {% endif %} +
+
+ {% if plan.left_out %} +
+ {% translate 'Left out: investments without an asset class or a target' %} · + +
+ {% endif %} +
+ {% endwith %} + {% endfor %} +{% endif %} diff --git a/frontend/src/js/charts.js b/frontend/src/js/charts.js index ceb318e6..62b8664c 100644 --- a/frontend/src/js/charts.js +++ b/frontend/src/js/charts.js @@ -4,3 +4,23 @@ import zoomPlugin from 'chartjs-plugin-zoom'; Chart.register(SankeyController, Flow, zoomPlugin); window.Chart = Chart; + +// The theme's text color at a given strength, resolved to a value canvas can draw +// with. shortcut: read once per chart, so a theme switch shows on the next render. +window.chartInk = (percent) => { + const probe = document.createElement('span'); + probe.style.color = `color-mix(in oklab, var(--color-base-content) ${percent}%, transparent)`; + document.body.appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; +}; + +// Formats amounts with the user's separators, as the amounts elsewhere in the app. +// currency: {prefix, suffix, decimals, decimal, thousand} +window.chartMoney = (currency) => (v, decimals = currency.decimals) => { + const [whole, fraction] = Math.abs(v).toFixed(decimals).split('.'); + const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, currency.thousand); + const number = fraction ? `${grouped}${currency.decimal}${fraction}` : grouped; + return `${v < 0 ? '-' : ''}${currency.prefix}${number}${currency.suffix}`; +}; From 494937870e4b33780cdb714fa51d3c32686b2b8b Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sun, 8 Nov 2026 02:26:40 -0300 Subject: [PATCH 12/26] fix(export): back up asset classes and investment income links Restore asset classes before accounts and take a second pass over accounts once categories, tags and entities exist, so their links survive a restore into a fresh install. --- app/apps/export_app/forms.py | 2 + app/apps/export_app/resources/accounts.py | 22 +++++++++- app/apps/export_app/tests.py | 52 ++++++++++++++++++++++- app/apps/export_app/views.py | 11 ++++- 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/app/apps/export_app/forms.py b/app/apps/export_app/forms.py index 1581791d..96b579b9 100644 --- a/app/apps/export_app/forms.py +++ b/app/apps/export_app/forms.py @@ -101,6 +101,7 @@ class ExportForm(forms.Form): self.helper.layout = Layout( "users", "accounts", + "investment_classes", "currencies", "transactions", "categories", @@ -127,6 +128,7 @@ class RestoreForm(forms.Form): ) users = forms.FileField(required=False, label=_("Users")) accounts = forms.FileField(required=False, label=_("Accounts")) + investment_classes = forms.FileField(required=False, label=_("Asset classes")) currencies = forms.FileField(required=False, label=_("Currencies")) transactions_categories = forms.FileField(required=False, label=_("Categories")) transactions_tags = forms.FileField(required=False, label=_("Tags")) diff --git a/app/apps/export_app/resources/accounts.py b/app/apps/export_app/resources/accounts.py index 7c993bae..45cac926 100644 --- a/app/apps/export_app/resources/accounts.py +++ b/app/apps/export_app/resources/accounts.py @@ -1,10 +1,24 @@ from import_export import fields, resources, widgets -from apps.accounts.models import Account, AccountGroup +from apps.accounts.models import Account, AccountGroup, InvestmentClass from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget from apps.currencies.models import Currency +class InvestmentClassResource(resources.ModelResource): + class Meta: + model = InvestmentClass + + def get_queryset(self): + return InvestmentClass.all_objects.all() + + +class AllClassesWidget(widgets.ForeignKeyWidget): + # By id, across owners: a shared account may carry another user's class + def get_queryset(self, value, row, *args, **kwargs): + return InvestmentClass.all_objects.all() + + class AccountResource(resources.ModelResource): group = fields.Field( attribute="group", @@ -22,6 +36,12 @@ class AccountResource(resources.ModelResource): widget=widgets.ForeignKeyWidget(Currency, "name"), ) + investment_class = fields.Field( + attribute="investment_class", + column_name="investment_class", + widget=AllClassesWidget(InvestmentClass), + ) + class Meta: model = Account diff --git a/app/apps/export_app/tests.py b/app/apps/export_app/tests.py index 7ce503c2..e70c0357 100644 --- a/app/apps/export_app/tests.py +++ b/app/apps/export_app/tests.py @@ -1,3 +1,53 @@ +import io +import zipfile +from decimal import Decimal + +from django.contrib.auth import get_user_model from django.test import TestCase -# Create your tests here. +from apps.accounts.models import Account, InvestmentClass +from apps.common.middleware.thread_local import delete_current_user, write_current_user +from apps.currencies.models import Currency +from apps.export_app.resources.accounts import AccountResource, InvestmentClassResource +from apps.export_app.resources.transactions import TransactionEntityResource +from apps.export_app.views import process_imports +from apps.transactions.models import TransactionEntity + + +class InvestmentRestoreTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user(email="u@test.com", password="pw") + write_current_user(self.user) + + def tearDown(self): + delete_current_user() + + def test_round_trip_keeps_asset_class_and_income_links(self): + brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + stocks = InvestmentClass.objects.create(name="Stocks", target=Decimal("60")) + broker = TransactionEntity.objects.create(name="Broker") + account = Account.objects.create( + name="PETR4", currency=brl, is_investment=True, investment_class=stocks + ) + account.income_entities.add(broker) + + backup = io.BytesIO() + with zipfile.ZipFile(backup, "w") as z: + for resource, name in ( + (AccountResource, "accounts"), + (InvestmentClassResource, "investment_classes"), + (TransactionEntityResource, "transactions_entities"), + ): + z.writestr(f"{name}.csv", resource().export().csv) + + Account.all_objects.all().delete() + InvestmentClass.all_objects.all().delete() + TransactionEntity.all_objects.all().delete() + + backup.seek(0) + process_imports(None, {"zip_file": backup}) + + account = Account.all_objects.get(name="PETR4") + self.assertEqual(account.investment_class.name, "Stocks") + self.assertEqual(account.investment_class.target, Decimal("60")) + self.assertEqual([e.name for e in account.income_entities.all()], ["Broker"]) diff --git a/app/apps/export_app/views.py b/app/apps/export_app/views.py index b60f4a0c..c0cee747 100644 --- a/app/apps/export_app/views.py +++ b/app/apps/export_app/views.py @@ -14,7 +14,10 @@ from tablib import Dataset from apps.common.decorators.htmx import only_htmx from apps.export_app.forms import ExportForm, RestoreForm -from apps.export_app.resources.accounts import AccountResource +from apps.export_app.resources.accounts import ( + AccountResource, + InvestmentClassResource, +) from apps.export_app.resources.currencies import ( CurrencyResource, ExchangeRateResource, @@ -91,6 +94,9 @@ def export_form(request): exports.append((UserResource().export(), "users")) if export_accounts: exports.append((AccountResource().export(), "accounts")) + exports.append( + (InvestmentClassResource().export(), "investment_classes") + ) if export_currencies: exports.append((CurrencyResource().export(), "currencies")) if export_transactions: @@ -226,10 +232,13 @@ def process_imports(request, cleaned_data): "currencies", CurrencyResource, ), # We do a double pass because exchange_currency may not exist when currency is initially created + ("investment_classes", InvestmentClassResource), ("accounts", AccountResource), ("transactions_categories", TransactionCategoyResource), ("transactions_tags", TransactionTagResource), ("transactions_entities", TransactionEntityResource), + # Again: income categories, tags and entities don't exist on the first pass + ("accounts", AccountResource), ("automatic_exchange_rates", ExchangeRateServiceResource), ("exchange_rates", ExchangeRateResource), ("installment_plans", InstallmentPlanResource), From e0c1e4d5ffc7c5935eaf35c6a4552a50a79e6858 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sun, 8 Nov 2026 03:09:04 -0300 Subject: [PATCH 13/26] fix(import): resolve symlinks when checking the upload path The temp root is resolve()d in settings but the file path was only abspath()ed, so a symlink inside the temp folder could point outside it and still pass the check. Resolve both sides the same way. Also drop the unused /temp folder from the prod image. --- app/apps/import_app/services/v1.py | 3 ++- app/apps/import_app/tests/test_paths.py | 10 ++++++++++ docker/prod/django/Dockerfile | 4 +--- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/apps/import_app/services/v1.py b/app/apps/import_app/services/v1.py index 5aad0b9e..2bc16981 100644 --- a/app/apps/import_app/services/v1.py +++ b/app/apps/import_app/services/v1.py @@ -1131,7 +1131,8 @@ class ImportService: Validates that the file path is within the allowed temporary directory. Returns the absolute path. """ - abs_path = os.path.abspath(file_path) + # resolve() like IMPORT_TEMP_ROOT, so symlinks can't point outside it + abs_path = str(Path(file_path).resolve()) if not Path(abs_path).is_relative_to(settings.IMPORT_TEMP_ROOT): raise ValueError( f"Invalid file path. File must be in {settings.IMPORT_TEMP_ROOT}" diff --git a/app/apps/import_app/tests/test_paths.py b/app/apps/import_app/tests/test_paths.py index 33c6d32d..a9eb7807 100644 --- a/app/apps/import_app/tests/test_paths.py +++ b/app/apps/import_app/tests/test_paths.py @@ -149,3 +149,13 @@ class ImportTempRootTests(ImportObjectPermissionTestCase): ): with self.subTest(path=path), self.assertRaises(ValueError): service._validate_file_path(str(path)) + + def test_service_rejects_symlinks_pointing_outside_temp_root(self): + link = self.temp_root / "link.csv" + try: + link.symlink_to("/etc/passwd") + except OSError: + self.skipTest("symlinks not available") + + with self.assertRaises(ValueError): + self._service()._validate_file_path(str(link)) diff --git a/docker/prod/django/Dockerfile b/docker/prod/django/Dockerfile index 9ad00cb2..fd83829f 100644 --- a/docker/prod/django/Dockerfile +++ b/docker/prod/django/Dockerfile @@ -69,9 +69,7 @@ RUN sed -i 's/\r$//g' /start && \ COPY --chown=app:app ./app . RUN chown -R app:app /usr/src/app && \ - chown -R app:app /usr/src/frontend && \ - mkdir /temp && \ - chown -R app:app /temp + chown -R app:app /usr/src/frontend USER app From de1df9d13c333e1292eb56f5676dadbcc38f352a Mon Sep 17 00:00:00 2001 From: eitchtee <10510126+eitchtee@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:13:26 +0000 Subject: [PATCH 14/26] chore(locale): update translation files [skip ci] Automatically generated by Django makemessages workflow --- app/locale/de/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/en/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/es/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/fr/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/hu/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/id/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/it/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/nl/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/pl/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/pt_BR/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/ru/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/sv/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/sw/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/uk/LC_MESSAGES/django.po | 18 +++++++++--------- app/locale/zh_Hant/LC_MESSAGES/django.po | 18 +++++++++--------- 15 files changed, 135 insertions(+), 135 deletions(-) diff --git a/app/locale/de/LC_MESSAGES/django.po b/app/locale/de/LC_MESSAGES/django.po index 7371dcab..cf8024de 100644 --- a/app/locale/de/LC_MESSAGES/django.po +++ b/app/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-08 06:01+0000\n" +"POT-Creation-Date: 2026-11-08 06:13+0000\n" "PO-Revision-Date: 2026-08-10 12:57+0000\n" "Last-Translator: GitEbu \n" "Language-Team: German \n" "Language-Team: LANGUAGE \n" @@ -234,7 +234,7 @@ msgstr "" #: apps/accounts/views/account_groups.py:109 #: apps/accounts/views/accounts.py:137 apps/dca/views.py:106 -#: apps/import_app/views.py:147 apps/rules/views.py:191 +#: apps/import_app/views.py:148 apps/rules/views.py:191 #: apps/transactions/views/categories.py:163 #: apps/transactions/views/entities.py:135 apps/transactions/views/tags.py:131 msgid "Item no longer shared with you" @@ -242,7 +242,7 @@ msgstr "" #: apps/accounts/views/account_groups.py:132 #: apps/accounts/views/accounts.py:182 apps/dca/views.py:131 -#: apps/import_app/views.py:273 apps/rules/views.py:217 +#: apps/import_app/views.py:274 apps/rules/views.py:217 #: apps/transactions/views/categories.py:188 #: apps/transactions/views/entities.py:160 apps/transactions/views/tags.py:154 msgid "Ownership taken successfully" @@ -250,7 +250,7 @@ msgstr "" #: apps/accounts/views/account_groups.py:152 #: apps/accounts/views/accounts.py:106 apps/dca/views.py:151 -#: apps/import_app/views.py:294 apps/rules/views.py:238 +#: apps/import_app/views.py:295 apps/rules/views.py:238 #: apps/transactions/views/categories.py:130 #: apps/transactions/views/entities.py:180 apps/transactions/views/tags.py:174 msgid "Configuration saved successfully" @@ -1020,23 +1020,23 @@ msgstr "" msgid "File name" msgstr "" -#: apps/import_app/views.py:75 +#: apps/import_app/views.py:76 msgid "Import Profile added successfully" msgstr "" -#: apps/import_app/views.py:113 +#: apps/import_app/views.py:114 msgid "Import Profile update successfully" msgstr "" -#: apps/import_app/views.py:142 +#: apps/import_app/views.py:143 msgid "Import Profile deleted successfully" msgstr "" -#: apps/import_app/views.py:222 +#: apps/import_app/views.py:223 msgid "Import Run queued successfully" msgstr "" -#: apps/import_app/views.py:249 +#: apps/import_app/views.py:250 msgid "Run deleted successfully" msgstr "" diff --git a/app/locale/es/LC_MESSAGES/django.po b/app/locale/es/LC_MESSAGES/django.po index cc3089e0..e57d0adf 100644 --- a/app/locale/es/LC_MESSAGES/django.po +++ b/app/locale/es/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-08 06:01+0000\n" +"POT-Creation-Date: 2026-11-08 06:13+0000\n" "PO-Revision-Date: 2026-07-10 04:57+0000\n" "Last-Translator: Juan David Afanador \n" "Language-Team: Spanish \n" "Language-Team: French \n" "Language-Team: Hungarian \n" "Language-Team: Italian \n" "Language-Team: Dutch \n" "Language-Team: Polish \n" "Language-Team: Portuguese (Brazil) \n" "Language-Team: Russian \n" "Language-Team: Swedish \n" "Language-Team: Swahili \n" "Language-Team: Ukrainian \n" "Language-Team: Chinese (Traditional Han script) Date: Sun, 8 Nov 2026 03:17:33 -0300 Subject: [PATCH 15/26] docs(readme): give IMPORT_TEMP_ROOT a literal default and move path vars up List the Docker path as the default like the other rows do, and move both rows out from under the dev-only Vite settings. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0d35ea5a..ba019170 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,8 @@ To create the first user, open the container's console using Unraid's UI, by cli | ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. | | KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. | | TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases | +| IMPORT_TEMP_ROOT | string | /usr/src/app/temp | Where uploaded import files wait to be processed. Defaults to the `temp` folder inside the app folder. Must be writable and shared between the web and task processes. | +| CACHE_ROOT | string | /var/tmp | The writable folder the file-based caches are stored in. | | DEMO | true\|false | false | If demo mode is enabled. | | ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. | | ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. | @@ -199,8 +201,6 @@ To create the first user, open the container's console using Unraid's UI, by cli | DJANGO_VITE_DEV_MODE | true\|false | false | Enables Vite dev server mode for frontend development. When true, assets are served from Vite's dev server instead of the build manifest. For development only! | | DJANGO_VITE_DEV_SERVER_PORT | int | 5173 | The port where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! | | DJANGO_VITE_DEV_SERVER_HOST | string | localhost | The host where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! | -| IMPORT_TEMP_ROOT | string | temp, in the app folder | Where uploaded import files wait to be processed. Must be writable and shared between the web and task processes. | -| CACHE_ROOT | string | /var/tmp | The writable folder the file-based caches are stored in. | ## OIDC Configuration From 03f2b0aeb4248de3797ba911ee2e3732c462b25a Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sun, 8 Nov 2026 03:26:11 -0300 Subject: [PATCH 16/26] fix(toasts): space the close button from the text and edges Lay the alert out as an icon | text | button grid so the close button gets a consistent gap from the message and the same visual inset as the icon. Long messages now wrap instead of pushing the button, and the aria-label is quoted. --- app/templates/common/fragments/toasts.html | 27 ++++++++++------------ 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/app/templates/common/fragments/toasts.html b/app/templates/common/fragments/toasts.html index c3fbd18a..a6da99f3 100644 --- a/app/templates/common/fragments/toasts.html +++ b/app/templates/common/fragments/toasts.html @@ -2,25 +2,22 @@ {% load toast_bg %} {% if messages %} {% for message in messages %} -