diff --git a/app/apps/transactions/forms.py b/app/apps/transactions/forms.py index a5ef7049..5a2a582a 100644 --- a/app/apps/transactions/forms.py +++ b/app/apps/transactions/forms.py @@ -887,11 +887,17 @@ class InstallmentPlanForm(forms.ModelForm): ), ) - def save(self, **kwargs): + def save(self, source_transaction=None, **kwargs): is_new = not self.instance.id instance = super().save(**kwargs) - if is_new: + if is_new and source_transaction: + # The converted transaction becomes the first installment + source_transaction.installment_plan = instance + source_transaction.installment_id = instance.installment_start + source_transaction.save() + instance.update_transactions() + elif is_new: instance.create_transactions() else: instance.update_transactions() @@ -1145,12 +1151,12 @@ class RecurringTransactionForm(forms.ModelForm): return cleaned_data - def save(self, **kwargs): + def save(self, source_transaction=None, **kwargs): is_new = not self.instance.id instance = super().save(**kwargs) if is_new: - instance.create_upcoming_transactions() + instance.create_upcoming_transactions(first_transaction=source_transaction) else: instance.update_unpaid_transactions() instance.generate_upcoming_transactions() diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index f462a35e..8fd0285b 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -915,7 +915,7 @@ class RecurringTransaction(models.Model): instance = super().save(*args, **kwargs) return instance - def create_upcoming_transactions(self): + def create_upcoming_transactions(self, first_transaction=None): current_date = self.start_date reference_date = self.reference_date end_date = min( @@ -925,6 +925,12 @@ class RecurringTransaction(models.Model): timezone.now().date() + (self.get_recurrence_delta() * self.keep_at_most), ) + # When converting an existing transaction, it becomes the first occurrence + if first_transaction: + self.adopt_transaction(first_transaction, current_date, reference_date) + current_date = self.get_next_date(current_date) + reference_date = self.get_next_date(reference_date) + while current_date <= end_date: self.create_transaction(current_date, reference_date) current_date = self.get_next_date(current_date) @@ -959,6 +965,25 @@ class RecurringTransaction(models.Model): created_transaction.tags.set(self.tags(manager="all_objects").all()) created_transaction.entities.set(self.entities(manager="all_objects").all()) + def adopt_transaction(self, existing_transaction, date, reference_date): + existing_transaction.account = self.account + existing_transaction.type = self.type + existing_transaction.date = date + existing_transaction.reference_date = reference_date.replace(day=1) + existing_transaction.description = ( + self.description if self.add_description_to_transaction else "" + ) + existing_transaction.category = self.category + existing_transaction.notes = self.notes if self.add_notes_to_transaction else "" + existing_transaction.recurring_transaction = self + + if not existing_transaction.is_paid: # Don't update value for paid transactions + existing_transaction.amount = self.amount + + existing_transaction.save() + existing_transaction.tags.set(self.tags(manager="all_objects").all()) + existing_transaction.entities.set(self.entities(manager="all_objects").all()) + def get_recurrence_delta(self): if self.recurrence_type == self.RecurrenceType.DAY: return relativedelta(days=self.recurrence_interval) diff --git a/app/apps/transactions/tests/test_views.py b/app/apps/transactions/tests/test_views.py index 26ba86e0..88a51694 100644 --- a/app/apps/transactions/tests/test_views.py +++ b/app/apps/transactions/tests/test_views.py @@ -2,11 +2,15 @@ from datetime import date from django.contrib.auth import get_user_model from django.test import TestCase, override_settings +from django.urls import reverse from django.utils import timezone from apps.accounts.models import Account, AccountGroup from apps.currencies.models import Currency from apps.transactions.models import ( + InstallmentPlan, + QuickTransaction, + RecurringTransaction, Transaction, TransactionCategory, TransactionTag, @@ -172,3 +176,154 @@ class TransactionSimpleAddViewTests(TestCase): self.assertEqual(response.status_code, 200) form = response.context["form"] self.assertEqual(form.initial.get("category"), self.category.id) + + +@override_settings( + STORAGES={ + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, + }, + WHITENOISE_AUTOREFRESH=True, +) +class TransactionConvertViewTests(TestCase): + """Tests for converting a transaction into quick/recurring/installment""" + + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + email="testuser@test.com", password="testpass123" + ) + self.client.login(username="testuser@test.com", password="testpass123") + + self.currency = Currency.objects.create( + code="USD", name="US Dollar", decimal_places=2, prefix="$ " + ) + self.account = Account.objects.create( + name="Test Account", currency=self.currency + ) + self.category = TransactionCategory.objects.create(name="Test Category") + self.tag = TransactionTag.objects.create(name="TestTag") + + self.transaction = Transaction.objects.create( + account=self.account, + type=Transaction.Type.EXPENSE, + date=date(2030, 1, 15), + amount=50, + description="Gym", + category=self.category, + is_paid=True, + ) + self.transaction.tags.add(self.tag) + + def _get(self, url_name): + return self.client.get( + reverse(url_name, kwargs={"transaction_id": self.transaction.id}), + HTTP_HX_REQUEST="true", + ) + + def _post(self, url_name, data): + return self.client.post( + reverse(url_name, kwargs={"transaction_id": self.transaction.id}), + data, + HTTP_HX_REQUEST="true", + ) + + def test_quick_transaction_form_is_prefilled_without_name(self): + response = self._get("transaction_convert_to_quick_transaction") + self.assertEqual(response.status_code, 200) + initial = response.context["form"].initial + self.assertEqual(initial["description"], "Gym") + self.assertEqual(initial["tags"], ["TestTag"]) + self.assertNotIn("name", initial) + + def test_quick_transaction_requires_name_and_keeps_transaction(self): + data = { + "account": self.account.id, + "type": "EX", + "amount": "50", + "description": "Gym", + } + response = self._post("transaction_convert_to_quick_transaction", data) + self.assertEqual(response.status_code, 200) + self.assertFalse(QuickTransaction.objects.exists()) + + response = self._post( + "transaction_convert_to_quick_transaction", {**data, "name": "Gym"} + ) + self.assertEqual(response.status_code, 204) + self.assertTrue(QuickTransaction.objects.filter(name="Gym").exists()) + self.assertTrue(Transaction.objects.filter(id=self.transaction.id).exists()) + + def test_recurring_transaction_adopts_original_as_first_occurrence(self): + response = self._post( + "transaction_convert_to_recurring_transaction", + { + "account": self.account.id, + "type": "EX", + "amount": "60", + "description": "Gym", + "category": self.category.id, + "tags": ["TestTag"], + "start_date": "2030-01-15", + "recurrence_type": "month", + "recurrence_interval": 1, + "keep_at_most": 6, + }, + ) + self.assertEqual(response.status_code, 204) + + recurring = RecurringTransaction.all_objects.get() + self.transaction.refresh_from_db() + self.assertEqual(self.transaction.recurring_transaction, recurring) + # Paid amount is preserved + self.assertEqual(self.transaction.amount, 50) + self.assertEqual( + recurring.transactions.filter(date=date(2030, 1, 15)).count(), 1 + ) + self.assertEqual(recurring.last_generated_date, date(2030, 1, 15)) + + def test_installment_plan_adopts_original_as_first_installment(self): + response = self._post( + "transaction_convert_to_installment_plan", + { + "account": self.account.id, + "type": "EX", + "installment_amount": "50", + "description": "TV", + "add_description_to_transaction": "on", + "number_of_installments": 3, + "installment_start": 1, + "start_date": "2030-01-15", + "recurrence": "monthly", + }, + ) + self.assertEqual(response.status_code, 204) + + plan = InstallmentPlan.all_objects.get() + self.transaction.refresh_from_db() + self.assertEqual(self.transaction.installment_plan, plan) + self.assertEqual(self.transaction.installment_id, 1) + self.assertTrue(self.transaction.is_paid) + self.assertEqual(self.transaction.description, "TV") + self.assertEqual(plan.transactions.count(), 3) + + def test_linked_transaction_cannot_be_converted_into_plan(self): + self._post( + "transaction_convert_to_installment_plan", + { + "account": self.account.id, + "type": "EX", + "installment_amount": "50", + "description": "TV", + "number_of_installments": 2, + "installment_start": 1, + "start_date": "2030-01-15", + "recurrence": "monthly", + }, + ) + self.assertEqual( + self._get("transaction_convert_to_recurring_transaction").status_code, 404 + ) + self.assertEqual( + self._get("transaction_convert_to_installment_plan").status_code, 404 + ) diff --git a/app/apps/transactions/urls.py b/app/apps/transactions/urls.py index bd9444ca..00291fa9 100644 --- a/app/apps/transactions/urls.py +++ b/app/apps/transactions/urls.py @@ -398,8 +398,18 @@ urlpatterns = [ name="quick_transaction_add_as_transaction", ), path( - "transactions//add-as-quick-transaction/", - views.quick_transaction_add_as_quick_transaction, - name="quick_transaction_add_as_quick_transaction", + "transaction//convert/quick-transaction/", + views.transaction_convert_to_quick_transaction, + name="transaction_convert_to_quick_transaction", + ), + path( + "transaction//convert/recurring-transaction/", + views.transaction_convert_to_recurring_transaction, + name="transaction_convert_to_recurring_transaction", + ), + path( + "transaction//convert/installment-plan/", + views.transaction_convert_to_installment_plan, + name="transaction_convert_to_installment_plan", ), ] diff --git a/app/apps/transactions/views/__init__.py b/app/apps/transactions/views/__init__.py index b784666d..871a9ffc 100644 --- a/app/apps/transactions/views/__init__.py +++ b/app/apps/transactions/views/__init__.py @@ -6,3 +6,4 @@ from .actions import * from .installment_plans import * from .recurring_transactions import * from .quick_transactions import * +from .conversions import * diff --git a/app/apps/transactions/views/conversions.py b/app/apps/transactions/views/conversions.py new file mode 100644 index 00000000..759d435c --- /dev/null +++ b/app/apps/transactions/views/conversions.py @@ -0,0 +1,159 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.db import transaction as db_transaction +from django.http import HttpResponse +from django.shortcuts import render, get_object_or_404 +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.http import require_http_methods + +from apps.common.decorators.htmx import only_htmx +from apps.transactions.forms import ( + InstallmentPlanForm, + QuickTransactionForm, + RecurringTransactionForm, +) +from apps.transactions.models import Transaction + + +def _initial_from_transaction(transaction: Transaction): + return { + "account": transaction.account_id, + "type": transaction.type, + "description": transaction.description, + "notes": transaction.notes, + "category": transaction.category_id, + "tags": list(transaction.tags.values_list("name", flat=True)), + "entities": list(transaction.entities.values_list("name", flat=True)), + } + + +def _get_unlinked_transaction(transaction_id): + # Transactions already generated by a plan or recurrence can't be converted again + return get_object_or_404( + Transaction, + id=transaction_id, + installment_plan__isnull=True, + recurring_transaction__isnull=True, + ) + + +def _render_convert_form(request, form, title, transaction_id, url_name): + return render( + request, + "transactions/fragments/convert.html", + { + "form": form, + "title": title, + "form_url": reverse(url_name, kwargs={"transaction_id": transaction_id}), + }, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def transaction_convert_to_quick_transaction(request, transaction_id): + transaction = get_object_or_404(Transaction, id=transaction_id) + + if request.method == "POST": + form = QuickTransactionForm(request.POST) + if form.is_valid(): + form.save() + messages.success(request, _("Item added successfully")) + + return HttpResponse( + status=204, + headers={"HX-Trigger": "updated, hide_offcanvas"}, + ) + else: + form = QuickTransactionForm( + initial={ + **_initial_from_transaction(transaction), + "is_paid": transaction.is_paid, + "amount": transaction.amount, + "mute": transaction.mute, + } + ) + + return _render_convert_form( + request, + form, + _("Convert into quick transaction"), + transaction_id, + "transaction_convert_to_quick_transaction", + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def transaction_convert_to_recurring_transaction(request, transaction_id): + transaction = _get_unlinked_transaction(transaction_id) + + if request.method == "POST": + form = RecurringTransactionForm(request.POST) + if form.is_valid(): + with db_transaction.atomic(): + form.save(source_transaction=transaction) + messages.success(request, _("Recurring Transaction added successfully")) + + return HttpResponse( + status=204, + headers={"HX-Trigger": "updated, hide_offcanvas"}, + ) + else: + form = RecurringTransactionForm( + initial={ + **_initial_from_transaction(transaction), + "amount": transaction.amount, + "start_date": transaction.date, + "reference_date": transaction.reference_date, + "recurrence_interval": 1, + "recurrence_type": "month", + } + ) + + return _render_convert_form( + request, + form, + _("Convert into recurring transaction"), + transaction_id, + "transaction_convert_to_recurring_transaction", + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def transaction_convert_to_installment_plan(request, transaction_id): + transaction = _get_unlinked_transaction(transaction_id) + + if request.method == "POST": + form = InstallmentPlanForm(request.POST) + if form.is_valid(): + with db_transaction.atomic(): + form.save(source_transaction=transaction) + messages.success(request, _("Installment Plan added successfully")) + + return HttpResponse( + status=204, + headers={"HX-Trigger": "updated, hide_offcanvas"}, + ) + else: + form = InstallmentPlanForm( + initial={ + **_initial_from_transaction(transaction), + "installment_amount": transaction.amount, + "start_date": transaction.date, + "reference_date": transaction.reference_date, + } + ) + + return _render_convert_form( + request, + form, + _("Convert into installment plan"), + transaction_id, + "transaction_convert_to_installment_plan", + ) diff --git a/app/apps/transactions/views/quick_transactions.py b/app/apps/transactions/views/quick_transactions.py index ee7dc4c4..b8c9acb7 100644 --- a/app/apps/transactions/views/quick_transactions.py +++ b/app/apps/transactions/views/quick_transactions.py @@ -162,70 +162,3 @@ def quick_transaction_add_as_transaction(request, quick_transaction_id): }, ) - -@only_htmx -@login_required -@require_http_methods(["GET"]) -def quick_transaction_add_as_quick_transaction(request, transaction_id): - transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id) - - if ( - transaction.description - and QuickTransaction.objects.filter( - name__startswith=transaction.description - ).exists() - ) or QuickTransaction.objects.filter( - name__startswith=_("Quick Transaction") - ).exists(): - if transaction.description: - count = QuickTransaction.objects.filter( - name__startswith=transaction.description - ).count() - qt_name = transaction.description + f" ({count + 1})" - else: - count = QuickTransaction.objects.filter( - name__startswith=_("Quick Transaction") - ).count() - qt_name = _("Quick Transaction") + f" ({count + 1})" - else: - qt_name = transaction.description or _("Quick Transaction") - - transaction_data = model_to_dict( - transaction, - exclude=[ - "id", - "name", - "owner", - "account", - "category", - "tags", - "entities", - "date", - "reference_date", - "installment_plan", - "installment_id", - "recurring_transaction", - "deleted", - "deleted_at", - "internal_id", - ], - ) - - new_quick_transaction = QuickTransaction(**transaction_data) - new_quick_transaction.account = transaction.account - new_quick_transaction.category = transaction.category - - new_quick_transaction.name = qt_name - - new_quick_transaction.save() - new_quick_transaction.tags.set(transaction.tags.all()) - new_quick_transaction.entities.set(transaction.entities.all()) - - messages.success(request, _("Item added successfully")) - - return HttpResponse( - status=204, - headers={ - "HX-Trigger": "toasts", - }, - ) diff --git a/app/templates/cotton/transaction/item.html b/app/templates/cotton/transaction/item.html index 248bc29a..1ae35b81 100644 --- a/app/templates/cotton/transaction/item.html +++ b/app/templates/cotton/transaction/item.html @@ -203,9 +203,50 @@ hx-target="closest .transaction" hx-swap="outerHTML">{% translate 'Hide from summaries' %} {% endif %} -
  • {% translate 'Add as quick transaction' %} +

  • + {% crispy form %} + +{% endblock %} diff --git a/frontend/src/styles/_dropdown.scss b/frontend/src/styles/_dropdown.scss index c2241572..52a89ec6 100644 --- a/frontend/src/styles/_dropdown.scss +++ b/frontend/src/styles/_dropdown.scss @@ -402,3 +402,48 @@ $breakpoints: ( --dropdown-link-disabled-color: #adb5bd; --dropdown-header-color: #adb5bd; } + +// Flyout submenu inside a dropdown menu, opened on hover/focus +.dropdown-submenu { + position: relative; + // Block layout (not daisyUI's wrapping flex column), so the inline submenu's + // height is measured at its real width instead of a near-zero fit-content one + display: block; + + > .dropdown-submenu-content { + display: none; + // Inline (mobile) submenu fits the menu's current width instead of widening it + contain: inline-size; + width: auto; // stretch to the parent item (daisyUI's .menu is fit-content) + white-space: normal; + margin-inline-start: 0.5rem; + padding: 0; + + &::before { + display: none; + } + } + + &:hover > .dropdown-submenu-content, + &:focus-within > .dropdown-submenu-content { + display: block; + } + + @include media-breakpoint-up(md) { + > .dropdown-submenu-content { + position: absolute; + top: calc(-1 * (var(--dropdown-padding-y) + var(--dropdown-border-width))); + left: 100%; + margin: 0; + z-index: var(--dropdown-z-index); + width: max-content; + contain: none; + white-space: nowrap; + padding: var(--dropdown-padding-y) var(--dropdown-padding-x); + background-color: var(--dropdown-bg); + border: var(--dropdown-border-width) solid var(--dropdown-border-color); + border-radius: var(--dropdown-border-radius); + box-shadow: var(--dropdown-box-shadow); + } + } +}