from datetime import date from decimal import Decimal import pytest from finance.models import Account, Category, Transaction from finance.reports import ( ReportInputError, account_balances, available_currencies, monthly_summary, spending_by_category, ) pytestmark = pytest.mark.django_db def make_account(name="Synthetic Bank", *, kind=Account.Kind.BANK, currency="BRL"): return Account.objects.create(name=name, kind=kind, 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_summary_counts_card_purchase_once_and_refund_in_posting_month(): bank = make_account() card = make_account("Synthetic Card", kind=Account.Kind.CREDIT_CARD) post(bank, "500.00", Transaction.Kind.OPENING, date(2025, 12, 31)) post(bank, "1000.00", Transaction.Kind.INCOME, date(2026, 1, 2)) post(card, "-300.00", Transaction.Kind.EXPENSE, date(2026, 1, 3)) post(bank, "-300.00", Transaction.Kind.TRANSFER, date(2026, 1, 20)) post(card, "300.00", Transaction.Kind.TRANSFER, date(2026, 1, 20)) post(card, "50.00", Transaction.Kind.REFUND, date(2026, 1, 25)) post(card, "20.00", Transaction.Kind.REFUND, date(2026, 2, 1)) result = monthly_summary(date(2026, 1, 1), "BRL") assert result.inflow == Decimal("1000.00") assert result.expense == Decimal("300.00") assert result.refund == Decimal("50.00") assert result.net_spending == Decimal("250.00") assert result.net_activity == Decimal("750.00") def test_spending_has_uncategorized_bucket_and_never_combines_currency(): bank = make_account() usd = make_account("Synthetic USD", currency="USD") food = Category.objects.create(name="Synthetic Food", kind=Category.Kind.EXPENSE) post(bank, "-100.00", Transaction.Kind.EXPENSE, date(2026, 1, 2), category=food) post(bank, "20.00", Transaction.Kind.REFUND, date(2026, 1, 3), category=food) post(bank, "-20.00", Transaction.Kind.EXPENSE, date(2026, 1, 4)) post(usd, "-999.00", Transaction.Kind.EXPENSE, date(2026, 1, 5), category=food) rows = spending_by_category(date(2026, 1, 1), "BRL") assert [(row.category_name, row.net_spending) for row in rows] == [ ("Synthetic Food", Decimal("80.00")), ("Uncategorized", Decimal("20.00")), ] assert rows[0].expense == Decimal("100.00") assert rows[0].refund == Decimal("20.00") def test_empty_month_returns_explicit_zero_totals_and_no_spending_rows(): result = monthly_summary(date(2026, 1, 1), "BRL") assert result.inflow == Decimal("0.00") assert result.expense == Decimal("0.00") assert result.refund == Decimal("0.00") assert result.net_spending == Decimal("0.00") assert result.net_activity == Decimal("0.00") assert spending_by_category(date(2026, 1, 1), "BRL") == () def test_account_balances_include_openings_and_transfers_through_month_end(): beta = make_account("Synthetic Beta") alpha = make_account("Synthetic Alpha") usd = make_account("Synthetic USD", currency="USD") post(alpha, "100.00", Transaction.Kind.OPENING, date(2025, 12, 1)) post(alpha, "-20.00", Transaction.Kind.TRANSFER, date(2026, 1, 20)) post(beta, "20.00", Transaction.Kind.TRANSFER, date(2026, 1, 20)) post(beta, "-5.00", Transaction.Kind.EXPENSE, date(2026, 2, 1)) post(usd, "1000.00", Transaction.Kind.OPENING, date(2025, 12, 1)) rows = account_balances(date(2026, 1, 1), "BRL") assert [(row.account_name, row.balance) for row in rows] == [ ("Synthetic Alpha", Decimal("80.00")), ("Synthetic Beta", Decimal("20.00")), ] def test_available_currencies_are_distinct_and_sorted(): make_account("Synthetic BRL", currency="BRL") make_account("Synthetic USD", currency="USD") make_account("Synthetic BRL 2", currency="BRL") assert available_currencies() == ("BRL", "USD") @pytest.mark.parametrize( ("month", "currency", "errors"), [ (date(2026, 1, 2), "BRL", {"month": "must_be_first_day"}), (date(9999, 12, 1), "BRL", {"month": "invalid_month"}), (date(2026, 1, 1), "brl", {"currency": "invalid_currency"}), ("2026-01", "BRL", {"month": "invalid_date"}), ], ) def test_report_inputs_are_validated(month, currency, errors): with pytest.raises(ReportInputError) as caught: monthly_summary(month, currency) assert caught.value.errors == errors