diff --git a/expenses_manager/expenses/tests/test_transfers.py b/expenses_manager/expenses/tests/test_transfers.py new file mode 100644 index 0000000..33f42c3 --- /dev/null +++ b/expenses_manager/expenses/tests/test_transfers.py @@ -0,0 +1,242 @@ +"""Traspasos entre cuentas (is_transfer). + +Un traspaso no es gasto ni ingreso real, pero sí mueve dinero entre cuentas. +De ahí los dos bloques de este fichero, que comprueban lo contrario el uno del +otro: los análisis excluyen los traspasos, los saldos los incluyen. +""" +import pytest +from datetime import date +from decimal import Decimal +from django.urls import reverse +from expenses.models import Account, Category, Expense, Goal, Income + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def savings(user): + return Account.objects.create( + owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True + ) + + +def make_expense(user, account, category, amount, on, is_transfer=False): + return Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal(amount), + date=on, + is_transfer=is_transfer, + ) + + +def make_income(user, account, amount, on, name="Ingreso", is_transfer=False): + return Income.objects.create( + owner=user, + account=account, + name=name, + amount=Decimal(amount), + date=on, + is_transfer=is_transfer, + ) + + +# -------------------------------------------------------------------------- +# Análisis: los traspasos NO cuentan +# -------------------------------------------------------------------------- + +def test_dashboard_kpi_total_ignores_transfer(auth_client, user, account, category): + make_expense(user, account, category, "100", date(2024, 5, 10)) + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("dashboard"), {"year": 2024}) + + assert response.context["kpi_total"] == Decimal("100") + assert response.context["kpi_count"] == 1 + + +def test_dashboard_by_category_ignores_transfer(auth_client, user, account, category): + other = Category.objects.create(owner=user, name="Traspasos") + make_expense(user, account, category, "100", date(2024, 5, 10)) + make_expense(user, account, other, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("dashboard"), {"year": 2024}) + + rows = {row["category__name"]: row["total"] for row in response.context["by_category"]} + assert rows == {category.name: Decimal("100")} + + +def test_dashboard_comparison_ignores_transfer(auth_client, user, account, category): + make_expense(user, account, category, "30", date(2024, 3, 10)) + make_expense(user, account, category, "20", date(2024, 2, 15)) + make_expense(user, account, category, "500", date(2024, 2, 16), is_transfer=True) + + response = auth_client.get( + reverse("dashboard"), {"year": 2024, "month": 3, "compare": "1"} + ) + + assert response.context["kpi_previous_total"] == Decimal("20") + assert response.context["kpi_difference"] == Decimal("10") + + +def test_home_kpi_total_ignores_transfer(auth_client, user, account, category): + today = date.today() + make_expense(user, account, category, "100", today) + make_expense(user, account, category, "400", today, is_transfer=True) + + response = auth_client.get(reverse("home")) + + assert response.context["kpi_total"] == Decimal("100") + assert response.context["kpi_count"] == 1 + + +def test_home_previous_month_comparison_ignores_transfer( + auth_client, user, account, category +): + today = date.today() + # Día 1 para que reste un mes sin caer en un día inexistente. + first = date(today.year, today.month, 1) + prev = date(first.year - 1, 12, 1) if first.month == 1 else date( + first.year, first.month - 1, 1 + ) + + make_expense(user, account, category, "10", prev) + make_expense(user, account, category, "900", prev, is_transfer=True) + + response = auth_client.get(reverse("home")) + + assert response.context["prev_total"] == Decimal("10") + + +def test_expense_list_total_ignores_transfer_but_row_is_listed( + auth_client, user, account, category +): + make_expense(user, account, category, "100", date(2024, 5, 10)) + transfer = make_expense( + user, account, category, "400", date(2024, 5, 11), is_transfer=True + ) + + response = auth_client.get(reverse("expense_list"), {"year": 2024}) + + assert response.context["kpi_total"] == Decimal("100") + assert response.context["kpi_count"] == 1 + # El traspaso sigue en la tabla aunque no sume en el total. + assert transfer in list(response.context["page_obj"]) + + +def test_budget_goal_progress_ignores_transfer(user, account, category): + today = date.today() + make_expense(user, account, category, "50", today) + make_expense(user, account, category, "300", today, is_transfer=True) + + goal = Goal.objects.create( + owner=user, + name="Presupuesto mensual", + target_amount=Decimal("200"), + kind=Goal.KIND_BUDGET, + category=category, + period=Goal.PERIOD_MONTH, + ) + + assert goal.progress == Decimal("50") + assert not goal.is_exceeded() + + +# -------------------------------------------------------------------------- +# Saldos: los traspasos SÍ cuentan +# -------------------------------------------------------------------------- + +def test_transfer_expense_still_lowers_source_balance(user, account, category): + account.initial_balance = Decimal("1000") + account.save() + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + + assert account.current_balance() == Decimal("600") + + +def test_transfer_income_still_raises_target_balance(user, savings): + make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True) + + assert savings.current_balance() == Decimal("400") + + +def test_transfer_keeps_total_balance_unchanged(auth_client, user, account, category, savings): + account.initial_balance = Decimal("1000") + account.save() + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("home")) + + # El dinero cambia de sitio, el patrimonio no se mueve. + assert response.context["total_balance"] == Decimal("1000") + + +def test_saving_goal_progress_counts_the_transfer(user, savings): + """El punto más delicado: un traspaso a la cuenta de ahorro ES el progreso.""" + make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True) + + goal = Goal.objects.create( + owner=user, + name="Fondo de emergencia", + target_amount=Decimal("1000"), + kind=Goal.KIND_SAVING, + account=savings, + ) + + assert goal.progress == Decimal("400") + + +def test_account_list_balance_counts_the_transfer(auth_client, user, account, category): + account.initial_balance = Decimal("1000") + account.save() + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("account_list")) + + rows = {row["account"].id: row["balance"] for row in response.context["account_rows"]} + assert rows[account.id] == Decimal("600") + + +# -------------------------------------------------------------------------- +# Formularios +# -------------------------------------------------------------------------- + +def test_expense_form_can_mark_a_transfer(auth_client, user, account, category): + response = auth_client.post( + reverse("expense_create"), + { + "date": "2024-05-11", + "amount": "400", + "description": "", + "category": category.id, + "account": account.id, + "is_transfer": "on", + }, + ) + + assert response.status_code == 302 + assert Expense.objects.get(owner=user).is_transfer is True + + +def test_income_form_can_mark_a_transfer(auth_client, user, account): + response = auth_client.post( + reverse("income_create"), + { + "account": account.id, + "name": "Traspaso desde nómina", + "amount": "400", + "date": "2024-05-11", + "is_transfer": "on", + }, + ) + + assert response.status_code == 302 + assert Income.objects.get(owner=user).is_transfer is True + + +def test_expense_defaults_to_not_a_transfer(user, account, category): + expense = make_expense(user, account, category, "10", date(2024, 5, 11)) + + assert expense.is_transfer is False