You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

153 lines
6.3 KiB

from datetime import date
import re
from uuid import UUID
from django import forms
from finance.models import Account, Category, CategoryGroup, Payee, Transaction
from finance.queries import active_accounts, active_categories, active_payees, category_label
_MONTH_RE = re.compile(r"(?P<year>\d{4})-(?P<month>0[1-9]|1[0-2])", re.ASCII)
def _reference_choices(model, *, include=None):
rows = {Account: active_accounts, Category: active_categories, Payee: active_payees}[model]()
if include is not None:
rows = model.objects.filter(pk=include.pk) | rows
rows = rows.order_by("name", "id")
return [(str(row.pk), category_label(row) if model is Category else row.name) for row in rows]
def _uuid(value):
return None if value == "" else UUID(value)
class ManualTransactionForm(forms.Form):
account = forms.TypedChoiceField(coerce=UUID)
amount = forms.DecimalField(max_digits=18, decimal_places=2)
posting_date = forms.DateField(widget=forms.DateInput(attrs={"type": "date"}))
occurrence_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}))
kind = forms.ChoiceField(choices=Transaction.Kind.choices)
description = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 3}))
payee = forms.TypedChoiceField(required=False, empty_value=None, coerce=_uuid)
category = forms.TypedChoiceField(required=False, empty_value=None, coerce=_uuid)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["account"].choices = _reference_choices(Account)
self.fields["payee"].choices = [("", "None"), *_reference_choices(Payee)]
self.fields["category"].choices = [("", "Uncategorized"), *_reference_choices(Category)]
def clean(self):
values = super().clean()
amount = values.get("amount")
kind = values.get("kind")
if amount is not None and kind == Transaction.Kind.EXPENSE and amount >= 0:
self.add_error("amount", "Expense amounts must be negative.")
if amount is not None and kind in {Transaction.Kind.INCOME, Transaction.Kind.REFUND} and amount <= 0:
self.add_error("amount", "Income and refund amounts must be positive.")
return values
class AnnotationForm(forms.Form):
description = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 3}))
payee = forms.TypedChoiceField(required=False, empty_value=None, coerce=_uuid)
category = forms.TypedChoiceField(required=False, empty_value=None, coerce=_uuid)
def __init__(self, *args, transaction, **kwargs):
super().__init__(*args, **kwargs)
self.transaction = transaction
self.fields["payee"].choices = [("", "None"), *_reference_choices(Payee, include=transaction.payee)]
self.fields["category"].choices = [("", "Uncategorized"), *_reference_choices(Category, include=transaction.category)]
if not self.is_bound:
self.initial = {
"description": transaction.description,
"payee": transaction.payee_id,
"category": transaction.category_id,
}
def changes(self):
values = self.cleaned_data
changes = {}
for form_name, model_name in (("description", "description"), ("payee", "payee_id"), ("category", "category_id")):
if values[form_name] != getattr(self.transaction, model_name):
changes[model_name] = values[form_name]
return changes
class AccountForm(forms.Form):
name = forms.CharField(max_length=255)
kind = forms.ChoiceField(choices=Account.Kind.choices)
currency = forms.RegexField(regex=r"^[A-Z]{3}$", max_length=3)
class CategoryForm(forms.Form):
name = forms.CharField(max_length=255)
kind = forms.ChoiceField(choices=Category.Kind.choices)
class CategoryGroupForm(forms.Form):
name = forms.CharField(max_length=255)
class PayeeForm(forms.Form):
name = forms.CharField(max_length=255)
class ReconciliationForm(forms.Form):
account = forms.TypedChoiceField(coerce=UUID)
as_of_date = forms.DateField(widget=forms.DateInput(attrs={"type": "date"}))
institution_balance = forms.DecimalField(max_digits=18, decimal_places=2)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["account"].choices = _reference_choices(Account)
class ImportMappingForm(forms.Form):
def __init__(self, *args, sections, **kwargs):
super().__init__(*args, **kwargs)
self.sections = sections
choices = [("", "Choose an account"), *(
(str(row.pk), row.name) for row in active_accounts().filter(currency="BRL")
)]
for index, section in enumerate(sections):
self.fields[f"section_{index}"] = forms.TypedChoiceField(
label=f"Section {section}", coerce=UUID, choices=choices,
error_messages={
"required": "Choose an active BRL account for this section.",
"invalid_choice": "Choose an active BRL account for this section.",
},
)
def section_mapping(self):
return {section: str(self.cleaned_data[f"section_{index}"])
for index, section in enumerate(self.sections)}
class ReportSelectionForm(forms.Form):
month = forms.CharField(
error_messages={"required": "invalid_month"},
widget=forms.TextInput(attrs={"type": "month"}),
)
currency = forms.ChoiceField(
error_messages={"required": "invalid_currency", "invalid_choice": "invalid_currency"}
)
def __init__(self, *args, currencies, **kwargs):
super().__init__(*args, **kwargs)
self.fields["currency"].choices = [(currency, currency) for currency in currencies]
def clean_month(self):
value = self.cleaned_data["month"]
match = _MONTH_RE.fullmatch(value)
if match is None:
raise forms.ValidationError("invalid_month", code="invalid_month")
try:
month = date(int(match["year"]), int(match["month"]), 1)
except ValueError as exc:
raise forms.ValidationError("invalid_month", code="invalid_month") from exc
if month.year == date.max.year and month.month == 12:
raise forms.ValidationError("invalid_month", code="invalid_month")
return month