13 changed files with 690 additions and 0 deletions
@ -0,0 +1,52 @@
|
||||
import os |
||||
from django.core.exceptions import ImproperlyConfigured |
||||
|
||||
|
||||
def required(name): |
||||
value = os.environ.get(name) |
||||
if not value or value.startswith("replace-"): |
||||
raise ImproperlyConfigured(f"Set {name} in your local environment.") |
||||
return value |
||||
|
||||
|
||||
SECRET_KEY = required("DJANGO_SECRET_KEY") |
||||
DEBUG = False |
||||
ALLOWED_HOSTS = ["localhost", "127.0.0.1"] |
||||
ROOT_URLCONF = "config.urls" |
||||
INSTALLED_APPS = [ |
||||
"django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", |
||||
"django.contrib.messages", "django.contrib.staticfiles", "core", "finance", "ingestion", |
||||
] |
||||
MIDDLEWARE = [ |
||||
"django.middleware.security.SecurityMiddleware", |
||||
"django.contrib.sessions.middleware.SessionMiddleware", |
||||
"django.middleware.common.CommonMiddleware", |
||||
"django.middleware.csrf.CsrfViewMiddleware", |
||||
"django.contrib.messages.middleware.MessageMiddleware", |
||||
] |
||||
TEMPLATES = [{ |
||||
"BACKEND": "django.template.backends.django.DjangoTemplates", |
||||
"DIRS": [], |
||||
"APP_DIRS": True, |
||||
"OPTIONS": {"context_processors": [ |
||||
"django.template.context_processors.request", |
||||
"django.contrib.messages.context_processors.messages", |
||||
"core.context.navigation", |
||||
]}, |
||||
}] |
||||
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" |
||||
STATIC_URL = "static/" |
||||
DATABASES = {"default": { |
||||
"ENGINE": "django.db.backends.postgresql", |
||||
"NAME": required("DB_NAME"), |
||||
"USER": required("DB_USER"), |
||||
"PASSWORD": required("DB_PASSWORD"), |
||||
"HOST": required("DB_HOST"), |
||||
"PORT": "5432", |
||||
"OPTIONS": {"connect_timeout": 3}, |
||||
}} |
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" |
||||
USE_TZ = True |
||||
TIME_ZONE = "UTC" |
||||
|
||||
IMPORT_ROOT = "/app/imports" |
||||
@ -0,0 +1,20 @@
|
||||
from django.urls import path |
||||
from core import views |
||||
|
||||
urlpatterns = [ |
||||
path("", views.home, name="home"), |
||||
path("health/", views.health, name="health"), |
||||
path("transactions/", views.transaction_list, name="transaction-list"), |
||||
path("transactions/new/", views.transaction_create, name="transaction-create"), |
||||
path("transactions/<uuid:transaction_id>/", views.transaction_detail, name="transaction-detail"), |
||||
path("transactions/<uuid:transaction_id>/annotate/", views.transaction_annotate, name="transaction-annotate"), |
||||
path("accounts/", views.account_list, name="account-list"), |
||||
path("accounts/new/", views.account_create, name="account-create"), |
||||
path("accounts/<uuid:reference_id>/archive/", views.account_archive, name="account-archive"), |
||||
path("categories/", views.category_list, name="category-list"), |
||||
path("categories/new/", views.category_create, name="category-create"), |
||||
path("categories/<uuid:reference_id>/archive/", views.category_archive, name="category-archive"), |
||||
path("payees/", views.payee_list, name="payee-list"), |
||||
path("payees/new/", views.payee_create, name="payee-create"), |
||||
path("payees/<uuid:reference_id>/archive/", views.payee_archive, name="payee-archive"), |
||||
] |
||||
@ -0,0 +1,8 @@
|
||||
def navigation(_request): |
||||
return {"navigation": ( |
||||
("home", "Home"), |
||||
("account-list", "Accounts"), |
||||
("transaction-list", "Transactions"), |
||||
("category-list", "Categories"), |
||||
("payee-list", "Payees"), |
||||
)} |
||||
@ -0,0 +1,84 @@
|
||||
from uuid import UUID |
||||
|
||||
from django import forms |
||||
|
||||
from finance.models import Account, Category, Payee, Transaction |
||||
|
||||
|
||||
def _reference_choices(model, *, include=None): |
||||
rows = model.objects.filter(archived=False) |
||||
if include is not None: |
||||
rows = model.objects.filter(pk=include.pk) | rows |
||||
return [(str(row.pk), row.name) for row in rows.order_by("name")] |
||||
|
||||
|
||||
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) |
||||
@ -0,0 +1,26 @@
|
||||
:root { color-scheme: light; font-family: system-ui, sans-serif; color: #17221b; background: #f4f7f4; } |
||||
* { box-sizing: border-box; } |
||||
body { margin: 0; } |
||||
header { display: flex; align-items: center; gap: 2rem; padding: 1rem max(1rem, calc((100% - 72rem) / 2)); background: #173d2a; color: white; } |
||||
header a { color: white; } |
||||
.brand { font-size: 1.25rem; font-weight: 700; text-decoration: none; } |
||||
nav { display: flex; flex-wrap: wrap; gap: 1rem; } |
||||
main { max-width: 72rem; margin: auto; padding: 2rem 1rem; } |
||||
.heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } |
||||
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 1rem; align-items: end; padding: 1rem; background: white; border-radius: .5rem; } |
||||
label { display: block; font-weight: 650; margin-bottom: .3rem; } |
||||
input, select, textarea, button, .button { width: 100%; padding: .65rem; border: 1px solid #7b8d82; border-radius: .3rem; font: inherit; } |
||||
button, .button { width: auto; background: #236642; color: white; border-color: #236642; cursor: pointer; text-decoration: none; } |
||||
.secondary { background: white; color: #294b38; } |
||||
:focus-visible { outline: 3px solid #de9f22; outline-offset: 2px; } |
||||
.field { margin-bottom: 1rem; max-width: 36rem; } |
||||
.table-wrap { overflow-x: auto; margin-top: 1rem; } |
||||
table { width: 100%; border-collapse: collapse; background: white; } |
||||
th, td { padding: .75rem; border-bottom: 1px solid #d7dfd9; text-align: left; } |
||||
.number { text-align: right; font-variant-numeric: tabular-nums; } |
||||
.details { display: grid; grid-template-columns: max-content 1fr; gap: .5rem 1.5rem; } |
||||
.details dt { font-weight: 700; } |
||||
.details dd { margin: 0; } |
||||
.error, .errorlist, .error-summary { color: #8d1b16; } |
||||
.error-summary, .message { padding: .75rem; background: white; border-left: .3rem solid currentColor; } |
||||
@media (max-width: 40rem) { header, .heading { align-items: flex-start; flex-direction: column; } .details { grid-template-columns: 1fr; } } |
||||
@ -0,0 +1,22 @@
|
||||
{% load static %} |
||||
<!doctype html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||
<title>{% block title %}Gizmoduck{% endblock %}</title> |
||||
<link rel="stylesheet" href="{% static 'core/app.css' %}"> |
||||
</head> |
||||
<body> |
||||
<header> |
||||
<a class="brand" href="{% url 'home' %}">Gizmoduck</a> |
||||
<nav aria-label="Main navigation"> |
||||
{% for route, label in navigation %}<a href="{% url route %}">{{ label }}</a>{% endfor %} |
||||
</nav> |
||||
</header> |
||||
<main> |
||||
{% if messages %}<div aria-live="polite">{% for message in messages %}<p class="message {{ message.tags }}">{{ message }}</p>{% endfor %}</div>{% endif %} |
||||
{% block content %}{% endblock %} |
||||
</main> |
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,7 @@
|
||||
{% extends "core/base.html" %} |
||||
{% block title %}{{ title }} · Gizmoduck{% endblock %} |
||||
{% block content %} |
||||
<h1>{{ title }}</h1> |
||||
{% if form.errors %}<div class="error-summary" role="alert"><strong>Review the highlighted fields.</strong>{{ form.non_field_errors }}</div>{% endif %} |
||||
<form method="post">{% csrf_token %}{% for field in form %}<div class="field">{{ field.label_tag }}{{ field }}{{ field.errors }}</div>{% endfor %}<button type="submit">Save</button></form> |
||||
{% endblock %} |
||||
@ -0,0 +1,8 @@
|
||||
{% extends "core/base.html" %} |
||||
{% block title %}{{ title }} · Gizmoduck{% endblock %} |
||||
{% block content %} |
||||
<div class="heading"><h1>{{ title }}</h1><a class="button" href="{% url create_url %}">Create</a></div> |
||||
{% if rows %}<div class="table-wrap"><table><thead><tr><th>Name</th><th>Type</th><th>Status</th><th>Action</th></tr></thead><tbody> |
||||
{% for row in rows %}<tr><td>{{ row.name }}</td><td>{{ row.get_kind_display|default:"—" }}{% if row.currency %} · {{ row.currency }}{% endif %}</td><td>{% if row.archived %}Archived{% else %}Active{% endif %}</td><td>{% if not row.archived %}<form method="post" action="{% url archive_url row.pk %}">{% csrf_token %}<button type="submit" class="secondary">Archive</button></form>{% else %}Historical{% endif %}</td></tr>{% endfor %} |
||||
</tbody></table></div>{% else %}<p>No entries yet.</p>{% endif %} |
||||
{% endblock %} |
||||
@ -0,0 +1,17 @@
|
||||
{% extends "core/base.html" %} |
||||
{% block title %}Transaction · Gizmoduck{% endblock %} |
||||
{% block content %} |
||||
<div class="heading"><h1>Transaction</h1><a class="button" href="{% url 'transaction-annotate' transaction.pk %}">Edit annotations</a></div> |
||||
<dl class="details"> |
||||
<dt>Description</dt><dd>{{ transaction.description|default:"(No description)" }}</dd> |
||||
<dt>Posting date</dt><dd>{{ transaction.posting_date|date:"Y-m-d" }}</dd> |
||||
<dt>Occurrence date</dt><dd>{{ transaction.occurrence_date|date:"Y-m-d"|default:"—" }}</dd> |
||||
<dt>Amount</dt><dd>{{ transaction.amount }} {{ transaction.account.currency }}</dd> |
||||
<dt>Account</dt><dd>{{ transaction.account.name }}</dd> |
||||
<dt>Kind</dt><dd>{{ transaction.get_kind_display }}</dd> |
||||
<dt>Payee</dt><dd>{{ transaction.payee.name|default:"—" }}</dd> |
||||
<dt>Category</dt><dd>{{ transaction.category.name|default:"Uncategorized" }}</dd> |
||||
</dl> |
||||
<h2>Source provenance</h2> |
||||
{% if transaction.source_records.all %}<div class="table-wrap"><table><thead><tr><th>Format</th><th>Parser</th><th>Location</th></tr></thead><tbody>{% for source in transaction.source_records.all %}<tr><td>{{ source.source_format }}</td><td>{{ source.parser_version }}</td><td>{{ source.item_locator }}</td></tr>{% endfor %}</tbody></table></div>{% else %}<p>This transaction was entered manually.</p>{% endif %} |
||||
{% endblock %} |
||||
@ -0,0 +1,10 @@
|
||||
{% extends "core/base.html" %} |
||||
{% block title %}{{ title }} · Gizmoduck{% endblock %} |
||||
{% block content %} |
||||
<h1>{{ title }}</h1> |
||||
{% if form.errors %}<div class="error-summary" role="alert"><strong>Review the highlighted fields.</strong>{{ form.non_field_errors }}</div>{% endif %} |
||||
<form method="post">{% csrf_token %} |
||||
{% for field in form %}<div class="field">{{ field.label_tag }}{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{{ field.errors }}</div>{% endfor %} |
||||
<button type="submit">Save</button> |
||||
</form> |
||||
{% endblock %} |
||||
@ -0,0 +1,23 @@
|
||||
{% extends "core/base.html" %} |
||||
{% block title %}Transactions · Gizmoduck{% endblock %} |
||||
{% block content %} |
||||
<div class="heading"><h1>Transactions</h1><a class="button" href="{% url 'transaction-create' %}">New transaction</a></div> |
||||
<form method="get" class="filters"> |
||||
<div><label for="date_from">From</label><input id="date_from" name="date_from" type="date" value="{{ request.GET.date_from }}">{% if filter_errors.date_from %}<p class="error">{{ filter_errors.date_from }}</p>{% endif %}</div> |
||||
<div><label for="date_to">To</label><input id="date_to" name="date_to" type="date" value="{{ request.GET.date_to }}">{% if filter_errors.date_to %}<p class="error">{{ filter_errors.date_to }}</p>{% endif %}</div> |
||||
<div><label for="account">Account</label><select id="account" name="account"><option value="">All</option>{% for item in accounts %}<option value="{{ item.pk }}"{% if request.GET.account == item.pk|stringformat:'s' %} selected{% endif %}>{{ item.name }}{% if item.archived %} (Archived){% endif %}</option>{% endfor %}</select>{% if filter_errors.account %}<p class="error">{{ filter_errors.account }}</p>{% endif %}</div> |
||||
<div><label for="kind">Kind</label><select id="kind" name="kind"><option value="">All</option>{% for value, label in kinds %}<option value="{{ value }}"{% if request.GET.kind == value %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div> |
||||
<div><label for="payee">Payee</label><select id="payee" name="payee"><option value="">All</option>{% for item in payees %}<option value="{{ item.pk }}">{{ item.name }}</option>{% endfor %}</select></div> |
||||
<div><label for="category">Category</label><select id="category" name="category"><option value="">All</option>{% for item in categories %}<option value="{{ item.pk }}">{{ item.name }}</option>{% endfor %}</select></div> |
||||
<div><label for="currency">Currency</label><input id="currency" name="currency" maxlength="3" value="{{ request.GET.currency }}">{% if filter_errors.currency %}<p class="error">{{ filter_errors.currency }}</p>{% endif %}</div> |
||||
<div><label for="uncategorized">Category status</label><select id="uncategorized" name="uncategorized"><option value="">All</option><option value="yes">Uncategorized</option><option value="no">Categorized</option></select></div> |
||||
<button type="submit">Filter</button> |
||||
</form> |
||||
{% if filter_errors %}<p class="error" role="alert">Review the invalid filters above.</p>{% endif %} |
||||
{% if page.object_list %} |
||||
<div class="table-wrap"><table><thead><tr><th>Date</th><th>Description</th><th>Account</th><th>Kind</th><th>Category</th><th class="number">Amount</th></tr></thead><tbody> |
||||
{% for row in page %}<tr><td>{{ row.posting_date|date:"Y-m-d" }}</td><td><a href="{% url 'transaction-detail' row.pk %}">{{ row.description|default:"(No description)" }}</a></td><td>{{ row.account.name }}</td><td>{{ row.get_kind_display }}</td><td>{{ row.category.name|default:"Uncategorized" }}</td><td class="number">{{ row.amount }} {{ row.account.currency }}</td></tr>{% endfor %} |
||||
</tbody></table></div> |
||||
{% else %}<p>No transactions match these filters.</p>{% endif %} |
||||
{% if page.has_other_pages %}<nav aria-label="Pagination">{% if page.has_previous %}<a href="?page={{ page.previous_page_number }}">Previous</a>{% endif %}<span>Page {{ page.number }} of {{ page.paginator.num_pages }}</span>{% if page.has_next %}<a href="?page={{ page.next_page_number }}">Next</a>{% endif %}</nav>{% endif %} |
||||
{% endblock %} |
||||
@ -0,0 +1,198 @@
|
||||
from django.contrib import messages |
||||
from django.core.paginator import Paginator |
||||
from django.db import DatabaseError, connection |
||||
from django.http import JsonResponse |
||||
from django.shortcuts import get_object_or_404, redirect, render |
||||
from django.views.decorators.http import require_POST |
||||
|
||||
from finance.models import Account, Category, Payee, Transaction |
||||
from finance.queries import QueryInputError, TransactionFilters, filtered_transactions |
||||
from finance.services import ( |
||||
Actor, DomainError, annotate_transaction, archive, create_account, |
||||
create_category, create_payee, record_transaction, |
||||
) |
||||
|
||||
from .forms import AccountForm, AnnotationForm, CategoryForm, ManualTransactionForm, PayeeForm |
||||
|
||||
|
||||
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." |
||||
|
||||
|
||||
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 transaction_list(request) |
||||
|
||||
|
||||
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")) |
||||
return render(request, "core/transactions/list.html", { |
||||
"page": page, |
||||
"filters": filters, |
||||
"filter_errors": filter_errors, |
||||
"accounts": Account.objects.order_by("name"), |
||||
"categories": Category.objects.order_by("name"), |
||||
"payees": Payee.objects.order_by("name"), |
||||
"kinds": Transaction.Kind.choices, |
||||
}) |
||||
|
||||
|
||||
def transaction_detail(request, transaction_id): |
||||
row = get_object_or_404( |
||||
Transaction.objects.select_related( |
||||
"account", "payee", "category", "statement", "refund_of", "installment_plan" |
||||
).prefetch_related("source_records"), |
||||
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, Account, "Accounts", "account-create", "account-archive") |
||||
|
||||
|
||||
def category_list(request): |
||||
return _reference_list(request, Category, "Categories", "category-create", "category-archive") |
||||
|
||||
|
||||
def payee_list(request): |
||||
return _reference_list(request, Payee, "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, model, title, create_url, archive_url): |
||||
return render(request, "core/references/list.html", { |
||||
"title": title, |
||||
"rows": model.objects.order_by("archived", "name"), |
||||
"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.") |
||||
@ -0,0 +1,215 @@
|
||||
from datetime import date |
||||
from decimal import Decimal |
||||
|
||||
import pytest |
||||
from django.test import Client |
||||
from django.urls import reverse |
||||
|
||||
from finance.models import Account, AuditEvent, Category, Payee, SourceRecord, Transaction |
||||
from finance.services import Actor, create_account, create_category, create_payee, record_transaction |
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True) |
||||
SETUP_ACTOR = Actor(kind="AGENT", identifier="synthetic-web-test") |
||||
|
||||
|
||||
def account(name="Synthetic Bank"): |
||||
return create_account(name=name, kind="BANK", currency="BRL", actor=SETUP_ACTOR) |
||||
|
||||
|
||||
def transaction(target, description="Synthetic purchase"): |
||||
return record_transaction( |
||||
account_id=target.pk, |
||||
amount=Decimal("-24.50"), |
||||
posting_date=date(2026, 1, 15), |
||||
kind="EXPENSE", |
||||
description=description, |
||||
actor=SETUP_ACTOR, |
||||
) |
||||
|
||||
|
||||
def test_root_and_transaction_pages_are_discoverable_and_filter_rows(client): |
||||
bank = account() |
||||
expected = transaction(bank) |
||||
transaction(bank, "Other month").__class__.objects.filter(description="Other month").update( |
||||
posting_date=date(2026, 2, 1) |
||||
) |
||||
|
||||
home = client.get(reverse("home")) |
||||
page = client.get( |
||||
reverse("transaction-list"), |
||||
{"currency": "BRL", "date_from": "2026-01-01", "date_to": "2026-01-31"}, |
||||
) |
||||
|
||||
assert home.status_code == 200 |
||||
assert page.status_code == 200 |
||||
content = page.content.decode() |
||||
assert "Synthetic purchase" in content |
||||
assert "Other month" not in content |
||||
assert reverse("transaction-detail", args=[expected.pk]) in content |
||||
assert "<h1>Transactions</h1>" in content |
||||
assert "Accounts" in content and "Categories" in content and "Payees" in content |
||||
|
||||
|
||||
def test_invalid_transaction_filters_are_shown_without_querying(client): |
||||
response = client.get(reverse("transaction-list"), {"date_from": "not-a-date"}) |
||||
|
||||
assert response.status_code == 200 |
||||
assert "Enter a valid date." in response.content.decode() |
||||
|
||||
|
||||
def test_transaction_detail_shows_relationships_and_provenance(client): |
||||
bank = account() |
||||
row = transaction(bank) |
||||
SourceRecord.objects.create( |
||||
transaction=row, |
||||
document_hash="a" * 64, |
||||
source_format="BANK_CSV", |
||||
parser_version="synthetic-v1", |
||||
item_locator="row:2", |
||||
) |
||||
|
||||
response = client.get(reverse("transaction-detail", args=[row.pk])) |
||||
|
||||
content = response.content.decode() |
||||
assert response.status_code == 200 |
||||
assert "Synthetic purchase" in content |
||||
assert "Synthetic Bank" in content |
||||
assert "BANK_CSV" in content |
||||
assert "row:2" in content |
||||
|
||||
|
||||
@pytest.mark.parametrize( |
||||
"route,args", |
||||
[ |
||||
("transaction-create", ()), |
||||
("transaction-annotate", ("00000000-0000-0000-0000-000000000001",)), |
||||
("account-create", ()), |
||||
("account-archive", ("00000000-0000-0000-0000-000000000001",)), |
||||
("category-create", ()), |
||||
("category-archive", ("00000000-0000-0000-0000-000000000001",)), |
||||
("payee-create", ()), |
||||
("payee-archive", ("00000000-0000-0000-0000-000000000001",)), |
||||
], |
||||
) |
||||
def test_mutations_require_csrf(route, args): |
||||
guarded = Client(enforce_csrf_checks=True) |
||||
|
||||
response = guarded.post(reverse(route, args=args), {}) |
||||
|
||||
assert response.status_code == 403 |
||||
|
||||
|
||||
def test_manual_transaction_and_annotation_use_local_owner_audit(client): |
||||
bank = account() |
||||
category = create_category(name="Synthetic Food", kind="EXPENSE", actor=SETUP_ACTOR) |
||||
payee = create_payee(name="Synthetic Merchant", actor=SETUP_ACTOR) |
||||
|
||||
created = client.post( |
||||
reverse("transaction-create"), |
||||
{ |
||||
"account": bank.pk, |
||||
"amount": "-24.50", |
||||
"posting_date": "2026-01-15", |
||||
"occurrence_date": "", |
||||
"kind": "EXPENSE", |
||||
"description": "Synthetic purchase", |
||||
"payee": "", |
||||
"category": "", |
||||
}, |
||||
) |
||||
|
||||
row = Transaction.objects.get(description="Synthetic purchase") |
||||
assert created.status_code == 302 |
||||
assert created.url == reverse("transaction-detail", args=[row.pk]) |
||||
create_audit = AuditEvent.objects.get(operation="record_transaction", target_id=row.pk) |
||||
assert (create_audit.actor_kind, create_audit.actor_identifier) == ("HUMAN", "local-owner") |
||||
|
||||
annotated = client.post( |
||||
reverse("transaction-annotate", args=[row.pk]), |
||||
{"description": "Synthetic revised", "payee": payee.pk, "category": category.pk}, |
||||
) |
||||
|
||||
row.refresh_from_db() |
||||
assert annotated.status_code == 302 |
||||
assert row.description == "Synthetic revised" |
||||
assert row.payee_id == payee.pk and row.category_id == category.pk |
||||
assert row.amount == Decimal("-24.50") and row.posting_date == date(2026, 1, 15) |
||||
annotation_audit = AuditEvent.objects.get(operation="annotate_transaction", target_id=row.pk) |
||||
assert (annotation_audit.actor_kind, annotation_audit.actor_identifier) == ("HUMAN", "local-owner") |
||||
|
||||
|
||||
@pytest.mark.parametrize( |
||||
"kind,create_route,list_route,archive_route,payload,model", |
||||
[ |
||||
( |
||||
"account", |
||||
"account-create", |
||||
"account-list", |
||||
"account-archive", |
||||
{"name": "Synthetic Savings", "kind": "BANK", "currency": "BRL"}, |
||||
Account, |
||||
), |
||||
( |
||||
"category", |
||||
"category-create", |
||||
"category-list", |
||||
"category-archive", |
||||
{"name": "Synthetic Dining", "kind": "EXPENSE"}, |
||||
Category, |
||||
), |
||||
( |
||||
"payee", |
||||
"payee-create", |
||||
"payee-list", |
||||
"payee-archive", |
||||
{"name": "Synthetic Shop"}, |
||||
Payee, |
||||
), |
||||
], |
||||
) |
||||
def test_reference_create_list_and_archive_are_audited( |
||||
client, kind, create_route, list_route, archive_route, payload, model |
||||
): |
||||
created = client.post(reverse(create_route), payload) |
||||
obj = model.objects.get(name=payload["name"]) |
||||
|
||||
assert created.status_code == 302 |
||||
assert created.url == reverse(list_route) |
||||
assert payload["name"] in client.get(reverse(list_route)).content.decode() |
||||
create_audit = AuditEvent.objects.get(operation=f"create_{kind}", target_id=obj.pk) |
||||
assert create_audit.actor_identifier == "local-owner" |
||||
|
||||
archived = client.post(reverse(archive_route, args=[obj.pk])) |
||||
|
||||
obj.refresh_from_db() |
||||
assert archived.status_code == 302 |
||||
assert obj.archived is True |
||||
assert "Archived" in client.get(reverse(list_route)).content.decode() |
||||
archive_audit = AuditEvent.objects.get(operation="archive", target_id=obj.pk) |
||||
assert archive_audit.actor_identifier == "local-owner" |
||||
assert client.get(reverse(archive_route, args=[obj.pk])).status_code == 405 |
||||
|
||||
|
||||
def test_domain_errors_render_as_safe_fixed_messages(client): |
||||
bank = account() |
||||
|
||||
response = client.post( |
||||
reverse("transaction-create"), |
||||
{ |
||||
"account": bank.pk, |
||||
"amount": "24.50", |
||||
"posting_date": "2026-01-15", |
||||
"occurrence_date": "", |
||||
"kind": "EXPENSE", |
||||
"description": "Synthetic invalid sign", |
||||
"payee": "", |
||||
"category": "", |
||||
}, |
||||
) |
||||
|
||||
content = response.content.decode() |
||||
assert response.status_code == 200 |
||||
assert "Expense amounts must be negative." in content |
||||
assert "Synthetic invalid sign" in content |
||||
assert Transaction.objects.filter(description="Synthetic invalid sign").count() == 0 |
||||
Loading…
Reference in new issue