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..19284284 100644 --- a/app/apps/transactions/urls.py +++ b/app/apps/transactions/urls.py @@ -402,4 +402,19 @@ urlpatterns = [ views.quick_transaction_add_as_quick_transaction, name="quick_transaction_add_as_quick_transaction", ), + path( + "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/templates/transactions/fragments/convert.html b/app/templates/transactions/fragments/convert.html new file mode 100644 index 00000000..068f7e7a --- /dev/null +++ b/app/templates/transactions/fragments/convert.html @@ -0,0 +1,10 @@ +{% extends 'extends/offcanvas.html' %} +{% load crispy_forms_tags %} + +{% block title %}{{ title }}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+{% endblock %}