from datetime import date from decimal import Decimal import pytest from django.urls import reverse from django.utils import timezone from finance.models import Account, Category, Transaction from finance.services import Actor, create_reconciliation_checkpoint, record_transaction pytestmark = pytest.mark.django_db SETUP_ACTOR = Actor(kind="AGENT", identifier="synthetic-report-view-test") def make_account(name="Synthetic Bank", *, currency="BRL"): return Account.objects.create(name=name, kind=Account.Kind.BANK, currency=currency) def post(account, amount, kind, posting_date, *, category=None): return Transaction.objects.create( account=account, amount=Decimal(amount), kind=kind, posting_date=posting_date, category=category, ) def test_monthly_report_renders_exact_totals_category_rows_and_account_balances(client): bank = make_account() savings = make_account("Synthetic Savings") food = Category.objects.create(name="Synthetic Food", kind=Category.Kind.EXPENSE) post(bank, "500.00", Transaction.Kind.OPENING, date(2025, 12, 31)) post(bank, "1000.00", Transaction.Kind.INCOME, date(2026, 1, 2)) post(bank, "-300.00", Transaction.Kind.EXPENSE, date(2026, 1, 3), category=food) post(bank, "50.00", Transaction.Kind.REFUND, date(2026, 1, 25), category=food) post(bank, "-20.00", Transaction.Kind.EXPENSE, date(2026, 1, 26)) post(bank, "-200.00", Transaction.Kind.TRANSFER, date(2026, 1, 27)) post(savings, "200.00", Transaction.Kind.TRANSFER, date(2026, 1, 27)) response = client.get(reverse("monthly-report"), {"month": "2026-01", "currency": "BRL"}) assert response.status_code == 200 body = response.content.decode() assert "Monthly report" in body assert "1,000.00" in body assert "320.00" in body assert "50.00" in body assert "270.00" in body assert "730.00" in body assert "Synthetic Food" in body assert "Uncategorized" in body assert "Synthetic Bank" in body and "1,030.00" in body assert "Synthetic Savings" in body and "200.00" in body @pytest.mark.parametrize( ("parameters", "expected_error"), [ ({"month": "01/2026", "currency": "BRL"}, "invalid_month"), ({"month": "2026-01", "currency": "brl"}, "invalid_currency"), ], ) def test_invalid_report_selection_is_visible_and_not_silently_replaced(client, parameters, expected_error): response = client.get(reverse("monthly-report"), parameters) assert response.status_code == 400 assert expected_error in response.content.decode() def test_home_defaults_to_local_month_and_first_available_currency(client, monkeypatch): make_account("Synthetic USD", currency="USD") make_account("Synthetic BRL", currency="BRL") monkeypatch.setattr(timezone, "localdate", lambda: date(2026, 4, 17)) response = client.get(reverse("home")) assert response.status_code == 200 body = response.content.decode() assert "Dashboard" in body assert 'value="2026-04"' in body assert '' in body assert "Transactions" in body def test_home_uses_brl_when_no_account_currency_exists(client, monkeypatch): monkeypatch.setattr(timezone, "localdate", lambda: date(2026, 4, 17)) response = client.get(reverse("home")) assert response.status_code == 200 assert '' in response.content.decode() def test_monthly_report_rejects_month_without_representable_next_month(client): response = client.get( reverse("monthly-report"), {"month": "9999-12", "currency": "BRL"}, ) assert response.status_code == 400 assert "invalid_month" in response.content.decode() def test_reports_are_linked_from_non_dashboard_pages(client): response = client.get(reverse("transaction-list")) assert response.status_code == 200 assert f'href="{reverse("monthly-report")}">Reports' in response.content.decode() def test_negative_spending_and_balance_keep_their_sign_and_are_labeled(client): bank = make_account() post(bank, "-100.00", Transaction.Kind.OPENING, date(2025, 12, 31)) post(bank, "25.00", Transaction.Kind.REFUND, date(2026, 1, 3)) response = client.get(reverse("monthly-report"), {"month": "2026-01", "currency": "BRL"}) body = response.content.decode() assert response.status_code == 200 assert "Net spending (negative)" in body assert "-25.00" in body assert "Balance (negative)" in body assert "-75.00" in body @pytest.mark.django_db(transaction=True) def test_dashboard_displays_current_reconciliation_status_after_backdated_activity(client): bank = make_account("Synthetic reconciled bank") record_transaction( account_id=bank.pk, amount=Decimal("-40.00"), posting_date=date(2026, 1, 15), kind="EXPENSE", actor=SETUP_ACTOR, ) create_reconciliation_checkpoint( account_id=bank.pk, as_of_date=date(2026, 1, 31), institution_balance=Decimal("-40.00"), actor=SETUP_ACTOR, ) record_transaction( account_id=bank.pk, amount=Decimal("-2.50"), posting_date=date(2026, 1, 20), kind="EXPENSE", actor=SETUP_ACTOR, ) response = client.get(reverse("home"), {"month": "2026-01", "currency": "BRL"}) assert response.status_code == 200 body = response.content.decode() assert "Reconciliation status" in body assert "Synthetic reconciled bank" in body assert "Changed since reconciliation" in body assert "2.50 BRL" in body assert reverse("reconciliation-history", args=[bank.pk]) in body