From 31f6ea4bd5e78cdcc8d7e37caed3e4d33031dd8a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 20:16:35 -0300 Subject: [PATCH] fix: complete local transaction workflows --- config/settings.py | 1 + config/urls.py | 9 +- core/forms.py | 6 +- core/templates/core/transactions/detail.html | 5 + core/templates/core/transactions/list.html | 8 +- core/views.py | 30 ++-- finance/queries.py | 26 +++- tests/finance/test_queries.py | 19 +++ tests/test_web_workflows.py | 148 ++++++++++++++++++- 9 files changed, 232 insertions(+), 20 deletions(-) diff --git a/config/settings.py b/config/settings.py index 971017a..de97339 100644 --- a/config/settings.py +++ b/config/settings.py @@ -36,6 +36,7 @@ TEMPLATES = [{ }] SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" STATIC_URL = "static/" +LOCAL_SERVE_STATIC = os.environ.get("LOCAL_SERVE_STATIC", "true").casefold() in {"1", "true", "yes"} DATABASES = {"default": { "ENGINE": "django.db.backends.postgresql", "NAME": required("DB_NAME"), diff --git a/config/urls.py b/config/urls.py index 78cba25..3aa6669 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,4 +1,6 @@ -from django.urls import path +from django.conf import settings +from django.contrib.staticfiles.views import serve as serve_static +from django.urls import path, re_path from core import views urlpatterns = [ @@ -18,3 +20,8 @@ urlpatterns = [ path("payees/new/", views.payee_create, name="payee-create"), path("payees//archive/", views.payee_archive, name="payee-archive"), ] + +if settings.LOCAL_SERVE_STATIC: + urlpatterns.append( + re_path(r"^static/(?P.*)$", serve_static, {"insecure": True}, name="local-static") + ) diff --git a/core/forms.py b/core/forms.py index 72bb905..8ba5c9c 100644 --- a/core/forms.py +++ b/core/forms.py @@ -3,13 +3,15 @@ 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 def _reference_choices(model, *, include=None): - rows = model.objects.filter(archived=False) + rows = {Account: active_accounts, Category: active_categories, Payee: active_payees}[model]() 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")] + rows = rows.order_by("name", "id") + return [(str(row.pk), row.name) for row in rows] def _uuid(value): diff --git a/core/templates/core/transactions/detail.html b/core/templates/core/transactions/detail.html index b501a98..3468436 100644 --- a/core/templates/core/transactions/detail.html +++ b/core/templates/core/transactions/detail.html @@ -11,7 +11,12 @@
Kind
{{ transaction.get_kind_display }}
Payee
{{ transaction.payee.name|default:"—" }}
Category
{{ transaction.category.name|default:"Uncategorized" }}
+ {% if transaction.statement %}
Statement
{{ transaction.statement.period_start|date:"Y-m-d" }} to {{ transaction.statement.period_end|date:"Y-m-d" }}; due {{ transaction.statement.due_date|date:"Y-m-d" }}
{% endif %} + {% if transaction.installment_plan %}
Installment plan
{{ transaction.installment_plan.description|default:"(No description)" }} · {{ transaction.installment_number }} of {{ transaction.installment_plan.total_installments }}
{% endif %} + {% if transaction.refund_of %}
Refund of
{{ transaction.refund_of.description|default:"(No description)" }}
{% endif %} + {% if transaction.outgoing_transfer %}
Transfer counterpart
Incoming to {{ transaction.outgoing_transfer.incoming.account.name }}
{% elif transaction.incoming_transfer %}
Transfer counterpart
Outgoing from {{ transaction.incoming_transfer.outgoing.account.name }}
{% endif %} +{% if transaction.refunds.all %}

Refunds

{% endif %}

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/list.html b/core/templates/core/transactions/list.html index 3a78be3..325db0a 100644 --- a/core/templates/core/transactions/list.html +++ b/core/templates/core/transactions/list.html @@ -7,10 +7,10 @@
{% 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 %} @@ -19,5 +19,5 @@ {% for row in page %}{{ 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 }}{% endfor %} {% else %}

No transactions match these filters.

{% endif %} -{% if page.has_other_pages %}{% endif %} +{% if page.has_other_pages %}{% endif %} {% endblock %} diff --git a/core/views.py b/core/views.py index 3edba10..222cc9d 100644 --- a/core/views.py +++ b/core/views.py @@ -6,7 +6,10 @@ 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.queries import ( + QueryInputError, TransactionFilters, filtered_transactions, + listed_accounts, listed_categories, listed_payees, +) from finance.services import ( Actor, DomainError, annotate_transaction, archive, create_account, create_category, create_payee, record_transaction, @@ -53,22 +56,27 @@ def transaction_list(request): 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": Account.objects.order_by("name"), - "categories": Category.objects.order_by("name"), - "payees": Payee.objects.order_by("name"), + "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" - ).prefetch_related("source_records"), + "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}) @@ -113,15 +121,15 @@ def transaction_annotate(request, transaction_id): def account_list(request): - return _reference_list(request, Account, "Accounts", "account-create", "account-archive") + return _reference_list(request, listed_accounts(), "Accounts", "account-create", "account-archive") def category_list(request): - return _reference_list(request, Category, "Categories", "category-create", "category-archive") + return _reference_list(request, listed_categories(), "Categories", "category-create", "category-archive") def payee_list(request): - return _reference_list(request, Payee, "Payees", "payee-create", "payee-archive") + return _reference_list(request, listed_payees(), "Payees", "payee-create", "payee-archive") def account_create(request): @@ -151,10 +159,10 @@ 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): +def _reference_list(request, rows, title, create_url, archive_url): return render(request, "core/references/list.html", { "title": title, - "rows": model.objects.order_by("archived", "name"), + "rows": rows, "create_url": create_url, "archive_url": archive_url, }) diff --git a/finance/queries.py b/finance/queries.py index d4bcace..161d61c 100644 --- a/finance/queries.py +++ b/finance/queries.py @@ -6,7 +6,7 @@ from uuid import UUID from django.db.models import QuerySet -from finance.models import Transaction +from finance.models import Account, Category, Payee, Transaction _CURRENCY_RE = re.compile(r"[A-Z]{3}", re.ASCII) @@ -95,6 +95,30 @@ def filtered_transactions(filters: TransactionFilters) -> QuerySet[Transaction]: return rows.order_by("-posting_date", "-id") +def listed_accounts() -> QuerySet[Account]: + return Account.objects.order_by("archived", "name", "id") + + +def listed_categories() -> QuerySet[Category]: + return Category.objects.order_by("archived", "name", "id") + + +def listed_payees() -> QuerySet[Payee]: + return Payee.objects.order_by("archived", "name", "id") + + +def active_accounts() -> QuerySet[Account]: + return Account.objects.filter(archived=False).order_by("name", "id") + + +def active_categories() -> QuerySet[Category]: + return Category.objects.filter(archived=False).order_by("name", "id") + + +def active_payees() -> QuerySet[Payee]: + return Payee.objects.filter(archived=False).order_by("name", "id") + + def _optional_string(value: object) -> str | None: if value is None: return None diff --git a/tests/finance/test_queries.py b/tests/finance/test_queries.py index 4f90079..e829681 100644 --- a/tests/finance/test_queries.py +++ b/tests/finance/test_queries.py @@ -1,6 +1,7 @@ from datetime import date import pytest +import finance.queries as queries from finance.models import Account, Category, Payee, Transaction from finance.queries import ( @@ -105,3 +106,21 @@ def test_filter_errors_are_reported_together_and_date_order_is_checked(): "account": "invalid_uuid", "date_to": "before_date_from", } + + +def test_reference_lists_include_history_after_active_rows_with_stable_names(): + active_account = make_account("Synthetic Active") + archived_account = make_account("Synthetic Archived") + archived_account.archived = True + archived_account.save(update_fields=["archived"]) + active_category = Category.objects.create(name="Synthetic Active", kind=Category.Kind.EXPENSE) + archived_category = Category.objects.create(name="Synthetic Archived", kind=Category.Kind.EXPENSE, archived=True) + active_payee = Payee.objects.create(name="Synthetic Active") + archived_payee = Payee.objects.create(name="Synthetic Archived", archived=True) + + assert list(queries.listed_accounts()) == [active_account, archived_account] + assert list(queries.listed_categories()) == [active_category, archived_category] + assert list(queries.listed_payees()) == [active_payee, archived_payee] + assert list(queries.active_accounts()) == [active_account] + assert list(queries.active_categories()) == [active_category] + assert list(queries.active_payees()) == [active_payee] diff --git a/tests/test_web_workflows.py b/tests/test_web_workflows.py index 3e86506..7f5b015 100644 --- a/tests/test_web_workflows.py +++ b/tests/test_web_workflows.py @@ -1,12 +1,26 @@ from datetime import date from decimal import Decimal +from html import unescape +import re +from urllib.parse import parse_qs, urlsplit import pytest +from django.conf import settings 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 +from finance.services import ( + Actor, + DomainError, + create_account, + create_category, + create_payee, + create_plan, + create_statement, + create_transfer, + record_transaction, +) pytestmark = pytest.mark.django_db(transaction=True) @@ -51,6 +65,16 @@ def test_root_and_transaction_pages_are_discoverable_and_filter_rows(client): assert "Accounts" in content and "Categories" in content and "Payees" in content +def test_loopback_runtime_serves_the_application_stylesheet(client): + assert settings.DEBUG is False + assert settings.LOCAL_SERVE_STATIC is True + + response = client.get("/static/core/app.css") + + assert response.status_code == 200 + assert b"font-family: system-ui" in b"".join(response.streaming_content) + + def test_invalid_transaction_filters_are_shown_without_querying(client): response = client.get(reverse("transaction-list"), {"date_from": "not-a-date"}) @@ -58,6 +82,45 @@ def test_invalid_transaction_filters_are_shown_without_querying(client): assert "Enter a valid date." in response.content.decode() +def test_transaction_pagination_preserves_filters_and_selected_controls(client): + bank = account() + category = create_category(name="Synthetic Food", kind="EXPENSE", actor=SETUP_ACTOR) + payee = create_payee(name="Synthetic Merchant", actor=SETUP_ACTOR) + Transaction.objects.bulk_create([ + Transaction( + account=bank, + amount=Decimal("-1.00"), + posting_date=date(2026, 1, 15), + kind="EXPENSE", + description=f"Synthetic row {number}", + category=category, + payee=payee, + ) + for number in range(51) + ]) + + response = client.get(reverse("transaction-list"), { + "currency": "BRL", + "payee": str(payee.pk), + "category": str(category.pk), + "uncategorized": "no", + "page": "1", + }) + + content = response.content.decode() + next_href = unescape(re.search(r'href="([^"]+)">Next', content).group(1)) + assert parse_qs(urlsplit(next_href).query) == { + "currency": ["BRL"], + "payee": [str(payee.pk)], + "category": [str(category.pk)], + "uncategorized": ["no"], + "page": ["2"], + } + assert f'' in content + + def test_transaction_detail_shows_relationships_and_provenance(client): bank = account() row = transaction(bank) @@ -79,6 +142,65 @@ def test_transaction_detail_shows_relationships_and_provenance(client): assert "row:2" in content +def test_transaction_details_show_all_financial_relationships(client): + bank = account() + other = account("Synthetic Other") + card = create_account(name="Synthetic Card", kind="CREDIT_CARD", currency="BRL", actor=SETUP_ACTOR) + statement = create_statement( + account_id=card.pk, + period_start=date(2026, 1, 1), + period_end=date(2026, 1, 31), + due_date=date(2026, 2, 10), + actor=SETUP_ACTOR, + ) + plan = create_plan( + account_id=card.pk, + description="Synthetic appliance plan", + total_installments=3, + actor=SETUP_ACTOR, + ) + installment = record_transaction( + account_id=card.pk, + amount="-30.00", + posting_date=date(2026, 1, 15), + kind="EXPENSE", + description="Synthetic installment", + statement_id=statement.pk, + installment_plan_id=plan.pk, + installment_number=2, + actor=SETUP_ACTOR, + ) + refund = record_transaction( + account_id=bank.pk, + amount="10.00", + posting_date=date(2026, 1, 20), + kind="REFUND", + description="Synthetic refund", + refund_of_id=installment.pk, + actor=SETUP_ACTOR, + ) + transfer = create_transfer( + outgoing_account_id=bank.pk, + incoming_account_id=other.pk, + amount="15.00", + outgoing_date=date(2026, 1, 21), + incoming_date=date(2026, 1, 22), + actor=SETUP_ACTOR, + ) + + installment_page = client.get(reverse("transaction-detail", args=[installment.pk])).content.decode() + refund_page = client.get(reverse("transaction-detail", args=[refund.pk])).content.decode() + outgoing_page = client.get(reverse("transaction-detail", args=[transfer.outgoing_id])).content.decode() + incoming_page = client.get(reverse("transaction-detail", args=[transfer.incoming_id])).content.decode() + + assert "Statement" in installment_page and "2026-01-31" in installment_page + assert "Synthetic appliance plan" in installment_page and "2 of 3" in installment_page + assert "Refunds" in installment_page and "Synthetic refund" in installment_page + assert "Refund of" in refund_page and "Synthetic installment" in refund_page + assert "Transfer counterpart" in outgoing_page and "Synthetic Other" in outgoing_page + assert "Transfer counterpart" in incoming_page and "Synthetic Bank" in incoming_page + + @pytest.mark.parametrize( "route,args", [ @@ -213,3 +335,27 @@ def test_domain_errors_render_as_safe_fixed_messages(client): assert "Expense amounts must be negative." in content assert "Synthetic invalid sign" in content assert Transaction.objects.filter(description="Synthetic invalid sign").count() == 0 + + +def test_unknown_domain_error_uses_safe_fallback_without_exposing_code(client, monkeypatch): + bank = account() + + def rejected_write(**_values): + raise DomainError("synthetic_private_error_code") + + monkeypatch.setattr("core.views.record_transaction", rejected_write) + response = client.post(reverse("transaction-create"), { + "account": bank.pk, + "amount": "-24.50", + "posting_date": "2026-01-15", + "occurrence_date": "", + "kind": "EXPENSE", + "description": "Synthetic rejected transaction", + "payee": "", + "category": "", + }) + + content = response.content.decode() + assert response.status_code == 200 + assert "Unable to save this change." in content + assert "synthetic_private_error_code" not in content