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, DomainError, create_account, create_category, create_payee, create_plan, create_statement, create_transfer, 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_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"}) assert response.status_code == 200 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) 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 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", [ ("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 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