feat(transactions): add views to convert a transaction into a quick transaction, recurring transaction or installment plan

This commit is contained in:
Herculino Trotta committed 2026-10-09 23:25:19 -03:00
1 parent cd6069f857
commit 8c5c299312
5 files changed
+340

No files matched your search

+155
View File
@@ -2,11 +2,15 @@ from datetime import date
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from apps.accounts.models import Account, AccountGroup from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency from apps.currencies.models import Currency
from apps.transactions.models import ( from apps.transactions.models import (
InstallmentPlan,
QuickTransaction,
RecurringTransaction,
Transaction, Transaction,
TransactionCategory, TransactionCategory,
TransactionTag, TransactionTag,
@@ -172,3 +176,154 @@ class TransactionSimpleAddViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
form = response.context["form"] form = response.context["form"]
self.assertEqual(form.initial.get("category"), self.category.id) 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
)
+15
View File
@@ -402,4 +402,19 @@ urlpatterns = [
views.quick_transaction_add_as_quick_transaction, views.quick_transaction_add_as_quick_transaction,
name="quick_transaction_add_as_quick_transaction", name="quick_transaction_add_as_quick_transaction",
), ),
path(
"transaction/<int:transaction_id>/convert/quick-transaction/",
views.transaction_convert_to_quick_transaction,
name="transaction_convert_to_quick_transaction",
),
path(
"transaction/<int:transaction_id>/convert/recurring-transaction/",
views.transaction_convert_to_recurring_transaction,
name="transaction_convert_to_recurring_transaction",
),
path(
"transaction/<int:transaction_id>/convert/installment-plan/",
views.transaction_convert_to_installment_plan,
name="transaction_convert_to_installment_plan",
),
] ]
+1
View File
@@ -6,3 +6,4 @@ from .actions import *
from .installment_plans import * from .installment_plans import *
from .recurring_transactions import * from .recurring_transactions import *
from .quick_transactions import * from .quick_transactions import *
from .conversions import *
+159
View File
@@ -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",
)
@@ -0,0 +1,10 @@
{% extends 'extends/offcanvas.html' %}
{% load crispy_forms_tags %}
{% block title %}{{ title }}{% endblock %}
{% block body %}
<form hx-post="{{ form_url }}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}