mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
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:
1 parent
1f9ba8eba8
commit
0d7395cee6
7 files changed
+170
-30
No files matched your search
+20
-10
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -54,6 +54,8 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
"is_asset",
|
||||
"is_investment",
|
||||
"income_entities",
|
||||
"income_categories",
|
||||
"income_tags",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user