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.
128 lines
5.2 KiB
128 lines
5.2 KiB
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<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), 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 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
|
|
|