You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
122 lines
4.5 KiB
122 lines
4.5 KiB
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
|
|
|