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