from datetime import date import re from uuid import UUID from django import forms from finance.models import Account, Category, Payee, Transaction from finance.queries import active_accounts, active_categories, active_payees _MONTH_RE = re.compile(r"(?P\d{4})-(?P0[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), 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 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