mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
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.
This commit is contained in:
1 parent
2cd9238b18
commit
4d494549b7
3 files changed
+150
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user