diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..971017a --- /dev/null +++ b/config/settings.py @@ -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" diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..78cba25 --- /dev/null +++ b/config/urls.py @@ -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//", views.transaction_detail, name="transaction-detail"), + path("transactions//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//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//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//archive/", views.payee_archive, name="payee-archive"), +] diff --git a/core/context.py b/core/context.py new file mode 100644 index 0000000..430552a --- /dev/null +++ b/core/context.py @@ -0,0 +1,8 @@ +def navigation(_request): + return {"navigation": ( + ("home", "Home"), + ("account-list", "Accounts"), + ("transaction-list", "Transactions"), + ("category-list", "Categories"), + ("payee-list", "Payees"), + )} diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..72bb905 --- /dev/null +++ b/core/forms.py @@ -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) diff --git a/core/static/core/app.css b/core/static/core/app.css new file mode 100644 index 0000000..b52c043 --- /dev/null +++ b/core/static/core/app.css @@ -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; } } diff --git a/core/templates/core/base.html b/core/templates/core/base.html new file mode 100644 index 0000000..33796cf --- /dev/null +++ b/core/templates/core/base.html @@ -0,0 +1,22 @@ +{% load static %} + + + + + + {% block title %}Gizmoduck{% endblock %} + + + +
+ Gizmoduck + +
+
+ {% if messages %}
{% for message in messages %}

{{ message }}

{% endfor %}
{% endif %} + {% block content %}{% endblock %} +
+ + diff --git a/core/templates/core/references/form.html b/core/templates/core/references/form.html new file mode 100644 index 0000000..483c944 --- /dev/null +++ b/core/templates/core/references/form.html @@ -0,0 +1,7 @@ +{% extends "core/base.html" %} +{% block title %}{{ title }} · Gizmoduck{% endblock %} +{% block content %} +

{{ title }}

+{% if form.errors %}{% endif %} +
{% csrf_token %}{% for field in form %}
{{ field.label_tag }}{{ field }}{{ field.errors }}
{% endfor %}
+{% endblock %} diff --git a/core/templates/core/references/list.html b/core/templates/core/references/list.html new file mode 100644 index 0000000..f63fbe2 --- /dev/null +++ b/core/templates/core/references/list.html @@ -0,0 +1,8 @@ +{% extends "core/base.html" %} +{% block title %}{{ title }} · Gizmoduck{% endblock %} +{% block content %} +

{{ title }}

Create
+{% if rows %}
+{% for row in rows %}{% endfor %} +
NameTypeStatusAction
{{ row.name }}{{ row.get_kind_display|default:"—" }}{% if row.currency %} · {{ row.currency }}{% endif %}{% if row.archived %}Archived{% else %}Active{% endif %}{% if not row.archived %}
{% csrf_token %}
{% else %}Historical{% endif %}
{% else %}

No entries yet.

{% endif %} +{% endblock %} diff --git a/core/templates/core/transactions/detail.html b/core/templates/core/transactions/detail.html new file mode 100644 index 0000000..b501a98 --- /dev/null +++ b/core/templates/core/transactions/detail.html @@ -0,0 +1,17 @@ +{% extends "core/base.html" %} +{% block title %}Transaction · Gizmoduck{% endblock %} +{% block content %} +

Transaction

Edit annotations
+
+
Description
{{ transaction.description|default:"(No description)" }}
+
Posting date
{{ transaction.posting_date|date:"Y-m-d" }}
+
Occurrence date
{{ transaction.occurrence_date|date:"Y-m-d"|default:"—" }}
+
Amount
{{ transaction.amount }} {{ transaction.account.currency }}
+
Account
{{ transaction.account.name }}
+
Kind
{{ transaction.get_kind_display }}
+
Payee
{{ transaction.payee.name|default:"—" }}
+
Category
{{ transaction.category.name|default:"Uncategorized" }}
+
+

Source provenance

+{% if transaction.source_records.all %}
{% for source in transaction.source_records.all %}{% endfor %}
FormatParserLocation
{{ source.source_format }}{{ source.parser_version }}{{ source.item_locator }}
{% else %}

This transaction was entered manually.

{% endif %} +{% endblock %} diff --git a/core/templates/core/transactions/form.html b/core/templates/core/transactions/form.html new file mode 100644 index 0000000..37cb2d7 --- /dev/null +++ b/core/templates/core/transactions/form.html @@ -0,0 +1,10 @@ +{% extends "core/base.html" %} +{% block title %}{{ title }} · Gizmoduck{% endblock %} +{% block content %} +

{{ title }}

+{% if form.errors %}{% endif %} +
{% csrf_token %} + {% for field in form %}
{{ field.label_tag }}{{ field }}{% if field.help_text %}{{ field.help_text }}{% endif %}{{ field.errors }}
{% endfor %} + +
+{% endblock %} diff --git a/core/templates/core/transactions/list.html b/core/templates/core/transactions/list.html new file mode 100644 index 0000000..3a78be3 --- /dev/null +++ b/core/templates/core/transactions/list.html @@ -0,0 +1,23 @@ +{% extends "core/base.html" %} +{% block title %}Transactions · Gizmoduck{% endblock %} +{% block content %} +

Transactions

New transaction
+
+
{% if filter_errors.date_from %}

{{ filter_errors.date_from }}

{% endif %}
+
{% if filter_errors.date_to %}

{{ filter_errors.date_to }}

{% endif %}
+
{% if filter_errors.account %}

{{ filter_errors.account }}

{% endif %}
+
+
+
+
{% if filter_errors.currency %}

{{ filter_errors.currency }}

{% endif %}
+
+ +
+{% if filter_errors %}{% endif %} +{% if page.object_list %} +
+{% for row in page %}{% endfor %} +
DateDescriptionAccountKindCategoryAmount
{{ row.posting_date|date:"Y-m-d" }}{{ row.description|default:"(No description)" }}{{ row.account.name }}{{ row.get_kind_display }}{{ row.category.name|default:"Uncategorized" }}{{ row.amount }} {{ row.account.currency }}
+{% else %}

No transactions match these filters.

{% endif %} +{% if page.has_other_pages %}{% endif %} +{% endblock %} diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000..3edba10 --- /dev/null +++ b/core/views.py @@ -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.") diff --git a/tests/test_web_workflows.py b/tests/test_web_workflows.py new file mode 100644 index 0000000..3e86506 --- /dev/null +++ b/tests/test_web_workflows.py @@ -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 "

Transactions

" 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