From 706f8fea2b0af477d97861ee8fd6d8b0fd2ee9b7 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Tue, 10 Nov 2026 21:55:29 -0300 Subject: [PATCH 1/3] feat(transactions): allow filtering the transaction picker search TransactionSelect takes a filters dict sent as query params; the search endpoint applies allowlisted ones (currency, account). --- app/apps/common/widgets/tom_select.py | 6 ++++++ .../transactions/tests/test_transaction_search.py | 12 +++++++++++- app/apps/transactions/views/transactions.py | 12 ++++++++++++ frontend/src/js/select.js | 3 ++- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/apps/common/widgets/tom_select.py b/app/apps/common/widgets/tom_select.py index 0f22d106..c179e630 100644 --- a/app/apps/common/widgets/tom_select.py +++ b/app/apps/common/widgets/tom_select.py @@ -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 diff --git a/app/apps/transactions/tests/test_transaction_search.py b/app/apps/transactions/tests/test_transaction_search.py index 6b02e95b..18901e4a 100644 --- a/app/apps/transactions/tests/test_transaction_search.py +++ b/app/apps/transactions/tests/test_transaction_search.py @@ -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"]) diff --git a/app/apps/transactions/views/transactions.py b/app/apps/transactions/views/transactions.py index 07b7f731..9ca4e68d 100644 --- a/app/apps/transactions/views/transactions.py +++ b/app/apps/transactions/views/transactions.py @@ -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) diff --git a/frontend/src/js/select.js b/frontend/src/js/select.js index dc573918..b65b9236 100644 --- a/frontend/src/js/select.js +++ b/frontend/src/js/select.js @@ -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 => { From 750b86394e4f624ad66d27c9f8337f9a0d09f897 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Tue, 10 Nov 2026 21:55:29 -0300 Subject: [PATCH 2/3] feat(dca): optional amounts, currency-checked links and entities on entries - Amounts default to the linked transaction's amount when left empty - Linked transactions must be in the strategy's payment/target currency; pickers only search those - Allow setting entities on transactions created from an entry - Fix crash when only the expense transaction was linked --- app/apps/dca/forms.py | 95 +++++++++++++++------ app/apps/dca/tests/test_transfer_linking.py | 55 +++++++++++- 2 files changed, 124 insertions(+), 26 deletions(-) diff --git a/app/apps/dca/forms.py b/app/apps/dca/forms.py index 03a9f539..60fe5d45 100644 --- a/app/apps/dca/forms.py +++ b/app/apps/dca/forms.py @@ -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,32 +227,25 @@ 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( @@ -248,10 +265,36 @@ class DCAEntryForm(forms.ModelForm): 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 +358,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 +372,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) diff --git a/app/apps/dca/tests/test_transfer_linking.py b/app/apps/dca/tests/test_transfer_linking.py index c98c9e2a..7dd0541f 100644 --- a/app/apps/dca/tests/test_transfer_linking.py +++ b/app/apps/dca/tests/test_transfer_linking.py @@ -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,56 @@ 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]) From 3468b10e942edf5aee857943c3b6fceed99e26cb Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Tue, 10 Nov 2026 21:57:39 -0300 Subject: [PATCH 3/3] feat(dca): limit create-transaction accounts to the strategy currencies --- app/apps/dca/forms.py | 2 ++ app/apps/dca/tests/test_transfer_linking.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/app/apps/dca/forms.py b/app/apps/dca/forms.py index 60fe5d45..0e638d68 100644 --- a/app/apps/dca/forms.py +++ b/app/apps/dca/forms.py @@ -250,6 +250,7 @@ class DCAEntryForm(forms.ModelForm): self.fields["from_account"].queryset = Account.objects.filter( is_archived=False, + currency=self.strategy.payment_currency, ) self.fields["from_category"].queryset = TransactionCategory.objects.filter( @@ -259,6 +260,7 @@ 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( diff --git a/app/apps/dca/tests/test_transfer_linking.py b/app/apps/dca/tests/test_transfer_linking.py index 7dd0541f..ef4cce48 100644 --- a/app/apps/dca/tests/test_transfer_linking.py +++ b/app/apps/dca/tests/test_transfer_linking.py @@ -150,3 +150,17 @@ class DCATransferLinkingTests(TestCase): 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)