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 %}