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.
521 lines
20 KiB
521 lines
20 KiB
from dataclasses import dataclass |
|
from datetime import date |
|
from decimal import Decimal |
|
|
|
from django.contrib import messages |
|
from django.core import signing |
|
from django.core.paginator import Paginator |
|
from django.db import DatabaseError, connection |
|
from django.http import HttpResponse, JsonResponse |
|
from django.shortcuts import get_object_or_404, redirect, render |
|
from django.utils import timezone |
|
from django.views.decorators.http import require_POST |
|
|
|
from finance.models import Account, Category, Payee, Transaction |
|
from finance.exports import build_export |
|
from finance.queries import ( |
|
QueryInputError, TransactionFilters, filtered_transactions, |
|
listed_accounts, listed_categories, listed_payees, |
|
) |
|
from finance.reconciliation import ( |
|
checkpoint_history, |
|
latest_checkpoint_statuses, |
|
ledger_balance_through, |
|
) |
|
from finance.reports import account_balances, available_currencies, monthly_summary, spending_by_category |
|
from finance.services import ( |
|
Actor, DomainError, annotate_transaction, archive, create_account, |
|
create_category, create_payee, create_reconciliation_checkpoint, |
|
record_transaction, |
|
) |
|
from ingestion import guided_imports |
|
|
|
from .forms import ( |
|
AccountForm, AnnotationForm, CategoryForm, ManualTransactionForm, PayeeForm, |
|
ImportMappingForm, ReconciliationForm, ReportSelectionForm, |
|
) |
|
|
|
|
|
LOCAL_ACTOR = Actor(kind="HUMAN", identifier="local-owner") |
|
DOMAIN_MESSAGES = { |
|
"archived_reference": "That reference is archived and cannot be selected.", |
|
"category_kind_mismatch": "The category does not match the transaction kind.", |
|
"conflict": "A record with these values already exists.", |
|
"invalid_amount": "Enter a valid amount with at most two decimal places.", |
|
"invalid_name": "Enter a name.", |
|
"not_found": "The requested record was not found.", |
|
"validation_error": "The values do not satisfy the financial rules.", |
|
"zero_amount": "The amount cannot be zero.", |
|
} |
|
UNKNOWN_DOMAIN_MESSAGE = "Unable to save this change." |
|
MAX_GUIDED_FILES = 10 |
|
|
|
|
|
@dataclass(frozen=True) |
|
class ReconciliationPreview: |
|
account: Account |
|
as_of_date: date |
|
institution_balance: Decimal |
|
ledger_balance: Decimal |
|
|
|
@property |
|
def difference(self) -> Decimal: |
|
return self.institution_balance - self.ledger_balance |
|
|
|
|
|
def health(request): |
|
try: |
|
with connection.cursor() as cursor: |
|
cursor.execute("SELECT 1") |
|
cursor.fetchone() |
|
except DatabaseError: |
|
return JsonResponse({"status": "unavailable"}, status=503) |
|
return JsonResponse({"status": "ok"}) |
|
|
|
|
|
def home(request): |
|
return _monthly_report(request, "core/dashboard.html") |
|
|
|
|
|
def monthly_report(request): |
|
return _monthly_report(request, "core/reports/monthly.html") |
|
|
|
|
|
def imports_index(request): |
|
if request.method == "POST": |
|
action = request.POST.get("action") |
|
if action == "scan": |
|
discovered = guided_imports.discover_imports() |
|
request.session.pop("guided_imports", None) |
|
if len(discovered) > MAX_GUIDED_FILES: |
|
return render(request, "core/imports/index.html", { |
|
"error": f"SCAN_TOO_LARGE: Keep at most {MAX_GUIDED_FILES} files in imports/ and scan again. No files were imported.", |
|
}) |
|
files = [{ |
|
"name": row.name, "profile": row.profile, "sections": list(row.sections), |
|
"state": "NEEDS_ATTENTION" if row.code else "MAPPING_REQUIRED", |
|
"code": row.code, "counts": None, |
|
} for row in discovered] |
|
scannable = [_import_metadata(row) for row in discovered if not row.code] |
|
_remember_import_summaries(request, [row for row in files if row["code"]]) |
|
return _render_imports(request, files=files, scan_token=signing.dumps( |
|
scannable, salt="guided-imports-scan", compress=True, |
|
) if scannable else None) |
|
elif action in {"prepare", "commit"}: |
|
try: |
|
payload = _load_import_token(request, prepared=action == "commit") |
|
except (signing.BadSignature, ValueError, TypeError): |
|
return render(request, "core/imports/index.html", { |
|
"error": "This import state is invalid or expired. Scan again before continuing.", |
|
}, status=400) |
|
if action == "prepare": |
|
return _prepare_import_files(request, payload) |
|
else: |
|
_commit_import_files(request, payload) |
|
else: |
|
return render(request, "core/imports/index.html", { |
|
"error": "Choose an import action. Scan again before continuing.", |
|
}, status=400) |
|
return redirect("imports-index") |
|
|
|
return _render_imports(request, files=request.session.get("guided_imports", [])) |
|
|
|
|
|
def _render_imports(request, *, files, **context): |
|
"""Only immediate form responses hold signed workflow metadata.""" |
|
displayed = [] |
|
mapping_index = 0 |
|
for row in files: |
|
row = dict(row) |
|
if row["state"] == "MAPPING_REQUIRED": |
|
row["form"] = ImportMappingForm(sections=row["sections"], prefix=f"file-{mapping_index}") |
|
mapping_index += 1 |
|
displayed.append(row) |
|
return render(request, "core/imports/index.html", {**context, "files": displayed}) |
|
|
|
|
|
def _import_metadata(row, *, prepared=False): |
|
metadata = { |
|
"name": row.name, "document_hash": row.document_hash, "profile": row.profile, |
|
"sections": list(row.sections), |
|
} |
|
if prepared: |
|
metadata.update(batch_id=row.batch_id, revision=row.revision) |
|
return metadata |
|
|
|
|
|
def _load_import_token(request, *, prepared): |
|
field, salt = ("prepared_token", "guided-imports-prepared") if prepared else ("scan_token", "guided-imports-scan") |
|
payload = signing.loads(request.POST.get(field, ""), salt=salt, max_age=900) |
|
keys = {"name", "document_hash", "profile", "sections"} |
|
if prepared: |
|
keys |= {"batch_id", "revision"} |
|
if not isinstance(payload, list) or not payload or len(payload) > MAX_GUIDED_FILES: |
|
raise ValueError |
|
for row in payload: |
|
if not isinstance(row, dict) or set(row) != keys: |
|
raise ValueError |
|
if any(not isinstance(row[key], str) or not row[key] for key in ("name", "document_hash", "profile")): |
|
raise ValueError |
|
if not isinstance(row["sections"], list) or not row["sections"] or any( |
|
not isinstance(section, str) or not section for section in row["sections"] |
|
): |
|
raise ValueError |
|
if prepared and (not isinstance(row["batch_id"], str) or type(row["revision"]) is not int): |
|
raise ValueError |
|
return payload |
|
|
|
|
|
def _import_summary(row): |
|
return { |
|
"name": row.name, "profile": row.profile, "sections": list(row.sections), |
|
"state": row.state, "code": row.code, "counts": row.counts, |
|
} |
|
|
|
|
|
def _import_failure(metadata, code="IMPORT_FAILED"): |
|
return { |
|
"name": metadata["name"], "profile": metadata["profile"], "sections": metadata["sections"], |
|
"state": "NEEDS_ATTENTION", "code": code, |
|
"counts": {"created": 0, "matched": 0, "skipped": 0}, |
|
} |
|
|
|
|
|
def _combined_import_results(request, results): |
|
# Retain exceptions from other files through each independent action. |
|
names = {row["name"] for row in results} |
|
previous = request.session.get("guided_imports", []) |
|
files = [row for row in previous |
|
if row["name"] not in names and row["state"] == "NEEDS_ATTENTION"] + results |
|
if len(files) > MAX_GUIDED_FILES: |
|
# Another tab may have scanned a different group since this form opened. |
|
files = results |
|
return sorted(files, key=lambda row: row["name"]) |
|
|
|
|
|
def _remember_import_summaries(request, files): |
|
# Do not retain hashes, batch identifiers, tokens, mappings, or source content. |
|
request.session["guided_imports"] = [{ |
|
key: row[key] for key in ("name", "profile", "state", "code", "counts") |
|
} for row in files] |
|
|
|
|
|
def _prepare_import_files(request, payload): |
|
results, ready = [], [] |
|
for index, metadata in enumerate(payload): |
|
form = ImportMappingForm(request.POST, sections=metadata["sections"], prefix=f"file-{index}") |
|
if not form.is_valid(): |
|
results.append(_import_failure(metadata, "INVALID_SECTION_MAPPING")) |
|
continue |
|
discovered = guided_imports.DiscoveredFile( |
|
**{**metadata, "sections": tuple(metadata["sections"])}, code=None, |
|
) |
|
try: |
|
result = guided_imports.prepare_import( |
|
discovered=discovered, section_mapping=form.section_mapping(), actor=LOCAL_ACTOR, |
|
) |
|
except Exception: |
|
# Source-bearing errors must stay local and cannot stop another file. |
|
results.append(_import_failure(metadata)) |
|
continue |
|
results.append(_import_summary(result)) |
|
if result.state in {"READY", "ALREADY_IMPORTED"}: |
|
ready.append(_import_metadata(result, prepared=True)) |
|
files = _combined_import_results(request, results) |
|
_remember_import_summaries(request, [row for row in files if row["state"] == "NEEDS_ATTENTION"]) |
|
return _render_imports(request, files=files, prepared_token=signing.dumps( |
|
ready, salt="guided-imports-prepared", compress=True, |
|
) if ready else None) |
|
|
|
|
|
def _commit_import_files(request, payload): |
|
results = [] |
|
for metadata in payload: |
|
prepared = guided_imports.PreparedImport( |
|
**{**metadata, "sections": tuple(metadata["sections"])}, |
|
state="READY", counts={"created": 0, "matched": 0, "skipped": 0}, code=None, |
|
) |
|
try: |
|
result = guided_imports.commit_prepared_import(prepared=prepared, actor=LOCAL_ACTOR) |
|
except Exception: |
|
results.append(_import_failure(metadata)) |
|
continue |
|
results.append(_import_summary(result)) |
|
_remember_import_summaries(request, _combined_import_results(request, results)) |
|
|
|
|
|
def reconciliation_index(request): |
|
form = ReconciliationForm(request.POST or None) |
|
preview = None |
|
preview_token = None |
|
if request.method == "POST" and form.is_valid(): |
|
action = request.POST.get("action") |
|
values = form.cleaned_data |
|
preview_inputs = { |
|
"account": str(values["account"]), |
|
"as_of_date": values["as_of_date"].isoformat(), |
|
"institution_balance": format(values["institution_balance"], ".2f"), |
|
} |
|
if action == "preview": |
|
selected_account = get_object_or_404( |
|
Account, |
|
pk=values["account"], |
|
archived=False, |
|
) |
|
preview = ReconciliationPreview( |
|
account=selected_account, |
|
as_of_date=values["as_of_date"], |
|
institution_balance=values["institution_balance"], |
|
ledger_balance=ledger_balance_through( |
|
selected_account.pk, |
|
values["as_of_date"], |
|
), |
|
) |
|
preview_token = signing.dumps(preview_inputs, salt="reconciliation-preview") |
|
elif action == "confirm": |
|
try: |
|
confirmed_inputs = signing.loads( |
|
request.POST.get("preview_token", ""), |
|
salt="reconciliation-preview", |
|
) |
|
except signing.BadSignature: |
|
confirmed_inputs = None |
|
if confirmed_inputs != preview_inputs: |
|
form.add_error(None, "Preview these values before confirming the checkpoint.") |
|
else: |
|
try: |
|
saved = create_reconciliation_checkpoint( |
|
account_id=values["account"], |
|
as_of_date=values["as_of_date"], |
|
institution_balance=values["institution_balance"], |
|
actor=LOCAL_ACTOR, |
|
) |
|
except DomainError as exc: |
|
form.add_error(None, _domain_message(exc)) |
|
else: |
|
messages.success(request, "Reconciliation checkpoint recorded.") |
|
return redirect("reconciliation-history", account_id=saved.account_id) |
|
return render(request, "core/reconciliations/index.html", { |
|
"form": form, |
|
"preview": preview, |
|
"preview_token": preview_token, |
|
"latest_statuses": _latest_reconciliation_statuses(), |
|
}) |
|
|
|
|
|
def reconciliation_history(request, account_id): |
|
selected_account = get_object_or_404(Account, pk=account_id) |
|
return render(request, "core/reconciliations/history.html", { |
|
"account": selected_account, |
|
"history": checkpoint_history(selected_account.pk), |
|
}) |
|
|
|
|
|
def export_index(request): |
|
return render(request, "core/exports.html") |
|
|
|
|
|
@require_POST |
|
def export_download(request): |
|
artifact = build_export() |
|
response = HttpResponse(artifact.content, content_type="application/zip") |
|
response["Content-Disposition"] = f'attachment; filename="{artifact.filename}"' |
|
return response |
|
|
|
|
|
def transaction_list(request): |
|
filter_errors = {} |
|
try: |
|
filters = TransactionFilters.from_mapping(request.GET) |
|
rows = filtered_transactions(filters) |
|
except QueryInputError as exc: |
|
filters = TransactionFilters() |
|
rows = Transaction.objects.none() |
|
filter_errors = {field: _query_message(code) for field, code in exc.errors.items()} |
|
page = Paginator(rows, 50).get_page(request.GET.get("page")) |
|
pagination_parameters = request.GET.copy() |
|
pagination_parameters.pop("page", None) |
|
return render(request, "core/transactions/list.html", { |
|
"page": page, |
|
"filters": filters, |
|
"filter_errors": filter_errors, |
|
"accounts": listed_accounts(), |
|
"categories": listed_categories(), |
|
"payees": listed_payees(), |
|
"kinds": Transaction.Kind.choices, |
|
"pagination_query": pagination_parameters.urlencode(), |
|
}) |
|
|
|
|
|
def transaction_detail(request, transaction_id): |
|
row = get_object_or_404( |
|
Transaction.objects.select_related( |
|
"account", "payee", "category", "statement", "refund_of", |
|
"installment_plan", "outgoing_transfer__incoming__account", |
|
"incoming_transfer__outgoing__account", |
|
).prefetch_related("source_records", "refunds"), |
|
pk=transaction_id, |
|
) |
|
return render(request, "core/transactions/detail.html", {"transaction": row}) |
|
|
|
|
|
def transaction_create(request): |
|
form = ManualTransactionForm(request.POST or None) |
|
if request.method == "POST" and form.is_valid(): |
|
values = form.cleaned_data |
|
try: |
|
row = record_transaction( |
|
account_id=values["account"], amount=values["amount"], |
|
posting_date=values["posting_date"], occurrence_date=values["occurrence_date"], |
|
kind=values["kind"], description=values["description"], |
|
payee_id=values["payee"], category_id=values["category"], actor=LOCAL_ACTOR, |
|
) |
|
except DomainError as exc: |
|
form.add_error(None, _domain_message(exc)) |
|
else: |
|
messages.success(request, "Transaction recorded.") |
|
return redirect("transaction-detail", transaction_id=row.pk) |
|
return render(request, "core/transactions/form.html", {"form": form, "title": "New transaction"}) |
|
|
|
|
|
def transaction_annotate(request, transaction_id): |
|
row = get_object_or_404(Transaction.objects.select_related("payee", "category"), pk=transaction_id) |
|
form = AnnotationForm(request.POST or None, transaction=row) |
|
if request.method == "POST" and form.is_valid(): |
|
changes = form.changes() |
|
if not changes: |
|
return redirect("transaction-detail", transaction_id=row.pk) |
|
try: |
|
annotate_transaction(transaction_id=row.pk, actor=LOCAL_ACTOR, **changes) |
|
except DomainError as exc: |
|
form.add_error(None, _domain_message(exc)) |
|
else: |
|
messages.success(request, "Transaction annotations updated.") |
|
return redirect("transaction-detail", transaction_id=row.pk) |
|
return render(request, "core/transactions/form.html", { |
|
"form": form, "title": "Edit transaction annotations", "transaction": row, |
|
}) |
|
|
|
|
|
def account_list(request): |
|
return _reference_list(request, listed_accounts(), "Accounts", "account-create", "account-archive") |
|
|
|
|
|
def category_list(request): |
|
return _reference_list(request, listed_categories(), "Categories", "category-create", "category-archive") |
|
|
|
|
|
def payee_list(request): |
|
return _reference_list(request, listed_payees(), "Payees", "payee-create", "payee-archive") |
|
|
|
|
|
def account_create(request): |
|
return _reference_create(request, AccountForm, create_account, "New account", "account-list") |
|
|
|
|
|
def category_create(request): |
|
return _reference_create(request, CategoryForm, create_category, "New category", "category-list") |
|
|
|
|
|
def payee_create(request): |
|
return _reference_create(request, PayeeForm, create_payee, "New payee", "payee-list") |
|
|
|
|
|
@require_POST |
|
def account_archive(request, reference_id): |
|
return _reference_archive(request, Account, reference_id, "account-list") |
|
|
|
|
|
@require_POST |
|
def category_archive(request, reference_id): |
|
return _reference_archive(request, Category, reference_id, "category-list") |
|
|
|
|
|
@require_POST |
|
def payee_archive(request, reference_id): |
|
return _reference_archive(request, Payee, reference_id, "payee-list") |
|
|
|
|
|
def _reference_list(request, rows, title, create_url, archive_url): |
|
return render(request, "core/references/list.html", { |
|
"title": title, |
|
"rows": rows, |
|
"create_url": create_url, |
|
"archive_url": archive_url, |
|
}) |
|
|
|
|
|
def _reference_create(request, form_class, service, title, redirect_name): |
|
form = form_class(request.POST or None) |
|
if request.method == "POST" and form.is_valid(): |
|
try: |
|
service(actor=LOCAL_ACTOR, **form.cleaned_data) |
|
except DomainError as exc: |
|
form.add_error(None, _domain_message(exc)) |
|
else: |
|
messages.success(request, f"{title.removeprefix('New ')} created.") |
|
return redirect(redirect_name) |
|
return render(request, "core/references/form.html", {"form": form, "title": title}) |
|
|
|
|
|
def _reference_archive(request, model, reference_id, redirect_name): |
|
item = get_object_or_404(model, pk=reference_id) |
|
try: |
|
archive(item, actor=LOCAL_ACTOR) |
|
except DomainError as exc: |
|
messages.error(request, _domain_message(exc)) |
|
else: |
|
messages.success(request, "Reference archived.") |
|
return redirect(redirect_name) |
|
|
|
|
|
def _domain_message(error): |
|
return DOMAIN_MESSAGES.get(error.code, UNKNOWN_DOMAIN_MESSAGE) |
|
|
|
|
|
def _query_message(code): |
|
return { |
|
"invalid_date": "Enter a valid date.", |
|
"before_date_from": "The end date must be on or after the start date.", |
|
"invalid_uuid": "Select a valid reference.", |
|
"invalid_choice": "Select a valid choice.", |
|
"invalid_currency": "Enter a three-letter uppercase currency code.", |
|
}.get(code, "Invalid filter.") |
|
|
|
|
|
def _monthly_report(request, template_name): |
|
currencies = available_currencies() or ("BRL",) |
|
parameters = { |
|
"month": request.GET.get("month", timezone.localdate().strftime("%Y-%m")), |
|
"currency": request.GET.get("currency", currencies[0]), |
|
} |
|
form = ReportSelectionForm(parameters, currencies=currencies) |
|
context = {"selection_form": form} |
|
if template_name == "core/dashboard.html": |
|
context["reconciliation_statuses"] = latest_checkpoint_statuses("BRL") |
|
status = 200 |
|
if form.is_valid(): |
|
month = form.cleaned_data["month"] |
|
currency = form.cleaned_data["currency"] |
|
context.update({ |
|
"summary": monthly_summary(month, currency), |
|
"spending_rows": spending_by_category(month, currency), |
|
"balances": account_balances(month, currency), |
|
}) |
|
else: |
|
status = 400 |
|
return render(request, template_name, context, status=status) |
|
|
|
|
|
def _latest_reconciliation_statuses(): |
|
currencies = ( |
|
Account.objects.filter(archived=False) |
|
.order_by("currency") |
|
.values_list("currency", flat=True) |
|
.distinct() |
|
) |
|
return [ |
|
status |
|
for currency in currencies |
|
for status in latest_checkpoint_statuses(currency) |
|
]
|
|
|