initial commit

This commit is contained in:
Herculino Trotta
2024-09-26 11:00:40 -03:00
parent 830e821a17
commit 50b0c6ce01
138 changed files with 13566 additions and 46 deletions

View File

View File

@@ -0,0 +1,20 @@
from django.contrib import admin
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag
@admin.register(Transaction)
class TransactionModelAdmin(admin.ModelAdmin):
list_display = [
"description",
"type",
"account__name",
"amount",
"account__currency__code",
"date",
"reference_date",
]
admin.site.register(TransactionCategory)
admin.site.register(TransactionTag)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TransactionsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.transactions"

View File

@@ -0,0 +1,46 @@
import datetime
from django import forms
from django.db import models
from django.core.exceptions import ValidationError
from apps.transactions.widgets import MonthYearWidget
class MonthYearField(models.DateField):
def to_python(self, value):
if value is None or isinstance(value, datetime.date):
return value
try:
# Parse the input as year-month
date = datetime.datetime.strptime(value, "%Y-%m")
# Set the day to 1
return date.replace(day=1).date()
except ValueError:
raise ValidationError("Invalid date format. Use YYYY-MM.")
def formfield(self, **kwargs):
kwargs["widget"] = MonthYearWidget
kwargs["form_class"] = MonthYearFormField
return super().formfield(**kwargs)
class MonthYearFormField(forms.DateField):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.input_formats = ["%Y-%m"]
def to_python(self, value):
if value in self.empty_values:
return None
try:
date = datetime.datetime.strptime(value, "%Y-%m")
return date.replace(day=1).date()
except ValueError:
raise ValidationError("Invalid date format. Use YYYY-MM.")
def prepare_value(self, value):
if isinstance(value, datetime.date):
return value.strftime("%Y-%m")
return value

View File

@@ -0,0 +1,75 @@
from crispy_bootstrap5.bootstrap5 import Switch
from django import forms
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 .models import Transaction
from apps.transactions.widgets import ArbitraryDecimalDisplayNumberInput
class TransactionForm(forms.ModelForm):
class Meta:
model = Transaction
fields = [
"account",
"type",
"is_paid",
"date",
"reference_date",
"amount",
"description",
"notes",
"category",
"tags",
]
widgets = {
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"notes": forms.Textarea(attrs={"rows": 3}),
}
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)
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",
),
Switch("is_paid"),
"account",
Row(
Column("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",
),
"description",
"amount",
Field("category", css_class="select"),
Field("tags", css_class="multiselect", size=1),
"notes",
Submit("submit", "Save", css_class="btn btn-primary"),
Submit("submit", "Save", css_class="btn btn-warning"),
)
if self.instance and self.instance.pk:
decimal_places = self.instance.account.currency.decimal_places
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
decimal_places=decimal_places
)

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.1.1 on 2024-09-19 02:11
import apps.transactions.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_paid', models.BooleanField(default=True)),
('date', models.DateField()),
('reference_date', apps.transactions.fields.MonthYearField(help_text='Please enter a month and year in the format MM/YYYY.')),
('description', models.CharField(max_length=500)),
('notes', models.TextField(blank=True)),
],
),
]

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.1.1 on 2024-09-19 13:35
import apps.transactions.fields
import apps.transactions.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
('transactions', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='account',
field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.PROTECT, to='accounts.account', verbose_name='Account'),
preserve_default=False,
),
migrations.AddField(
model_name='transaction',
name='amount',
field=models.DecimalField(decimal_places=18, default=0, max_digits=30, validators=[apps.transactions.validators.validate_non_negative, apps.transactions.validators.validate_decimal_places], verbose_name='Amount'),
preserve_default=False,
),
migrations.AlterField(
model_name='transaction',
name='date',
field=models.DateField(verbose_name='Date'),
),
migrations.AlterField(
model_name='transaction',
name='description',
field=models.CharField(max_length=500, verbose_name='Description'),
),
migrations.AlterField(
model_name='transaction',
name='is_paid',
field=models.BooleanField(default=True, verbose_name='Paid'),
),
migrations.AlterField(
model_name='transaction',
name='notes',
field=models.TextField(blank=True, verbose_name='Notes'),
),
migrations.AlterField(
model_name='transaction',
name='reference_date',
field=apps.transactions.fields.MonthYearField(verbose_name='Reference Date'),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.1 on 2024-09-20 03:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
('transactions', '0002_transaction_account_transaction_amount_and_more'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='type',
field=models.CharField(choices=[('IN', 'Income'), ('EX', 'Expense')], default='EX', max_length=2, verbose_name='Type'),
),
migrations.AlterField(
model_name='transaction',
name='account',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.account', verbose_name='Account'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.1 on 2024-09-23 04:05
import apps.transactions.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0003_transaction_type_alter_transaction_account'),
]
operations = [
migrations.AlterField(
model_name='transaction',
name='amount',
field=models.DecimalField(decimal_places=30, max_digits=42, validators=[apps.transactions.validators.validate_non_negative, apps.transactions.validators.validate_decimal_places], verbose_name='Amount'),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.1.1 on 2024-09-24 17:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0004_alter_transaction_amount'),
]
operations = [
migrations.CreateModel(
name='TransactionCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('mute', models.BooleanField(default=False, verbose_name='Mute')),
],
options={
'verbose_name': 'Transaction Category',
'verbose_name_plural': 'Transaction Categories',
'db_table': 'transaction_category',
},
),
migrations.AlterModelOptions(
name='transaction',
options={'verbose_name': 'Transaction', 'verbose_name_plural': 'Transactions'},
),
migrations.AlterModelTable(
name='transaction',
table='transactions',
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.1 on 2024-09-24 17:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0005_transactioncategory_alter_transaction_options_and_more'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='transactions.transactioncategory', verbose_name='Category'),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.1.1 on 2024-09-25 03:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0006_transaction_category'),
]
operations = [
migrations.CreateModel(
name='TransactionTags',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
],
options={
'verbose_name': 'Transaction Tags',
'verbose_name_plural': 'Transaction Tags',
'db_table': 'tags',
},
),
migrations.AlterModelTable(
name='transactioncategory',
table='t_categories',
),
migrations.AddField(
model_name='transaction',
name='tags',
field=models.ManyToManyField(to='transactions.transactiontags', verbose_name='Tags'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.1 on 2024-09-25 03:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('transactions', '0007_transactiontags_alter_transactioncategory_table_and_more'),
]
operations = [
migrations.RenameModel(
old_name='TransactionTags',
new_name='TransactionTag',
),
]

View File

@@ -0,0 +1,81 @@
from django.core.exceptions import ValidationError
from django.db import models
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
class TransactionCategory(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"))
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
class Meta:
verbose_name = _("Transaction Category")
verbose_name_plural = _("Transaction Categories")
db_table = "t_categories"
def __str__(self):
return self.name
class TransactionTag(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"))
class Meta:
verbose_name = _("Transaction Tags")
verbose_name_plural = _("Transaction Tags")
db_table = "tags"
def __str__(self):
return self.name
class Transaction(models.Model):
class Type(models.TextChoices):
INCOME = "IN", _("Income")
EXPENSE = "EX", _("Expense")
account = models.ForeignKey(
"accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account")
)
type = models.CharField(
max_length=2,
choices=Type,
default=Type.EXPENSE,
verbose_name=_("Type"),
)
is_paid = models.BooleanField(default=True, verbose_name=_("Paid"))
date = models.DateField(verbose_name=_("Date"))
reference_date = MonthYearField(verbose_name=_("Reference Date"))
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"))
notes = models.TextField(blank=True, verbose_name=_("Notes"))
category = models.ForeignKey(
TransactionCategory,
on_delete=models.CASCADE,
verbose_name=_("Category"),
blank=True,
null=True,
)
tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True)
class Meta:
verbose_name = _("Transaction")
verbose_name_plural = _("Transactions")
db_table = "transactions"
def save(self, *args, **kwargs):
self.amount = truncate_decimal(
value=self.amount, decimal_places=self.account.currency.decimal_places
)
self.full_clean()
super().save(*args, **kwargs)

View File

@@ -0,0 +1,31 @@
import datetime
from django import template
from django.template.defaultfilters import floatformat
from apps.transactions.models import Transaction
register = template.Library()
def _format_string(prefix, amount, decimal_places, suffix):
return f"{prefix}{floatformat(amount, decimal_places)}{suffix}"
@register.simple_tag(name="transaction_amount")
def transaction_currency(transaction: Transaction):
prefix = transaction.account.currency.prefix
amount = transaction.amount
decimal_places = transaction.account.currency.decimal_places
suffix = transaction.account.currency.suffix
return _format_string(prefix, amount, decimal_places, suffix)
@register.simple_tag(name="entry_amount")
def entry_currency(entry):
prefix = entry["prefix"]
amount = entry["total_amount"]
decimal_places = entry["decimal_places"]
suffix = entry["suffix"]
return _format_string(prefix, amount, decimal_places, suffix)

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,47 @@
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,
name="transaction_pay",
),
path(
"transaction/<int:transaction_id>/delete",
views.transaction_delete,
name="transaction_delete",
),
path(
"transaction/<int:transaction_id>/edit",
views.transaction_edit,
name="transaction_edit",
),
path(
"transaction/add",
views.transaction_add,
name="transaction_add",
),
path(
"available_dates/",
views.month_year_picker,
name="available_dates",
),
]

View File

@@ -0,0 +1,18 @@
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
def validate_decimal_places(value):
if abs(value.as_tuple().exponent) > 18:
raise ValidationError(
_("%(value)s has too many decimal places. Maximum is 18."),
params={"value": value},
)
def validate_non_negative(value):
if value < 0:
raise ValidationError(
_("%(value)s is not a non-negative number"),
params={"value": value},
)

View File

@@ -0,0 +1,387 @@
import datetime
from decimal import Decimal
from itertools import groupby
from operator import itemgetter
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Sum, F, Case, When, DecimalField, Value, Q, CharField
from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear
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 unicodedata import category
from apps.transactions.forms import TransactionForm
from apps.transactions.models import Transaction
@login_required
def index(request):
now = timezone.now()
return redirect(to="transactions_overview", month=now.month, year=now.year)
@login_required
def transactions_overview(request, month: int, year: int):
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
print(
Transaction.objects.annotate(
month=ExtractMonth("reference_date"), year=ExtractYear("reference_date")
)
.values("month", "year")
.distinct()
.order_by("year", "month")
)
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 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 monthly_summary(request, month: int, year: int):
queryset = (
Transaction.objects.filter(
Q(category__mute=False) | Q(category__isnull=True),
account__is_asset=False,
reference_date__year=year,
reference_date__month=month,
)
.annotate(
transaction_type=Value("expense", output_field=CharField()),
is_paid_status=Value("paid", output_field=CharField()),
)
.filter(type=Transaction.Type.EXPENSE, is_paid=True)
.values(
"account__currency__name",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__decimal_places",
"transaction_type",
"is_paid_status",
)
.annotate(
total_amount=Coalesce(
Sum("amount"),
0,
output_field=DecimalField(max_digits=30, decimal_places=18),
)
)
.union(
Transaction.objects.filter(
Q(category__mute=False) | Q(category__isnull=True),
account__is_asset=False,
reference_date__year=year,
reference_date__month=month,
)
.annotate(
transaction_type=Value("expense", output_field=CharField()),
is_paid_status=Value("projected", output_field=CharField()),
)
.filter(type=Transaction.Type.EXPENSE, is_paid=False)
.values(
"account__currency__name",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__decimal_places",
"transaction_type",
"is_paid_status",
)
.annotate(
total_amount=Coalesce(
Sum("amount"),
0,
output_field=DecimalField(max_digits=30, decimal_places=18),
)
)
)
.union(
Transaction.objects.filter(
Q(category__mute=False) | Q(category__isnull=True),
account__is_asset=False,
reference_date__year=year,
reference_date__month=month,
)
.annotate(
transaction_type=Value("income", output_field=CharField()),
is_paid_status=Value("paid", output_field=CharField()),
)
.filter(type=Transaction.Type.INCOME, is_paid=True)
.values(
"account__currency__name",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__decimal_places",
"transaction_type",
"is_paid_status",
)
.annotate(
total_amount=Coalesce(
Sum("amount"),
0,
output_field=DecimalField(max_digits=30, decimal_places=18),
)
)
)
.union(
Transaction.objects.filter(
Q(category__mute=False) | Q(category__isnull=True),
account__is_asset=False,
reference_date__year=year,
reference_date__month=month,
)
.annotate(
transaction_type=Value("income", output_field=CharField()),
is_paid_status=Value("projected", output_field=CharField()),
)
.filter(type=Transaction.Type.INCOME, is_paid=False)
.values(
"account__currency__name",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__decimal_places",
"transaction_type",
"is_paid_status",
)
.annotate(
total_amount=Coalesce(
Sum("amount"),
0,
output_field=DecimalField(max_digits=30, decimal_places=18),
)
)
)
.order_by("account__currency__name", "transaction_type", "is_paid_status")
)
result = {}
for (transaction_type, is_paid_status), group in groupby(
queryset, key=itemgetter("transaction_type", "is_paid_status")
):
key = f"{is_paid_status}_{transaction_type}"
result[key] = [
{
"name": item["account__currency__name"],
"prefix": item["account__currency__prefix"],
"suffix": item["account__currency__suffix"],
"decimal_places": item["account__currency__decimal_places"],
"total_amount": item["total_amount"],
}
for item in group
]
# result["total_balance"] =
# result["projected_balance"] = calculate_total(
# "projected_income", "projected_expenses"
# )
return render(
request,
"transactions/fragments/monthly_summary.html",
context={"totals": result},
)
@login_required
def month_year_picker(request):
available_dates = (
Transaction.objects.annotate(
month=ExtractMonth("reference_date"), year=ExtractYear("reference_date")
)
.values("month", "year")
.distinct()
.order_by("year", "month")
)
return render(
request,
"transactions/fragments/month_year_picker.html",
{"available_dates": available_dates},
)
# @login_required
# def monthly_income(request, month: int, year: int):
# situation = request.GET.get("s", "c")
#
# income_sum_by_currency = (
# Transaction.objects.filter(
# type=Transaction.Type.INCOME,
# is_paid=True if situation == "c" else False,
# account__is_asset=False,
# reference_date__year=year,
# reference_date__month=month,
# )
# .values(
# "account__currency__name",
# "account__currency__prefix",
# "account__currency__suffix",
# "account__currency__decimal_places",
# )
# .annotate(
# total_amount=Coalesce(
# Sum("amount"),
# 0,
# output_field=DecimalField(max_digits=30, decimal_places=18),
# )
# )
# .order_by("account__currency__name")
# )
#
# print(income_sum_by_currency)
#
# return render(
# request,
# "transactions/fragments/income.html",
# context={"income": income_sum_by_currency},
# )

View File

@@ -0,0 +1,34 @@
from datetime import datetime, date
from django import forms
from decimal import Decimal
class MonthYearWidget(forms.DateInput):
"""
Custom widget to display a month-year picker.
"""
input_type = "month" # Set the input type to 'month'
def format_value(self, value):
if isinstance(value, (datetime, date)):
return value.strftime("%Y-%m")
return value
class ArbitraryDecimalDisplayNumberInput(forms.NumberInput):
def __init__(self, *args, **kwargs):
self.decimal_places = kwargs.pop("decimal_places", 2)
super().__init__(*args, **kwargs)
self.attrs.update({"step": f".{'0' * (self.decimal_places - 1)}1"})
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])
return value