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.
This commit is contained in:
Herculino Trotta committed 2026-11-07 01:30:24 -03:00
1 parent 1f9ba8eba8
commit 0d7395cee6
7 files changed
+170 -30

No files matched your search

+20 -10
View File
@@ -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(
'<div class="hidden" _="on change from closest <form/> '
"if #id_is_investment.checked remove .hidden from #div_id_income_entities "
'else add .hidden to #div_id_income_entities end"></div>'
"if #id_is_investment.checked remove .hidden from #investment_income "
'else add .hidden to #investment_income end"></div>'
),
)
@@ -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'),
),
]
+23 -2
View File
@@ -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(
+2
View File
@@ -54,6 +54,8 @@ class AccountSerializer(serializers.ModelSerializer):
"is_asset",
"is_investment",
"income_entities",
"income_categories",
"income_tags",
]
def __init__(self, *args, **kwargs):
+42 -14
View File
@@ -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
+49 -3
View File
@@ -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)
@@ -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 %}
</div>
</td>