Merge pull request #663 from eitchtee/feat/dca-improvements

feat(dca): optional amounts, currency-checked links and entities
This commit is contained in:
Herculino Trotta authored and GitHub committed 2026-11-10 22:04:34 -03:00
commit edd045607f
6 files changed
+171 -28

No files matched your search

+6
View File
@@ -1,4 +1,5 @@
import json
from urllib.parse import urlencode
from django.forms import SelectMultiple, widgets
from django.urls import reverse
@@ -141,6 +142,9 @@ class TransactionSelect(TomSelect):
self.load_income = income
self.load_expense = expense
self.create = False
# Extra search filters sent as query params, e.g. {"currency": 1};
# keys must be in SEARCH_FILTERS of the transactions_search view
self.filters = {}
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
@@ -156,6 +160,8 @@ class TransactionSelect(TomSelect):
attrs["data-load"] = reverse(
"transactions_search", kwargs={"filter_type": "expenses"}
)
if self.filters:
attrs["data-load"] += "?" + urlencode(self.filters)
return attrs
+72 -25
View File
@@ -9,7 +9,12 @@ from apps.common.widgets.datepicker import AirDatePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
from apps.dca.models import DCAEntry, DCAStrategy
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionEntity,
TransactionTag,
)
from crispy_forms.bootstrap import AccordionGroup, FormActions, Accordion
from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Column, Div, Layout, Row
@@ -104,6 +109,23 @@ class DCAEntryForm(forms.ModelForm):
queryset=TransactionTag.objects.filter(active=True),
)
from_entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
to_field_name="name",
create_field="name",
required=False,
label=_("Entities"),
queryset=TransactionEntity.objects.filter(active=True),
)
to_entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
to_field_name="name",
create_field="name",
required=False,
label=_("Entities"),
queryset=TransactionEntity.objects.filter(active=True),
)
expense_transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
@@ -163,6 +185,7 @@ class DCAEntryForm(forms.ModelForm):
Column("from_category"),
Column("from_tags"),
),
"from_entities",
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border my-3",
),
Div(
@@ -171,6 +194,7 @@ class DCAEntryForm(forms.ModelForm):
Column("to_category"),
Column("to_tags"),
),
"to_entities",
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border",
),
active=False,
@@ -203,36 +227,30 @@ class DCAEntryForm(forms.ModelForm):
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
expense_transaction = None
income_transaction = None
if self.instance and self.instance.pk:
# Edit mode - get from instance
expense_transaction = self.instance.expense_transaction
income_transaction = self.instance.income_transaction
elif self.data.get("expense_transaction"):
# Form validation - get from submitted data
try:
expense_transaction = Transaction.objects.get(
id=self.data["expense_transaction"]
)
income_transaction = Transaction.objects.get(
id=self.data["income_transaction"]
)
except Transaction.DoesNotExist:
pass
# Amounts can come from the linked transactions instead
self.fields["amount_paid"].required = False
self.fields["amount_received"].required = False
# If we have a current transaction, ensure it's in the queryset
if income_transaction:
self.fields["income_transaction"].queryset = Transaction.objects.filter(
id=income_transaction.id
# The picker only searches the currency each side expects (enforced in clean)
for name, currency in (
("expense_transaction", self.strategy.payment_currency),
("income_transaction", self.strategy.target_currency),
):
field = self.fields[name]
field.widget.filters = {"currency": currency.id}
selected = (
self.data.get(name)
if self.is_bound
else getattr(self.instance, f"{name}_id", None)
)
if expense_transaction:
self.fields["expense_transaction"].queryset = Transaction.objects.filter(
id=expense_transaction.id
# Only the selected one: the picker loads the rest by search
field.queryset = Transaction.objects.filter(
id=selected if str(selected or "").isdigit() else None
)
self.fields["from_account"].queryset = Account.objects.filter(
is_archived=False,
currency=self.strategy.payment_currency,
)
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
@@ -242,16 +260,43 @@ class DCAEntryForm(forms.ModelForm):
self.fields["to_account"].queryset = Account.objects.filter(
is_archived=False,
currency=self.strategy.target_currency,
)
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["from_entities"].queryset = TransactionEntity.objects.filter(
active=True
)
self.fields["to_entities"].queryset = TransactionEntity.objects.filter(
active=True
)
def clean(self):
cleaned_data = super().clean()
creating = cleaned_data.get("create_transaction")
for amount_field, link_field, currency in (
("amount_paid", "expense_transaction", self.strategy.payment_currency),
("amount_received", "income_transaction", self.strategy.target_currency),
):
linked = cleaned_data.get(link_field)
if linked and linked.account.currency_id != currency.id:
self.add_error(
link_field,
_("Transaction must be in %(currency)s.")
% {"currency": currency.name},
)
linked = cleaned_data[link_field] = None
if cleaned_data.get(amount_field) is not None:
continue
if linked and not creating:
cleaned_data[amount_field] = linked.amount
elif amount_field not in self.errors:
self.add_error(amount_field, _("This field is required."))
if cleaned_data.get("create_transaction"):
from_account = cleaned_data.get("from_account")
to_account = cleaned_data.get("to_account")
@@ -315,6 +360,7 @@ class DCAEntryForm(forms.ModelForm):
notes=notes,
)
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
from_transaction.entities.set(self.cleaned_data.get("from_entities", []))
# Create "To" transaction
to_transaction = Transaction.objects.create(
@@ -328,6 +374,7 @@ class DCAEntryForm(forms.ModelForm):
notes=notes,
)
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
to_transaction.entities.set(self.cleaned_data.get("to_entities", []))
from_transaction.link_transfer(to_transaction)
+68 -1
View File
@@ -10,7 +10,7 @@ from apps.common.middleware.thread_local import delete_current_user, write_curre
from apps.currencies.models import Currency
from apps.dca.forms import DCAEntryForm
from apps.dca.models import DCAStrategy
from apps.transactions.models import Transaction
from apps.transactions.models import Transaction, TransactionEntity
IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE
@@ -97,3 +97,70 @@ class DCATransferLinkingTests(TestCase):
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)
def test_amounts_come_from_linked_transactions(self):
expense, income = self.tx(self.cash, EX, "500"), self.tx(self.wallet, IN, "0.001")
form = DCAEntryForm(
data=self.entry_data(
amount_paid="",
amount_received="",
expense_transaction=str(expense.id),
income_transaction=str(income.id),
),
strategy=self.strategy,
)
self.assertTrue(form.is_valid(), form.errors)
entry = form.save()
self.assertEqual(entry.amount_paid, Decimal("500"))
self.assertEqual(entry.amount_received, Decimal("0.001"))
def test_amount_required_without_link(self):
form = DCAEntryForm(
data=self.entry_data(amount_received=""), strategy=self.strategy
)
self.assertFalse(form.is_valid())
self.assertIn("amount_received", form.errors)
def test_link_in_wrong_currency_is_rejected(self):
# Expense must be in the payment currency, income in the target currency
expense, income = self.tx(self.wallet, EX, "1"), self.tx(self.cash, IN, "500")
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("expense_transaction", form.errors)
self.assertIn("income_transaction", form.errors)
def test_created_transactions_get_entities(self):
broker = TransactionEntity.objects.create(name="Broker")
form = DCAEntryForm(
data=self.entry_data(
create_transaction="on",
from_account=self.cash.id,
to_account=self.wallet.id,
from_entities=["Broker"],
to_entities=["Broker"],
),
strategy=self.strategy,
)
self.assertTrue(form.is_valid(), form.errors)
entry = form.save()
self.assertEqual(list(entry.expense_transaction.entities.all()), [broker])
self.assertEqual(list(entry.income_transaction.entities.all()), [broker])
def test_create_accounts_must_hold_strategy_currency(self):
# Swapped: paying from the BTC wallet into the BRL cash account
form = DCAEntryForm(
data=self.entry_data(
create_transaction="on",
from_account=self.wallet.id,
to_account=self.cash.id,
),
strategy=self.strategy,
)
self.assertFalse(form.is_valid())
self.assertIn("from_account", form.errors)
self.assertIn("to_account", form.errors)
@@ -32,7 +32,7 @@ class TransactionSearchTests(TestCase):
def make(self, days, description="", amount="10", **kwargs):
return Transaction.objects.create(
account=self.account,
account=kwargs.pop("account", self.account),
type=Transaction.Type.EXPENSE,
date=self.today + timedelta(days=days),
reference_date=(self.today + timedelta(days=days)).replace(day=1),
@@ -72,3 +72,13 @@ class TransactionSearchTests(TestCase):
self.assertEqual(self.search("wallet"), ["Groceries", "Other"])
self.assertEqual(self.search("groceries nomatch"), [])
def test_filters_by_currency(self):
usd = Currency.objects.create(code="USD", name="Dollar", decimal_places=2)
other = Account.objects.create(name="Bank", currency=usd)
self.make(0, "brl")
self.make(0, "usd", account=other)
response = self.client.get(
reverse("transactions_search"), {"q": "", "currency": usd.id}
)
self.assertEqual([r["description"] for r in response.json()], ["usd"])
@@ -985,6 +985,13 @@ def transactions_trash_can_list(request):
)
# Query params the transaction picker may filter by (TransactionSelect.filters)
SEARCH_FILTERS = {
"currency": "account__currency_id",
"account": "account_id",
}
@login_required
@require_http_methods(["GET"])
def get_recent_transactions(request, filter_type=None):
@@ -1002,6 +1009,11 @@ def get_recent_transactions(request, filter_type=None):
elif filter_type == "income":
queryset = queryset.filter(type=Transaction.Type.INCOME)
for param, lookup in SEARCH_FILTERS.items():
value = request.GET.get(param, "")
if value.isdigit():
queryset = queryset.filter(**{lookup: value})
if search_term:
queryset = search_transactions(queryset, search_term)
+2 -1
View File
@@ -136,7 +136,8 @@ window.TomSelect = function createDynamicTomSelect(element) {
config.preload = 'focus';
config.shouldLoad = () => true;
config.load = function (query, callback) {
let url = element.dataset.load + '?q=' + encodeURIComponent(query);
const url = new URL(element.dataset.load, window.location.origin);
url.searchParams.set('q', query);
fetch(url)
.then(response => response.json())
.then(json => {