From 4d494549b7eca2bbca70b55fbeb0865e9b450b3e Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Thu, 5 Nov 2026 00:14:41 -0300 Subject: [PATCH] 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)