mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
Merge pull request #663 from eitchtee/feat/dca-improvements
feat(dca): optional amounts, currency-checked links and entities
This commit is contained in:
6 files changed
+171
-28
No files matched your search
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in new issue
Block a user