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 import json
from urllib.parse import urlencode
from django.forms import SelectMultiple, widgets from django.forms import SelectMultiple, widgets
from django.urls import reverse from django.urls import reverse
@@ -141,6 +142,9 @@ class TransactionSelect(TomSelect):
self.load_income = income self.load_income = income
self.load_expense = expense self.load_expense = expense
self.create = False 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): def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs) attrs = super().build_attrs(base_attrs, extra_attrs)
@@ -156,6 +160,8 @@ class TransactionSelect(TomSelect):
attrs["data-load"] = reverse( attrs["data-load"] = reverse(
"transactions_search", kwargs={"filter_type": "expenses"} "transactions_search", kwargs={"filter_type": "expenses"}
) )
if self.filters:
attrs["data-load"] += "?" + urlencode(self.filters)
return attrs 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.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect, TransactionSelect from apps.common.widgets.tom_select import TomSelect, TransactionSelect
from apps.dca.models import DCAEntry, DCAStrategy 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.bootstrap import AccordionGroup, FormActions, Accordion
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Column, Div, Layout, Row from crispy_forms.layout import HTML, Column, Div, Layout, Row
@@ -104,6 +109,23 @@ class DCAEntryForm(forms.ModelForm):
queryset=TransactionTag.objects.filter(active=True), 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( expense_transaction = DynamicModelChoiceField(
model=Transaction, model=Transaction,
to_field_name="id", to_field_name="id",
@@ -163,6 +185,7 @@ class DCAEntryForm(forms.ModelForm):
Column("from_category"), Column("from_category"),
Column("from_tags"), Column("from_tags"),
), ),
"from_entities",
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border my-3", css_class="bg-base-100 rounded-box p-4 border-base-content/60 border my-3",
), ),
Div( Div(
@@ -171,6 +194,7 @@ class DCAEntryForm(forms.ModelForm):
Column("to_category"), Column("to_category"),
Column("to_tags"), Column("to_tags"),
), ),
"to_entities",
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border", css_class="bg-base-100 rounded-box p-4 border-base-content/60 border",
), ),
active=False, active=False,
@@ -203,36 +227,30 @@ class DCAEntryForm(forms.ModelForm):
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput() self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False) self.fields["date"].widget = AirDatePickerInput(clear_button=False)
expense_transaction = None # Amounts can come from the linked transactions instead
income_transaction = None self.fields["amount_paid"].required = False
if self.instance and self.instance.pk: self.fields["amount_received"].required = False
# 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
# If we have a current transaction, ensure it's in the queryset # The picker only searches the currency each side expects (enforced in clean)
if income_transaction: for name, currency in (
self.fields["income_transaction"].queryset = Transaction.objects.filter( ("expense_transaction", self.strategy.payment_currency),
id=income_transaction.id ("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: # Only the selected one: the picker loads the rest by search
self.fields["expense_transaction"].queryset = Transaction.objects.filter( field.queryset = Transaction.objects.filter(
id=expense_transaction.id id=selected if str(selected or "").isdigit() else None
) )
self.fields["from_account"].queryset = Account.objects.filter( self.fields["from_account"].queryset = Account.objects.filter(
is_archived=False, is_archived=False,
currency=self.strategy.payment_currency,
) )
self.fields["from_category"].queryset = TransactionCategory.objects.filter( self.fields["from_category"].queryset = TransactionCategory.objects.filter(
@@ -242,16 +260,43 @@ class DCAEntryForm(forms.ModelForm):
self.fields["to_account"].queryset = Account.objects.filter( self.fields["to_account"].queryset = Account.objects.filter(
is_archived=False, is_archived=False,
currency=self.strategy.target_currency,
) )
self.fields["to_category"].queryset = TransactionCategory.objects.filter( self.fields["to_category"].queryset = TransactionCategory.objects.filter(
active=True active=True
) )
self.fields["to_tags"].queryset = TransactionTag.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): def clean(self):
cleaned_data = super().clean() 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"): if cleaned_data.get("create_transaction"):
from_account = cleaned_data.get("from_account") from_account = cleaned_data.get("from_account")
to_account = cleaned_data.get("to_account") to_account = cleaned_data.get("to_account")
@@ -315,6 +360,7 @@ class DCAEntryForm(forms.ModelForm):
notes=notes, notes=notes,
) )
from_transaction.tags.set(self.cleaned_data.get("from_tags", [])) from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
from_transaction.entities.set(self.cleaned_data.get("from_entities", []))
# Create "To" transaction # Create "To" transaction
to_transaction = Transaction.objects.create( to_transaction = Transaction.objects.create(
@@ -328,6 +374,7 @@ class DCAEntryForm(forms.ModelForm):
notes=notes, notes=notes,
) )
to_transaction.tags.set(self.cleaned_data.get("to_tags", [])) 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) 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.currencies.models import Currency
from apps.dca.forms import DCAEntryForm from apps.dca.forms import DCAEntryForm
from apps.dca.models import DCAStrategy 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 IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE
@@ -97,3 +97,70 @@ class DCATransferLinkingTests(TestCase):
expense.link_transfer(income) expense.link_transfer(income)
expense.link_transfer(income) # already linked to each other: no-op expense.link_transfer(income) # already linked to each other: no-op
self.assertEqual(Transaction.objects.get(pk=income.pk).linked_transaction, expense) 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): def make(self, days, description="", amount="10", **kwargs):
return Transaction.objects.create( return Transaction.objects.create(
account=self.account, account=kwargs.pop("account", self.account),
type=Transaction.Type.EXPENSE, type=Transaction.Type.EXPENSE,
date=self.today + timedelta(days=days), date=self.today + timedelta(days=days),
reference_date=(self.today + timedelta(days=days)).replace(day=1), 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("wallet"), ["Groceries", "Other"])
self.assertEqual(self.search("groceries nomatch"), []) 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 @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def get_recent_transactions(request, filter_type=None): def get_recent_transactions(request, filter_type=None):
@@ -1002,6 +1009,11 @@ def get_recent_transactions(request, filter_type=None):
elif filter_type == "income": elif filter_type == "income":
queryset = queryset.filter(type=Transaction.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: if search_term:
queryset = search_transactions(queryset, 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.preload = 'focus';
config.shouldLoad = () => true; config.shouldLoad = () => true;
config.load = function (query, callback) { 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) fetch(url)
.then(response => response.json()) .then(response => response.json())
.then(json => { .then(json => {