This commit is contained in:
Herculino Trotta
2024-10-09 00:31:21 -03:00
parent e78e4cc5e1
commit 3dde44b1cd
139 changed files with 4965 additions and 1004 deletions

View File

@@ -1,6 +1,11 @@
from django.contrib import admin
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
InstallmentPlan,
)
@admin.register(Transaction)
@@ -16,5 +21,17 @@ class TransactionModelAdmin(admin.ModelAdmin):
]
class TransactionInline(admin.TabularInline):
model = Transaction
extra = 0
@admin.register(InstallmentPlan)
class InstallmentPlanAdmin(admin.ModelAdmin):
inlines = [
TransactionInline,
]
admin.site.register(TransactionCategory)
admin.site.register(TransactionTag)

View File

@@ -0,0 +1,108 @@
import django_filters
from crispy_bootstrap5.bootstrap5 import Switch
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
from apps.transactions.models import Transaction
from apps.transactions.models import TransactionCategory
from apps.transactions.models import TransactionTag
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
SITUACAO_CHOICES = (
("1", _("Paid")),
("0", _("Projected")),
)
def content_filter(queryset, name, value):
queryset = queryset.filter(
Q(description__icontains=value) | Q(notes__icontains=value)
)
return queryset
class TransactionsFilter(django_filters.FilterSet):
description = django_filters.CharFilter(
label=_("Content"),
method=content_filter,
widget=forms.TextInput(attrs={"type": "search"}),
)
type = django_filters.MultipleChoiceFilter(
choices=Transaction.Type.choices,
label=_("Transaction Type"),
)
account = django_filters.ModelMultipleChoiceFilter(
field_name="account__name",
queryset=Account.objects.all(),
to_field_name="name",
label="Accounts",
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
)
category = django_filters.ModelMultipleChoiceFilter(
field_name="category__name",
queryset=TransactionCategory.objects.all(),
to_field_name="name",
label="Categories",
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
)
tags = django_filters.ModelMultipleChoiceFilter(
field_name="tags__name",
queryset=TransactionTag.objects.all(),
to_field_name="name",
label="Tags",
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
)
is_paid = django_filters.MultipleChoiceFilter(
choices=SITUACAO_CHOICES,
field_name="is_paid",
label="Situação",
)
class Meta:
model = Transaction
fields = [
"description",
"type",
"account",
"is_paid",
"category",
"tags",
]
def __init__(self, data=None, *args, **kwargs):
# if filterset is bound, use initial values as defaults
if data is not None:
# get a mutable copy of the QueryDict
data = data.copy()
# # set type to all if it isn't set
if data.get("type") is None:
data.setlist("type", ["IN", "EX"])
if data.get("is_paid") is None:
data.setlist("is_paid", ["1", "0"])
super().__init__(data, *args, **kwargs)
self.form.helper = FormHelper()
self.form.helper.form_tag = False
self.form.helper.form_method = "GET"
self.form.helper.disable_csrf = True
self.form.helper.layout = Layout(
Field(
"type",
template="transactions/widgets/transaction_type_filter_buttons.html",
),
Field(
"is_paid",
template="transactions/widgets/transaction_type_filter_buttons.html",
),
Field("description"),
Field("account", size=1),
Field("category", size=1),
Field("tags", size=1),
)

View File

@@ -1,12 +1,26 @@
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, Fieldset
from dateutil.relativedelta import relativedelta
from django import forms
from django.db import transaction
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit, Row, Column, Div, Field, Hidden
from apps.accounts.models import Account
from .models import Transaction, TransactionCategory, TransactionTag
from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
)
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
InstallmentPlan,
)
from apps.transactions.widgets import (
ArbitraryDecimalDisplayNumberInput,
MonthYearWidget,
@@ -14,6 +28,19 @@ from apps.transactions.widgets import (
class TransactionForm(forms.ModelForm):
category = DynamicModelChoiceField(
model=TransactionCategory,
required=False,
label=_("Category"),
)
tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
)
class Meta:
model = Transaction
fields = [
@@ -31,20 +58,21 @@ class TransactionForm(forms.ModelForm):
widgets = {
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"notes": forms.Textarea(attrs={"rows": 3}),
"account": TomSelect(),
}
labels = {
"tags": mark_safe('<i class="fa-solid fa-hashtag me-1"></i>' + _("Tags")),
"category": mark_safe(
'<i class="fa-solid fa-icons me-1"></i>' + _("Category")
),
"notes": mark_safe(
'<i class="fa-solid fa-align-justify me-1"></i>' + _("Notes")
),
"amount": mark_safe('<i class="fa-solid fa-coins me-1"></i>' + _("Amount")),
"description": mark_safe(
'<i class="fa-solid fa-quote-left me-1"></i>' + _("Name")
),
}
# labels = {
# "tags": mark_safe('<i class="fa-solid fa-hashtag me-1"></i>' + _("Tags")),
# "category": mark_safe(
# '<i class="fa-solid fa-icons me-1"></i>' + _("Category")
# ),
# "notes": mark_safe(
# '<i class="fa-solid fa-align-justify me-1"></i>' + _("Notes")
# ),
# "amount": mark_safe('<i class="fa-solid fa-coins me-1"></i>' + _("Amount")),
# "description": mark_safe(
# '<i class="fa-solid fa-quote-left me-1"></i>' + _("Name")
# ),
# }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -66,28 +94,59 @@ class TransactionForm(forms.ModelForm):
),
"description",
Field("amount", inputmode="decimal"),
Field("category", css_class="select"),
Field("tags", css_class="multiselect", size=1),
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",
),
"notes",
Submit("submit", "Save", css_class="btn btn-warning"),
)
self.fields["reference_date"].required = False
if self.instance and self.instance.pk:
decimal_places = self.instance.account.currency.decimal_places
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
decimal_places=decimal_places
)
else:
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
decimal_places=2
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
def clean(self):
cleaned_data = super().clean()
date = cleaned_data.get("date")
reference_date = cleaned_data.get("reference_date")
if date and not reference_date:
cleaned_data["reference_date"] = date.replace(day=1)
return cleaned_data
class TransferForm(forms.Form):
from_account = forms.ModelChoiceField(
queryset=Account.objects.all(), label="From Account"
queryset=Account.objects.all(),
label="From Account",
widget=TomSelect(),
)
to_account = forms.ModelChoiceField(
queryset=Account.objects.all(), label="To Account"
queryset=Account.objects.all(),
label="To Account",
widget=TomSelect(),
)
from_amount = forms.DecimalField(
@@ -102,20 +161,30 @@ class TransferForm(forms.Form):
required=False,
)
from_category = forms.ModelChoiceField(
queryset=TransactionCategory.objects.all(),
from_category = DynamicModelChoiceField(
model=TransactionCategory,
required=False,
label="From Category",
label=_("Category"),
)
to_category = forms.ModelChoiceField(
queryset=TransactionCategory.objects.all(), required=False, label="To Category"
to_category = DynamicModelChoiceField(
model=TransactionCategory,
required=False,
label=_("Category"),
)
from_tags = forms.ModelMultipleChoiceField(
queryset=TransactionTag.objects.all(), required=False, label="From Tags"
from_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
)
to_tags = forms.ModelMultipleChoiceField(
queryset=TransactionTag.objects.all(), required=False, label="To Tags"
to_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
)
date = forms.DateField(
@@ -133,16 +202,25 @@ class TransferForm(forms.Form):
self.helper.layout = Layout(
Row(
Column("date", css_class="form-group col-md-6 mb-0"),
Column("reference_date", css_class="form-group col-md-6 mb-0"),
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
Column(
Field("reference_date"),
css_class="form-group col-md-6 mb-0",
),
css_class="form-row",
),
"description",
Field("description"),
Row(
Column(
Row(
Column("from_account", css_class="form-group col-md-6 mb-0"),
Column("from_amount", css_class="form-group col-md-6 mb-0"),
Column(
"from_account",
css_class="form-group col-md-6 mb-0",
),
Column(
Field("from_amount"),
css_class="form-group col-md-6 mb-0",
),
css_class="form-row",
),
Row(
@@ -156,8 +234,14 @@ class TransferForm(forms.Form):
Row(
Column(
Row(
Column("to_account", css_class="form-group col-md-6 mb-0"),
Column("to_amount", css_class="form-group col-md-6 mb-0"),
Column(
"to_account",
css_class="form-group col-md-6 mb-0",
),
Column(
Field("to_amount"),
css_class="form-group col-md-6 mb-0",
),
css_class="form-row",
),
Row(
@@ -168,16 +252,16 @@ class TransferForm(forms.Form):
),
css_class="p-1 mx-1 my-3 border rounded-3",
),
Submit("submit", "Save", css_class="btn btn-primary"),
FormActions(
NoClassSubmit(
"submit", _("Tranfer"), css_class="btn btn-outline-primary w-100"
),
),
)
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput(
decimal_places=2
)
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput(
decimal_places=2
)
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
def clean(self):
cleaned_data = super().clean()
@@ -227,3 +311,195 @@ class TransferForm(forms.Form):
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
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"),
)
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")),
),
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("start_date", css_class="form-group col-md-4 mb-0"),
Column("number_of_installments", css_class="form-group col-md-4 mb-0"),
Column("recurrence", css_class="form-group col-md-4 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"]
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"]
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
new_transaction = Transaction.objects.create(
account=account,
type=transaction_type,
date=transaction_date,
reference_date=transaction_date.replace(day=1),
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 TransactionTagForm(forms.ModelForm):
class Meta:
model = TransactionTag
fields = ["name"]
labels = {"name": _("Tag name")}
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("name", css_class="mb-3"))
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"
),
),
)
class TransactionCategoryForm(forms.ModelForm):
class Meta:
model = TransactionCategory
fields = ["name", "mute"]
labels = {"name": _("Category name")}
help_texts = {
"mute": _("Muted categories won't count towards your monthly total")
}
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("name", css_class="mb-3"), Switch("mute"))
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"
),
),
)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-09-27 19:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0008_rename_transactiontags_transactiontag'),
]
operations = [
migrations.AlterField(
model_name='transaction',
name='tags',
field=models.ManyToManyField(blank=True, to='transactions.transactiontag', verbose_name='Tags'),
),
]

View File

@@ -0,0 +1,79 @@
# Generated by Django 5.1.1 on 2024-10-05 02:15
import apps.transactions.validators
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_account_exchange_currency"),
("transactions", "0009_alter_transaction_tags"),
]
operations = [
migrations.CreateModel(
name="InstallmentPlan",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("start_date", models.DateField(verbose_name="Start Date")),
(
"description",
models.CharField(max_length=500, verbose_name="Description"),
),
(
"number_of_installments",
models.PositiveIntegerField(
validators=[django.core.validators.MinValueValidator(1)],
verbose_name="Number of Installments",
),
),
(
"total_amount",
models.DecimalField(
decimal_places=30,
max_digits=42,
validators=[
apps.transactions.validators.validate_non_negative,
apps.transactions.validators.validate_decimal_places,
],
verbose_name="Total Amount",
),
),
(
"account",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="accounts.account",
verbose_name="Account",
),
),
],
options={
"verbose_name": "Installment Plan",
"verbose_name_plural": "Installment Plans",
},
),
migrations.AddField(
model_name="transaction",
name="installment_plan",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="transactions",
to="transactions.installmentplan",
verbose_name="Installment Plan",
),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.1.1 on 2024-10-05 16:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("transactions", "0010_installmentplan_transaction_installment_plan"),
]
operations = [
migrations.RemoveField(
model_name="installmentplan",
name="start_date",
),
migrations.RemoveField(
model_name="installmentplan",
name="total_amount",
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.1 on 2024-10-06 22:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("transactions", "0011_remove_installmentplan_start_date_and_more"),
]
operations = [
migrations.AlterField(
model_name="transaction",
name="category",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="transactions.transactioncategory",
verbose_name="Category",
),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.1 on 2024-10-07 14:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("transactions", "0012_alter_transaction_category"),
]
operations = [
migrations.AlterField(
model_name="transactioncategory",
name="name",
field=models.CharField(max_length=255, unique=True, verbose_name="Name"),
),
migrations.AlterField(
model_name="transactiontag",
name="name",
field=models.CharField(max_length=255, unique=True, verbose_name="Name"),
),
]

View File

@@ -1,14 +1,18 @@
from dateutil.relativedelta import relativedelta
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from apps.common.functions.decimals import truncate_decimal
from apps.transactions.fields import MonthYearField
from apps.transactions.validators import validate_decimal_places, validate_non_negative
from apps.currencies.utils.convert import convert
class TransactionCategory(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"))
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
class Meta:
@@ -21,7 +25,7 @@ class TransactionCategory(models.Model):
class TransactionTag(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"))
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
class Meta:
verbose_name = _("Transaction Tags")
@@ -32,6 +36,30 @@ 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")
@@ -61,13 +89,22 @@ class Transaction(models.Model):
notes = models.TextField(blank=True, verbose_name=_("Notes"))
category = models.ForeignKey(
TransactionCategory,
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
verbose_name=_("Category"),
blank=True,
null=True,
)
tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True)
installment_plan = models.ForeignKey(
InstallmentPlan,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="transactions",
verbose_name=_("Installment Plan"),
)
class Meta:
verbose_name = _("Transaction")
verbose_name_plural = _("Transactions")
@@ -79,3 +116,21 @@ class Transaction(models.Model):
)
self.full_clean()
super().save(*args, **kwargs)
def exchanged_amount(self):
if self.account.exchange_currency:
converted_amount = convert(
self.amount,
self.account.exchange_currency,
self.account.currency,
date=self.date,
)
if converted_amount:
return {
"amount": converted_amount,
"suffix": self.account.exchange_currency.suffix,
"prefix": self.account.exchange_currency.prefix,
"decimal_places": self.account.exchange_currency.decimal_places,
}
return None

View File

@@ -1,5 +1,6 @@
from django import template
from django.template.defaultfilters import floatformat
from django.utils.formats import number_format
from apps.transactions.models import Transaction
@@ -7,7 +8,9 @@ register = template.Library()
def _format_string(prefix, amount, decimal_places, suffix):
formatted_amount = floatformat(abs(amount), f"{decimal_places}g")
formatted_amount = number_format(
value=abs(amount), decimal_pos=decimal_places, force_grouping=True
)
if amount < 0:
return f"-{prefix}{formatted_amount}{suffix}"
else:
@@ -32,3 +35,8 @@ def entry_currency(entry):
suffix = entry["suffix"]
return _format_string(prefix, amount, decimal_places, suffix)
@register.simple_tag(name="currency_display")
def currency_display(amount, prefix, suffix, decimal_places):
return _format_string(prefix, amount, decimal_places, suffix)

View File

@@ -3,22 +3,6 @@ from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
path(
"transactions/<int:month>/<int:year>/",
views.transactions_overview,
name="transactions_overview",
),
path(
"transactions/<int:month>/<int:year>/list/",
views.transactions_list,
name="transactions_list",
),
path(
"transactions/<int:month>/<int:year>/summary/",
views.monthly_summary,
name="monthly_summary",
),
path(
"transaction/<int:transaction_id>/pay",
views.transaction_pay,
@@ -39,14 +23,38 @@ urlpatterns = [
views.transaction_add,
name="transaction_add",
),
path(
"available_dates/",
views.month_year_picker,
name="available_dates",
),
path(
"transactions/transfer",
views.transactions_transfer,
name="transactions_transfer",
),
path(
"transactions/installments/add/",
views.AddInstallmentPlanView.as_view(),
name="installments_add",
),
path("tags/", views.tag_list, name="tags_list"),
path("tags/add/", views.tag_add, name="tag_add"),
path(
"tags/<int:tag_id>/edit/",
views.tag_edit,
name="tag_edit",
),
path(
"tags/<int:tag_id>/delete/",
views.tag_delete,
name="tag_delete",
),
path("categories/", views.categories_list, name="categories_list"),
path("categories/add/", views.category_add, name="category_add"),
path(
"categories/<int:category_id>/edit/",
views.category_edit,
name="category_edit",
),
path(
"categories/<int:category_id>/delete/",
views.category_delete,
name="category_delete",
),
]

View File

View File

@@ -0,0 +1,108 @@
from decimal import Decimal
from django.db.models import Sum
def calculate_sum(queryset, transaction_type, is_paid):
return (
queryset.filter(type=transaction_type, is_paid=is_paid)
.values(
"account__currency__name",
"account__currency__suffix",
"account__currency__prefix",
"account__currency__decimal_places",
)
.annotate(total=Sum("amount"))
.order_by("account__currency__name")
)
# Helper function to format currency sums
def format_currency_sum(queryset):
return [
{
"currency": item["account__currency__name"],
"suffix": item["account__currency__suffix"],
"prefix": item["account__currency__prefix"],
"decimal_places": item["account__currency__decimal_places"],
"amount": item["total"],
}
for item in queryset
]
# Calculate totals
def calculate_total(income, expenses):
totals = {}
# Process income
for item in income:
currency = item["account__currency__name"]
totals[currency] = totals.get(currency, Decimal("0")) + item["total"]
# Subtract expenses
for item in expenses:
currency = item["account__currency__name"]
totals[currency] = totals.get(currency, Decimal("0")) - item["total"]
return [
{
"currency": currency,
"suffix": next(
(
item["account__currency__suffix"]
for item in list(income) + list(expenses)
if item["account__currency__name"] == currency
),
"",
),
"prefix": next(
(
item["account__currency__prefix"]
for item in list(income) + list(expenses)
if item["account__currency__name"] == currency
),
"",
),
"decimal_places": next(
(
item["account__currency__decimal_places"]
for item in list(income) + list(expenses)
if item["account__currency__name"] == currency
),
2,
),
"amount": amount,
}
for currency, amount in totals.items()
]
# Calculate total final
def sum_totals(total1, total2):
totals = {}
for item in total1 + total2:
currency = item["currency"]
totals[currency] = totals.get(currency, Decimal("0")) + item["amount"]
return [
{
"currency": currency,
"suffix": next(
item["suffix"]
for item in total1 + total2
if item["currency"] == currency
),
"prefix": next(
item["prefix"]
for item in total1 + total2
if item["currency"] == currency
),
"decimal_places": next(
item["decimal_places"]
for item in total1 + total2
if item["currency"] == currency
),
"amount": amount,
}
for currency, amount in totals.items()
]

View File

@@ -1,419 +0,0 @@
import datetime
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, redirect, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.db.models import Sum, Q, F, Case, When, DecimalField
from django.db.models.functions import Coalesce
from decimal import Decimal
from apps.transactions.forms import TransactionForm, TransferForm
from apps.transactions.models import Transaction
from apps.common.functions.dates import remaining_days_in_month
@login_required
def index(request):
now = timezone.localdate(timezone.now())
return redirect(to="transactions_overview", month=now.month, year=now.year)
@login_required
def transactions_overview(request, month: int, year: int):
from django.utils.formats import get_format
from django.utils.translation import get_language
current_language = get_language()
thousand_separator = get_format("THOUSAND_SEPARATOR")
print(thousand_separator, current_language)
if month < 1 or month > 12:
from django.http import Http404
raise Http404("Month is out of range")
next_month = 1 if month == 12 else month + 1
next_year = year + 1 if next_month == 1 and month == 12 else year
previous_month = 12 if month == 1 else month - 1
previous_year = year - 1 if previous_month == 12 and month == 1 else year
return render(
request,
"transactions/overview.html",
context={
"month": month,
"year": year,
"next_month": next_month,
"next_year": next_year,
"previous_month": previous_month,
"previous_year": previous_year,
},
)
@login_required
def transactions_list(request, month: int, year: int):
from django.db.models.functions import ExtractMonth, ExtractYear
queryset = (
Transaction.objects.annotate(
month=ExtractMonth("reference_date"), year=ExtractYear("reference_date")
)
.values("month", "year")
.distinct()
.order_by("year", "month")
)
# print(queryset)
transactions = (
Transaction.objects.all()
.filter(
reference_date__year=year,
reference_date__month=month,
)
.order_by("date", "id")
.select_related()
)
return render(
request,
"transactions/fragments/list.html",
context={"transactions": transactions},
)
@login_required
def transaction_add(request, **kwargs):
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
transaction_type = Transaction.Type(request.GET.get("type", "IN"))
now = timezone.localdate(timezone.now())
expected_date = datetime.datetime(
day=now.day if month == now.month and year == now.year else 1,
month=month,
year=year,
).date()
if request.method == "POST":
form = TransactionForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Transaction added successfully!"))
# redirect to a new URL:
return HttpResponse(
status=204,
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
)
else:
form = TransactionForm(
initial={
"reference_date": expected_date,
"date": expected_date,
"type": transaction_type,
}
)
return render(
request,
"transactions/fragments/add.html",
{"form": form},
)
@login_required
def transaction_edit(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id)
if request.method == "POST":
form = TransactionForm(request.POST, instance=transaction)
if form.is_valid():
form.save()
messages.success(request, _("Transaction updated successfully!"))
# redirect to a new URL:
return HttpResponse(
status=204,
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
)
else:
form = TransactionForm(instance=transaction)
return render(
request,
"transactions/fragments/edit.html",
{"form": form, "transaction": transaction},
)
@login_required
def transaction_delete(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id)
transaction.delete()
messages.success(request, _("Transaction deleted successfully!"))
return HttpResponse(
status=204,
headers={"HX-Trigger": "transaction_updated, toast"},
)
@login_required
def transactions_transfer(request):
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
now = timezone.localdate(timezone.now())
expected_date = datetime.datetime(
day=now.day if month == now.month and year == now.year else 1,
month=month,
year=year,
).date()
if request.method == "POST":
form = TransferForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Transfer added successfully."))
return HttpResponse(
status=204,
headers={"HX-Trigger": "transaction_updated, toast, hide_offcanvas"},
)
else:
form = TransferForm(
initial={
"reference_date": expected_date,
"date": expected_date,
}
)
return render(request, "transactions/fragments/transfer.html", {"form": form})
@login_required
def transaction_pay(request, transaction_id):
transaction = get_object_or_404(Transaction, pk=transaction_id)
new_is_paid = False if transaction.is_paid else True
transaction.is_paid = new_is_paid
transaction.save()
response = render(
request,
"transactions/fragments/item.html",
context={"transaction": transaction},
)
response.headers["HX-Trigger"] = (
f'{"paid" if new_is_paid else "unpaid"}, transaction_updated'
)
return response
@login_required
def month_year_picker(request):
current_month = int(
request.GET.get("month", timezone.localdate(timezone.now()).month)
)
current_year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
available_years = Transaction.objects.dates(
"reference_date", "year", order="ASC"
) or [datetime.datetime(current_year, current_month, 1)]
return render(
request,
"transactions/fragments/month_year_picker.html",
{
"available_years": available_years,
"months": range(1, 13),
"current_month": current_month,
"current_year": current_year,
},
)
@login_required
def monthly_summary(request, month: int, year: int):
# Helper function to calculate sums for different transaction types
def calculate_sum(transaction_type, is_paid):
return (
base_queryset.filter(type=transaction_type, is_paid=is_paid)
.values(
"account__currency__name",
"account__currency__suffix",
"account__currency__prefix",
"account__currency__decimal_places",
)
.annotate(total=Sum("amount"))
.order_by("account__currency__name")
)
# Helper function to format currency sums
def format_currency_sum(queryset):
return [
{
"currency": item["account__currency__name"],
"suffix": item["account__currency__suffix"],
"prefix": item["account__currency__prefix"],
"decimal_places": item["account__currency__decimal_places"],
"amount": round(
item["total"], item["account__currency__decimal_places"]
),
}
for item in queryset
]
# Calculate totals
def calculate_total(income, expenses):
totals = {}
# Process income
for item in income:
currency = item["account__currency__name"]
totals[currency] = totals.get(currency, Decimal("0")) + item["total"]
# Subtract expenses
for item in expenses:
currency = item["account__currency__name"]
totals[currency] = totals.get(currency, Decimal("0")) - item["total"]
return [
{
"currency": currency,
"suffix": next(
(
item["account__currency__suffix"]
for item in list(income) + list(expenses)
if item["account__currency__name"] == currency
),
"",
),
"prefix": next(
(
item["account__currency__prefix"]
for item in list(income) + list(expenses)
if item["account__currency__name"] == currency
),
"",
),
"decimal_places": next(
(
item["account__currency__decimal_places"]
for item in list(income) + list(expenses)
if item["account__currency__name"] == currency
),
2,
),
"amount": round(
amount,
next(
(
item["account__currency__decimal_places"]
for item in list(income) + list(expenses)
if item["account__currency__name"] == currency
),
2,
),
),
}
for currency, amount in totals.items()
]
# Calculate total final
def sum_totals(total1, total2):
totals = {}
for item in total1 + total2:
currency = item["currency"]
totals[currency] = totals.get(currency, Decimal("0")) + item["amount"]
return [
{
"currency": currency,
"suffix": next(
item["suffix"]
for item in total1 + total2
if item["currency"] == currency
),
"prefix": next(
item["prefix"]
for item in total1 + total2
if item["currency"] == currency
),
"decimal_places": next(
item["decimal_places"]
for item in total1 + total2
if item["currency"] == currency
),
"amount": round(
amount,
next(
item["decimal_places"]
for item in total1 + total2
if item["currency"] == currency
),
),
}
for currency, amount in totals.items()
]
# Base queryset with all required filters
base_queryset = Transaction.objects.filter(
reference_date__year=year, reference_date__month=month, account__is_asset=False
).exclude(Q(category__mute=True) & ~Q(category=None))
# Calculate sums for different transaction types
paid_income = calculate_sum(Transaction.Type.INCOME, True)
projected_income = calculate_sum(Transaction.Type.INCOME, False)
paid_expenses = calculate_sum(Transaction.Type.EXPENSE, True)
projected_expenses = calculate_sum(Transaction.Type.EXPENSE, False)
total_current = calculate_total(paid_income, paid_expenses)
total_projected = calculate_total(projected_income, projected_expenses)
total_final = sum_totals(total_current, total_projected)
# Calculate daily spending allowance
remaining_days = remaining_days_in_month(
month=month, year=year, current_date=timezone.localdate(timezone.now())
)
daily_spending_allowance = [
{
"currency": item["currency"],
"suffix": item["suffix"],
"prefix": item["prefix"],
"decimal_places": item["decimal_places"],
"amount": (
amount
if (amount := item["amount"] / remaining_days) > 0
else Decimal("0")
),
}
for item in total_final
]
# Construct the response dictionary
response_data = {
"paid_income": format_currency_sum(paid_income),
"projected_income": format_currency_sum(projected_income),
"paid_expenses": format_currency_sum(paid_expenses),
"projected_expenses": format_currency_sum(projected_expenses),
"total_current": total_current,
"total_projected": total_projected,
"total_final": total_final,
"daily_spending_allowance": daily_spending_allowance,
}
return render(
request,
"transactions/fragments/monthly_summary.html",
context={
"totals": response_data,
},
)

View File

@@ -0,0 +1,3 @@
from .transactions import *
from .tags import *
from .categories import *

View File

@@ -0,0 +1,92 @@
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.urls import reverse
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 TransactionCategoryForm
from apps.transactions.models import TransactionCategory
@login_required
@require_http_methods(["GET"])
def categories_list(request):
categories = TransactionCategory.objects.all().order_by("id")
return render(request, "categories/pages/list.html", {"categories": categories})
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def category_add(request, **kwargs):
if request.method == "POST":
form = TransactionCategoryForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Category added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("categories_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = TransactionCategoryForm()
return render(
request,
"categories/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def category_edit(request, category_id):
category = get_object_or_404(TransactionCategory, id=category_id)
if request.method == "POST":
form = TransactionCategoryForm(request.POST, instance=category)
if form.is_valid():
form.save()
messages.success(request, _("Category updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("categories_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = TransactionCategoryForm(instance=category)
return render(
request,
"categories/fragments/edit.html",
{"form": form, "category": category},
)
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def category_delete(request, category_id):
category = get_object_or_404(TransactionCategory, id=category_id)
category.delete()
messages.success(request, _("Category deleted successfully"))
return HttpResponse(
status=204,
headers={"HX-Location": reverse("categories_list")},
)

View File

@@ -0,0 +1,92 @@
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.urls import reverse
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 TransactionTagForm
from apps.transactions.models import TransactionTag
@login_required
@require_http_methods(["GET"])
def tag_list(request):
tags = TransactionTag.objects.all().order_by("id")
return render(request, "tags/pages/list.html", {"tags": tags})
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def tag_add(request, **kwargs):
if request.method == "POST":
form = TransactionTagForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Tag added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("tags_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = TransactionTagForm()
return render(
request,
"tags/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def tag_edit(request, tag_id):
tag = get_object_or_404(TransactionTag, id=tag_id)
if request.method == "POST":
form = TransactionTagForm(request.POST, instance=tag)
if form.is_valid():
form.save()
messages.success(request, _("Tag updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("tags_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = TransactionTagForm(instance=tag)
return render(
request,
"tags/fragments/edit.html",
{"form": form, "tag": tag},
)
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def tag_delete(request, tag_id):
tag = get_object_or_404(TransactionTag, id=tag_id)
tag.delete()
messages.success(request, _("Tag deleted successfully"))
return HttpResponse(
status=204,
headers={"HX-Location": reverse("tags_list")},
)

View File

@@ -0,0 +1,181 @@
import datetime
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 import timezone
from django.utils.translation import gettext_lazy as _
from django.views import View
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 TransactionForm, TransferForm, InstallmentPlanForm
from apps.transactions.models import Transaction
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def transaction_add(request, **kwargs):
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
transaction_type = Transaction.Type(request.GET.get("type", "IN"))
now = timezone.localdate(timezone.now())
expected_date = datetime.datetime(
day=now.day if month == now.month and year == now.year else 1,
month=month,
year=year,
).date()
if request.method == "POST":
form = TransactionForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Transaction added successfully"))
return HttpResponse(
status=204,
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
)
else:
form = TransactionForm(
initial={
"date": expected_date,
"type": transaction_type,
}
)
return render(
request,
"transactions/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def transaction_edit(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id)
if request.method == "POST":
form = TransactionForm(request.POST, instance=transaction)
if form.is_valid():
form.save()
messages.success(request, _("Transaction updated successfully"))
return HttpResponse(
status=204,
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
)
else:
form = TransactionForm(instance=transaction)
return render(
request,
"transactions/fragments/edit.html",
{"form": form, "transaction": transaction},
)
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def transaction_delete(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id)
if transaction.installment_plan:
messages.error(
request,
_(
"This transaction is part of a Installment Plan, you can't delete it directly."
),
)
else:
transaction.delete()
messages.success(request, _("Transaction deleted successfully"))
return HttpResponse(
status=204,
headers={"HX-Trigger": "transaction_updated, toast"},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def transactions_transfer(request):
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
now = timezone.localdate(timezone.now())
expected_date = datetime.datetime(
day=now.day if month == now.month and year == now.year else 1,
month=month,
year=year,
).date()
if request.method == "POST":
form = TransferForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Transfer added successfully."))
return HttpResponse(
status=204,
headers={"HX-Trigger": "transaction_updated, toast, hide_offcanvas"},
)
else:
form = TransferForm(
initial={
"reference_date": expected_date,
"date": expected_date,
}
)
return render(request, "transactions/fragments/transfer.html", {"form": form})
@only_htmx
@login_required
@require_http_methods(["GET"])
def transaction_pay(request, transaction_id):
transaction = get_object_or_404(Transaction, pk=transaction_id)
new_is_paid = False if transaction.is_paid else True
transaction.is_paid = new_is_paid
transaction.save()
response = render(
request,
"transactions/fragments/item.html",
context={"transaction": transaction},
)
response.headers["HX-Trigger"] = (
f'{"paid" if new_is_paid else "unpaid"}, monthly_summary_update'
)
return response
class AddInstallmentPlanView(View):
template_name = "transactions/fragments/add_installment_plan.html"
def get(self, request):
form = InstallmentPlanForm()
return render(request, self.template_name, {"form": form})
def post(self, request):
form = InstallmentPlanForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Installment plan created successfully."))
return HttpResponse(
status=204,
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
)
return render(request, self.template_name, {"form": form})

View File

@@ -1,13 +1,13 @@
from datetime import datetime, date
from django import forms
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from django.template.defaultfilters import floatformat
from django.utils.formats import get_format
from django.utils.formats import get_format, number_format
def convert_to_decimal(value):
def convert_to_decimal(value: str):
# Remove any whitespace
value = value.strip()
@@ -41,8 +41,9 @@ class MonthYearWidget(forms.DateInput):
class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
"""A widget for displaying and inputing decimal numbers with the least amount of trailing zeros possible. You
must set this on your Form's __init__ method."""
def __init__(self, *args, **kwargs):
self.decimal_places = kwargs.pop("decimal_places", 2)
self.decimal_places = kwargs.pop("decimal_places", None)
self.type = "text"
super().__init__(*args, **kwargs)
self.attrs.update(
@@ -53,15 +54,28 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
)
def format_value(self, value):
if value is not None and isinstance(value, Decimal):
# Strip trailing 0s, leaving a minimum of 2 decimal places
while (
abs(value.as_tuple().exponent) > self.decimal_places
and value.as_tuple().digits[-1] == 0
):
value = Decimal(str(value)[:-1])
if value is not None and isinstance(value, (Decimal, float, str)):
try:
# Convert to Decimal if it's a float or string
if isinstance(value, float):
value = Decimal(value)
elif isinstance(value, str):
value = Decimal(convert_to_decimal(value))
value = floatformat(value, f"{self.decimal_places}g")
# Remove trailing zeros
value = value.normalize()
# Format the number using Django's localization
formatted_value = number_format(
value,
force_grouping=False,
decimal_pos=self.decimal_places,
)
return formatted_value
except (InvalidOperation, ValueError):
# If there's an error in conversion, return the original value
return value
return value
def value_from_datadict(self, data, files, name):