From a18e2ab58a20bb0205465b20d55b9db09369caca Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Thu, 17 Oct 2024 00:38:12 -0300 Subject: [PATCH] feat: add recurring transactions --- app/apps/transactions/admin.py | 8 + app/apps/transactions/forms.py | 260 ++++++++---------- app/apps/transactions/models.py | 199 ++++++++++++-- app/apps/transactions/tasks.py | 9 + app/apps/transactions/urls.py | 35 +++ app/apps/transactions/views/__init__.py | 1 + .../views/recurring_transactions.py | 145 ++++++++++ app/templates/includes/navbar.html | 21 +- .../monthly_overview/pages/overview.html | 6 + .../recurring_transactions/fragments/add.html | 11 + .../fragments/edit.html | 13 + .../fragments/list.html | 74 +++++ .../fragments/list_transactions.html | 13 + .../recurring_transactions/pages/index.html | 8 + frontend/src/application/select.js | 2 +- 15 files changed, 630 insertions(+), 175 deletions(-) create mode 100644 app/apps/transactions/tasks.py create mode 100644 app/apps/transactions/views/recurring_transactions.py create mode 100644 app/templates/recurring_transactions/fragments/add.html create mode 100644 app/templates/recurring_transactions/fragments/edit.html create mode 100644 app/templates/recurring_transactions/fragments/list.html create mode 100644 app/templates/recurring_transactions/fragments/list_transactions.html create mode 100644 app/templates/recurring_transactions/pages/index.html diff --git a/app/apps/transactions/admin.py b/app/apps/transactions/admin.py index d26ce95..073eae7 100644 --- a/app/apps/transactions/admin.py +++ b/app/apps/transactions/admin.py @@ -5,6 +5,7 @@ from apps.transactions.models import ( TransactionCategory, TransactionTag, InstallmentPlan, + RecurringTransaction, ) @@ -33,5 +34,12 @@ class InstallmentPlanAdmin(admin.ModelAdmin): ] +@admin.register(RecurringTransaction) +class RecurringTransactionAdmin(admin.ModelAdmin): + inlines = [ + TransactionInline, + ] + + admin.site.register(TransactionCategory) admin.site.register(TransactionTag) diff --git a/app/apps/transactions/forms.py b/app/apps/transactions/forms.py index cb68b70..354e1b0 100644 --- a/app/apps/transactions/forms.py +++ b/app/apps/transactions/forms.py @@ -1,10 +1,13 @@ from crispy_bootstrap5.bootstrap5 import Switch from crispy_forms.bootstrap import FormActions from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout, Row, Column, Field -from dateutil.relativedelta import relativedelta +from crispy_forms.layout import ( + Layout, + Row, + Column, + Field, +) from django import forms -from django.db import transaction from django.utils.translation import gettext_lazy as _ from apps.accounts.models import Account @@ -12,16 +15,17 @@ from apps.common.fields.forms.dynamic_select import ( DynamicModelChoiceField, DynamicModelMultipleChoiceField, ) +from apps.common.fields.month_year import MonthYearFormField from apps.common.widgets.crispy.submit import NoClassSubmit +from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput from apps.common.widgets.tom_select import TomSelect from apps.transactions.models import ( Transaction, TransactionCategory, TransactionTag, InstallmentPlan, + RecurringTransaction, ) -from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput -from apps.common.fields.month_year import MonthYearFormField class TransactionForm(forms.ModelForm): @@ -312,143 +316,6 @@ class TransferForm(forms.Form): return from_transaction, to_transaction -# class InstallmentPlanForm(forms.Form): -# type = forms.ChoiceField(choices=Transaction.Type.choices) -# account = forms.ModelChoiceField( -# queryset=Account.objects.all(), -# label=_("Account"), -# widget=TomSelect(), -# ) -# start_date = forms.DateField( -# label=_("Start Date"), -# widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), -# ) -# reference_date = MonthYearFormField(label=_("Reference Date"), required=False) -# description = forms.CharField(max_length=500, label=_("Description")) -# number_of_installments = forms.IntegerField( -# min_value=1, label=_("Number of Installments") -# ) -# recurrence = forms.ChoiceField( -# choices=( -# ("yearly", _("Yearly")), -# ("monthly", _("Monthly")), -# ("weekly", _("Weekly")), -# ("daily", _("Daily")), -# ), -# label=_("Recurrence"), -# initial="monthly", -# widget=TomSelect(clear_button=False), -# ) -# installment_amount = forms.DecimalField( -# max_digits=42, -# decimal_places=30, -# required=True, -# label=_("Installment Amount"), -# ) -# category = DynamicModelChoiceField( -# model=TransactionCategory, -# required=False, -# label=_("Category"), -# ) -# tags = DynamicModelMultipleChoiceField( -# model=TransactionTag, -# to_field_name="name", -# create_field="name", -# required=False, -# label=_("Tags"), -# ) -# -# def __init__(self, *args, **kwargs): -# super().__init__(*args, **kwargs) -# -# self.helper = FormHelper() -# self.helper.form_tag = False -# self.helper.form_method = "post" -# -# self.helper.layout = Layout( -# Field( -# "type", -# template="transactions/widgets/income_expense_toggle_buttons.html", -# ), -# "account", -# "description", -# Row( -# Column("number_of_installments", css_class="form-group col-md-6 mb-0"), -# Column("recurrence", css_class="form-group col-md-6 mb-0"), -# css_class="form-row", -# ), -# Row( -# Column("start_date", css_class="form-group col-md-6 mb-0"), -# Column("reference_date", css_class="form-group col-md-6 mb-0"), -# css_class="form-row", -# ), -# "installment_amount", -# Row( -# Column("category", css_class="form-group col-md-6 mb-0"), -# Column("tags", css_class="form-group col-md-6 mb-0"), -# css_class="form-row", -# ), -# FormActions( -# NoClassSubmit( -# "submit", _("Add"), css_class="btn btn-outline-primary w-100" -# ), -# ), -# ) -# -# self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput() -# -# def save(self): -# number_of_installments = self.cleaned_data["number_of_installments"] -# transaction_type = self.cleaned_data["type"] -# start_date = self.cleaned_data["start_date"] -# reference_date = self.cleaned_data["reference_date"] or start_date -# recurrence = self.cleaned_data["recurrence"] -# account = self.cleaned_data["account"] -# description = self.cleaned_data["description"] -# installment_amount = self.cleaned_data["installment_amount"] -# category = self.cleaned_data["category"] -# -# print(reference_date, type(reference_date)) -# print(start_date, type(start_date)) -# -# with transaction.atomic(): -# installment_plan = InstallmentPlan.objects.create( -# account=account, -# description=description, -# number_of_installments=number_of_installments, -# ) -# -# with transaction.atomic(): -# for i in range(number_of_installments): -# if recurrence == "yearly": -# delta = relativedelta(years=i) -# elif recurrence == "monthly": -# delta = relativedelta(months=i) -# elif recurrence == "weekly": -# delta = relativedelta(weeks=i) -# elif recurrence == "daily": -# delta = relativedelta(days=i) -# -# transaction_date = start_date + delta -# transaction_reference_date = (reference_date + delta).replace(day=1) -# new_transaction = Transaction.objects.create( -# account=account, -# type=transaction_type, -# date=transaction_date, -# is_paid=False, -# reference_date=transaction_reference_date, -# amount=installment_amount, -# description=description, -# notes=f"{i + 1}/{number_of_installments}", -# category=category, -# installment_plan=installment_plan, -# ) -# -# new_transaction.tags.set(self.cleaned_data.get("tags", [])) -# -# return installment_plan - - class InstallmentPlanForm(forms.ModelForm): tags = DynamicModelMultipleChoiceField( model=TransactionTag, @@ -615,3 +482,112 @@ class TransactionCategoryForm(forms.ModelForm): ), ), ) + + +class RecurringTransactionForm(forms.ModelForm): + tags = DynamicModelMultipleChoiceField( + model=TransactionTag, + to_field_name="name", + create_field="name", + required=False, + label=_("Tags"), + ) + category = DynamicModelChoiceField( + model=TransactionCategory, + required=False, + label=_("Category"), + ) + type = forms.ChoiceField(choices=Transaction.Type.choices) + reference_date = MonthYearFormField(label=_("Reference Date"), required=False) + + class Meta: + model = RecurringTransaction + fields = [ + "account", + "type", + "amount", + "description", + "category", + "tags", + "start_date", + "reference_date", + "end_date", + "recurrence_type", + "recurrence_interval", + ] + widgets = { + "start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + "end_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + "account": TomSelect(clear_button=False), + "recurrence_type": TomSelect(clear_button=False), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = "post" + self.helper.form_tag = False + + self.helper.layout = Layout( + Field( + "type", + template="transactions/widgets/income_expense_toggle_buttons.html", + ), + "account", + "description", + "amount", + Row( + Column("category", css_class="form-group col-md-6 mb-0"), + Column("tags", css_class="form-group col-md-6 mb-0"), + css_class="form-row", + ), + Row( + Column("start_date", css_class="form-group col-md-6 mb-0"), + Column("reference_date", css_class="form-group col-md-6 mb-0"), + css_class="form-row", + ), + Row( + Column("recurrence_interval", css_class="form-group col-md-4 mb-0"), + Column("recurrence_type", css_class="form-group col-md-4 mb-0"), + Column("end_date", css_class="form-group col-md-4 mb-0"), + css_class="form-row", + ), + ) + + self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput() + + if self.instance and self.instance.pk: + self.helper.layout.append( + FormActions( + NoClassSubmit( + "submit", _("Update"), css_class="btn btn-outline-primary w-100" + ), + ), + ) + else: + self.helper.layout.append( + FormActions( + NoClassSubmit( + "submit", _("Add"), css_class="btn btn-outline-primary w-100" + ), + ), + ) + + def clean(self): + cleaned_data = super().clean() + start_date = cleaned_data.get("start_date") + end_date = cleaned_data.get("end_date") + + if start_date and end_date and start_date > end_date: + raise forms.ValidationError("End date should be after the start date.") + + return cleaned_data + + def save(self, **kwargs): + is_new = not self.instance.id + + instance = super().save(**kwargs) + if is_new: + instance.create_upcoming_transactions() + + return instance diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index ece75dd..f3dde01 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -1,17 +1,16 @@ import logging from dateutil.relativedelta import relativedelta -from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models, transaction from django.db.models import Q -from django.utils.functional import cached_property +from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from apps.common.functions.decimals import truncate_decimal -from apps.transactions.validators import validate_decimal_places, validate_non_negative -from apps.currencies.utils.convert import convert from apps.common.fields.month_year import MonthYearModelField +from apps.common.functions.decimals import truncate_decimal +from apps.currencies.utils.convert import convert +from apps.transactions.validators import validate_decimal_places, validate_non_negative logger = logging.getLogger() @@ -41,30 +40,6 @@ class TransactionTag(models.Model): return self.name -# class InstallmentPlan(models.Model): -# account = models.ForeignKey( -# "accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account") -# ) -# description = models.CharField(max_length=500, verbose_name=_("Description")) -# number_of_installments = models.PositiveIntegerField( -# validators=[MinValueValidator(1)], verbose_name=_("Number of Installments") -# ) -# # start_date = models.DateField(verbose_name=_("Start Date")) -# # end_date = models.DateField(verbose_name=_("End Date")) -# -# class Meta: -# verbose_name = _("Installment Plan") -# verbose_name_plural = _("Installment Plans") -# -# def __str__(self): -# return f"{self.description} - {self.number_of_installments} installments" -# -# def delete(self, *args, **kwargs): -# # Delete related transactions -# self.transactions.all().delete() -# super().delete(*args, **kwargs) - - class Transaction(models.Model): class Type(models.TextChoices): INCOME = "IN", _("Income") @@ -110,6 +85,14 @@ class Transaction(models.Model): verbose_name=_("Installment Plan"), ) installment_id = models.PositiveIntegerField(null=True, blank=True) + recurring_transaction = models.ForeignKey( + "RecurringTransaction", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="transactions", + verbose_name=_("Recurring Transaction"), + ) class Meta: verbose_name = _("Transaction") @@ -120,6 +103,7 @@ class Transaction(models.Model): self.amount = truncate_decimal( value=self.amount, decimal_places=self.account.currency.decimal_places ) + self.reference_date = self.reference_date.replace(day=1) self.full_clean() super().save(*args, **kwargs) @@ -323,3 +307,160 @@ class InstallmentPlan(models.Model): # Delete related transactions self.transactions.all().delete() super().delete(*args, **kwargs) + + +class RecurringTransaction(models.Model): + class RecurrenceType(models.TextChoices): + DAY = "day", _("day(s)") + WEEK = "week", _("week(s)") + MONTH = "month", _("month(s)") + YEAR = "year", _("year(s)") + + account = models.ForeignKey( + "accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account") + ) + type = models.CharField( + max_length=2, + choices=Transaction.Type, + default=Transaction.Type.EXPENSE, + verbose_name=_("Type"), + ) + amount = models.DecimalField( + max_digits=42, + decimal_places=30, + verbose_name=_("Amount"), + validators=[validate_non_negative, validate_decimal_places], + ) + description = models.CharField(max_length=500, verbose_name=_("Description")) + category = models.ForeignKey( + TransactionCategory, + on_delete=models.SET_NULL, + verbose_name=_("Category"), + blank=True, + null=True, + ) + tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True) + reference_date = models.DateField( + verbose_name=_("Reference Date"), null=True, blank=True + ) + + # Recurrence fields + start_date = models.DateField(verbose_name=_("Start Date")) + end_date = models.DateField(verbose_name=_("End Date"), null=True, blank=True) + recurrence_type = models.CharField( + max_length=7, choices=RecurrenceType, verbose_name=_("Recurrence Type") + ) + recurrence_interval = models.PositiveIntegerField( + verbose_name=_("Recurrence Interval"), + ) + + last_generated_date = models.DateField( + verbose_name=_("Last Generated Date"), null=True, blank=True + ) + last_generated_reference_date = models.DateField( + verbose_name=_("Last Generated Reference Date"), null=True, blank=True + ) + + class Meta: + verbose_name = _("Recurring Transaction") + verbose_name_plural = _("Recurring Transactions") + db_table = "recurring_transactions" + + def __str__(self): + return self.description + + def save(self, *args, **kwargs): + if not self.reference_date: + self.reference_date = self.start_date.replace(day=1) + + instance = super().save(*args, **kwargs) + return instance + + def create_upcoming_transactions(self): + current_date = self.start_date + reference_date = self.reference_date + end_date = min( + self.end_date or timezone.now().date() + relativedelta(years=1), + timezone.now().date() + relativedelta(years=1), + ) + + while current_date <= end_date: + self.create_transaction(current_date, reference_date) + current_date = self.get_next_date(current_date) + reference_date = self.get_next_date(reference_date) + + self.last_generated_date = current_date - self.get_recurrence_delta() + self.last_generated_reference_date = ( + reference_date - self.get_recurrence_delta() + ) + self.save( + update_fields=["last_generated_date", "last_generated_reference_date"] + ) + + def create_transaction(self, date, reference_date): + created_transaction = Transaction.objects.create( + account=self.account, + type=self.type, + date=date, + reference_date=reference_date, + amount=self.amount, + description=self.description, + category=self.category, + is_paid=False, + recurring_transaction=self, + ) + if self.tags.exists(): + created_transaction.tags.set(self.tags.all()) + + def get_recurrence_delta(self): + if self.recurrence_type == self.RecurrenceType.DAY: + return relativedelta(days=self.recurrence_interval) + elif self.recurrence_type == self.RecurrenceType.WEEK: + return relativedelta(weeks=self.recurrence_interval) + elif self.recurrence_type == self.RecurrenceType.MONTH: + return relativedelta(months=self.recurrence_interval) + elif self.recurrence_type == self.RecurrenceType.YEAR: + return relativedelta(years=self.recurrence_interval) + + def get_next_date(self, current_date): + return current_date + self.get_recurrence_delta() + + @classmethod + def generate_upcoming_transactions(cls): + today = timezone.now().date() + recurring_transactions = cls.objects.filter( + models.Q(end_date__isnull=True) | models.Q(end_date__gte=today) + ) + + for recurring_transaction in recurring_transactions: + if recurring_transaction.last_generated_date: + start_date = recurring_transaction.get_next_date( + recurring_transaction.last_generated_date + ) + reference_date = recurring_transaction.get_next_date( + recurring_transaction.last_generated_reference_date + ) + else: + start_date = max(recurring_transaction.start_date, today) + reference_date = recurring_transaction.reference_date + + current_date = start_date + end_date = min( + recurring_transaction.end_date or today + relativedelta(years=1), + today + relativedelta(years=1), + ) + + while current_date <= end_date: + recurring_transaction.create_transaction(current_date, reference_date) + current_date = recurring_transaction.get_next_date(current_date) + reference_date = recurring_transaction.get_next_date(reference_date) + + recurring_transaction.last_generated_date = ( + current_date - recurring_transaction.get_recurrence_delta() + ) + recurring_transaction.last_generated_reference_date = ( + reference_date - recurring_transaction.get_recurrence_delta() + ) + recurring_transaction.save( + update_fields=["last_generated_date", "last_generated_reference_date"] + ) diff --git a/app/apps/transactions/tasks.py b/app/apps/transactions/tasks.py new file mode 100644 index 0000000..215241f --- /dev/null +++ b/app/apps/transactions/tasks.py @@ -0,0 +1,9 @@ +from procrastinate.contrib.django import app + +from apps.transactions.models import RecurringTransaction + + +@app.periodic(cron="0 0 * * *") +@app.task +def generate_recurring_transactions(): + RecurringTransaction.generate_upcoming_transactions() diff --git a/app/apps/transactions/urls.py b/app/apps/transactions/urls.py index b6444f4..e749361 100644 --- a/app/apps/transactions/urls.py +++ b/app/apps/transactions/urls.py @@ -103,4 +103,39 @@ urlpatterns = [ views.installment_plan_refresh, name="installment_plan_refresh", ), + path( + "recurring-trasanctions/", + views.recurring_transactions_index, + name="recurring_trasanctions_index", + ), + path( + "recurring-trasanctions/list/", + views.recurring_transactions_list, + name="recurring_transaction_list", + ), + path( + "recurring-transactions/add/", + views.recurring_transaction_add, + name="recurring_transaction_add", + ), + path( + "recurring-transactions//transactions/", + views.recurring_transaction_transactions, + name="recurring_transaction_transactions", + ), + path( + "recurring-transactions//edit/", + views.recurring_transaction_edit, + name="recurring_transaction_edit", + ), + path( + "recurring-transactions//delete/", + views.recurring_transaction_delete, + name="recurring_transaction_delete", + ), + # path( + # "installment-plans//refresh/", + # views.installment_plan_refresh, + # name="installment_plan_refresh", + # ), ] diff --git a/app/apps/transactions/views/__init__.py b/app/apps/transactions/views/__init__.py index fa19c76..dddb240 100644 --- a/app/apps/transactions/views/__init__.py +++ b/app/apps/transactions/views/__init__.py @@ -3,3 +3,4 @@ from .tags import * from .categories import * from .actions import * from .installment_plans import * +from .recurring_transactions import * diff --git a/app/apps/transactions/views/recurring_transactions.py b/app/apps/transactions/views/recurring_transactions.py new file mode 100644 index 0000000..aeed07e --- /dev/null +++ b/app/apps/transactions/views/recurring_transactions.py @@ -0,0 +1,145 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.shortcuts import render, get_object_or_404 +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from apps.common.decorators.htmx import only_htmx +from apps.transactions.forms import RecurringTransactionForm +from apps.transactions.models import RecurringTransaction + + +@login_required +@require_http_methods(["GET"]) +def recurring_transactions_index(request): + return render( + request, + "recurring_transactions/pages/index.html", + ) + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def recurring_transactions_list(request): + recurring_transactions = RecurringTransaction.objects.all().order_by("-end_date") + + return render( + request, + "recurring_transactions/fragments/list.html", + {"recurring_transactions": recurring_transactions}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def recurring_transaction_transactions(request, recurring_transaction_id): + recurring_transaction = get_object_or_404( + RecurringTransaction, id=recurring_transaction_id + ) + transactions = recurring_transaction.transactions.all().order_by( + "reference_date", "id" + ) + + return render( + request, + "recurring_transactions/fragments/list_transactions.html", + {"recurring_transaction": recurring_transaction, "transactions": transactions}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def recurring_transaction_add(request): + if request.method == "POST": + form = RecurringTransactionForm(request.POST) + if form.is_valid(): + form.save() + messages.success(request, _("Recurring Transaction added successfully")) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) + else: + form = RecurringTransactionForm() + + return render( + request, + "recurring_transactions/fragments/add.html", + {"form": form}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def recurring_transaction_edit(request, recurring_transaction_id): + recurring_transaction = get_object_or_404( + RecurringTransaction, id=recurring_transaction_id + ) + + if request.method == "POST": + form = RecurringTransactionForm(request.POST, instance=recurring_transaction) + if form.is_valid(): + form.save() + messages.success(request, _("Recurring Transaction updated successfully")) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) + else: + form = RecurringTransactionForm(instance=recurring_transaction) + + return render( + request, + "recurring_transactions/fragments/edit.html", + {"form": form, "recurring_transaction": recurring_transaction}, + ) + + +# @only_htmx +# @login_required +# @require_http_methods(["GET"]) +# def recurring_transaction_refresh(request, installment_plan_id): +# installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id) +# installment_plan.update_transactions() +# +# messages.success(request, _("Installment Plan refreshed successfully")) +# +# return HttpResponse( +# status=204, +# headers={ +# "HX-Trigger": "updated, hide_offcanvas, toasts", +# }, +# ) + + +@only_htmx +@login_required +@csrf_exempt +@require_http_methods(["DELETE"]) +def recurring_transaction_delete(request, recurring_transaction_id): + recurring_transaction = get_object_or_404( + RecurringTransaction, id=recurring_transaction_id + ) + + recurring_transaction.delete() + + messages.success(request, _("Recurring Transaction deleted successfully")) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) diff --git a/app/templates/includes/navbar.html b/app/templates/includes/navbar.html index 0c0a6f6..a90b1a8 100644 --- a/app/templates/includes/navbar.html +++ b/app/templates/includes/navbar.html @@ -34,7 +34,24 @@ +
  • {% translate 'Tags' %}
  • -
  • {% translate 'Installment Plans' %}
  • diff --git a/app/templates/monthly_overview/pages/overview.html b/app/templates/monthly_overview/pages/overview.html index cd8f700..a1881a0 100644 --- a/app/templates/monthly_overview/pages/overview.html +++ b/app/templates/monthly_overview/pages/overview.html @@ -73,6 +73,12 @@ {% translate "Installment" %} +