6 changed files with 227 additions and 2 deletions
@ -0,0 +1,29 @@ |
|||||||
|
{% extends "core/base.html" %} |
||||||
|
{% block title %}Dashboard · Gizmoduck{% endblock %} |
||||||
|
{% block content %} |
||||||
|
<div class="heading"><h1>Dashboard</h1><a class="button secondary" href="{% url 'monthly-report' %}?month={{ selection_form.month.value }}&currency={{ selection_form.currency.value }}">Detailed monthly report</a></div> |
||||||
|
<form method="get" class="filters"> |
||||||
|
<div><label for="id_month">Month</label>{{ selection_form.month }}{% for error in selection_form.month.errors %}<p class="error">{{ error }}</p>{% endfor %}</div> |
||||||
|
<div><label for="id_currency">Currency</label>{{ selection_form.currency }}{% for error in selection_form.currency.errors %}<p class="error">{{ error }}</p>{% endfor %}</div> |
||||||
|
<button type="submit">Show month</button> |
||||||
|
</form> |
||||||
|
{% if selection_form.errors %}<p class="error-summary" role="alert">Review the invalid report selection above.</p>{% endif %} |
||||||
|
{% if summary %} |
||||||
|
<h2>Cash flow</h2> |
||||||
|
<dl class="details"> |
||||||
|
<dt>Inflow</dt><dd class="number">{{ summary.inflow|floatformat:"2g" }} {{ summary.currency }}</dd> |
||||||
|
<dt>Expense</dt><dd class="number">{{ summary.expense|floatformat:"2g" }} {{ summary.currency }}</dd> |
||||||
|
<dt>Refund</dt><dd class="number">{{ summary.refund|floatformat:"2g" }} {{ summary.currency }}</dd> |
||||||
|
<dt>Net spending{% if summary.net_spending < 0 %} (negative){% endif %}</dt><dd class="number">{{ summary.net_spending|floatformat:"2g" }} {{ summary.currency }}</dd> |
||||||
|
<dt>Net activity</dt><dd class="number">{{ summary.net_activity|floatformat:"2g" }} {{ summary.currency }}</dd> |
||||||
|
</dl> |
||||||
|
<h2>Spending by category</h2> |
||||||
|
{% if spending_rows %}<div class="table-wrap"><table><thead><tr><th>Category</th><th class="number">Expense</th><th class="number">Refund</th><th class="number">Net spending</th></tr></thead><tbody> |
||||||
|
{% for row in spending_rows %}<tr><td>{{ row.category_name }}</td><td class="number">{{ row.expense|floatformat:"2g" }}</td><td class="number">{{ row.refund|floatformat:"2g" }}</td><td class="number">{{ row.net_spending|floatformat:"2g" }} {{ summary.currency }}</td></tr>{% endfor %} |
||||||
|
</tbody></table></div>{% else %}<p>No spending activity for this month.</p>{% endif %} |
||||||
|
<h2>Account balances</h2> |
||||||
|
{% if balances %}<div class="table-wrap"><table><thead><tr><th>Account</th><th class="number">Balance</th></tr></thead><tbody> |
||||||
|
{% for balance in balances %}<tr><td>{{ balance.account_name }}</td><td class="number">{% if balance.balance < 0 %}<span>Balance (negative): </span>{% endif %}{{ balance.balance|floatformat:"2g" }} {{ summary.currency }}</td></tr>{% endfor %} |
||||||
|
</tbody></table></div>{% else %}<p>No accounts use {{ summary.currency }}.</p>{% endif %} |
||||||
|
{% endif %} |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
{% extends "core/base.html" %} |
||||||
|
{% block title %}Monthly report · Gizmoduck{% endblock %} |
||||||
|
{% block content %} |
||||||
|
<div class="heading"><h1>Monthly report</h1><a class="button secondary" href="{% url 'home' %}?month={{ selection_form.month.value }}&currency={{ selection_form.currency.value }}">Dashboard</a></div> |
||||||
|
<form method="get" class="filters"> |
||||||
|
<div><label for="id_month">Month</label>{{ selection_form.month }}{% for error in selection_form.month.errors %}<p class="error">{{ error }}</p>{% endfor %}</div> |
||||||
|
<div><label for="id_currency">Currency</label>{{ selection_form.currency }}{% for error in selection_form.currency.errors %}<p class="error">{{ error }}</p>{% endfor %}</div> |
||||||
|
<button type="submit">Show report</button> |
||||||
|
</form> |
||||||
|
{% if selection_form.errors %}<p class="error-summary" role="alert">Review the invalid report selection above.</p>{% endif %} |
||||||
|
{% if summary %} |
||||||
|
<h2>Cash flow</h2> |
||||||
|
<dl class="details"> |
||||||
|
<dt>Inflow</dt><dd class="number">{{ summary.inflow|floatformat:"2g" }} {{ summary.currency }}</dd> |
||||||
|
<dt>Expense</dt><dd class="number">{{ summary.expense|floatformat:"2g" }} {{ summary.currency }}</dd> |
||||||
|
<dt>Refund</dt><dd class="number">{{ summary.refund|floatformat:"2g" }} {{ summary.currency }}</dd> |
||||||
|
<dt>Net spending{% if summary.net_spending < 0 %} (negative){% endif %}</dt><dd class="number">{{ summary.net_spending|floatformat:"2g" }} {{ summary.currency }}</dd> |
||||||
|
<dt>Net activity</dt><dd class="number">{{ summary.net_activity|floatformat:"2g" }} {{ summary.currency }}</dd> |
||||||
|
</dl> |
||||||
|
<h2>Spending by category</h2> |
||||||
|
{% if spending_rows %}<div class="table-wrap"><table><thead><tr><th>Category</th><th class="number">Expense</th><th class="number">Refund</th><th class="number">Net spending</th></tr></thead><tbody> |
||||||
|
{% for row in spending_rows %}<tr><td>{{ row.category_name }}</td><td class="number">{{ row.expense|floatformat:"2g" }}</td><td class="number">{{ row.refund|floatformat:"2g" }}</td><td class="number">{{ row.net_spending|floatformat:"2g" }} {{ summary.currency }}</td></tr>{% endfor %} |
||||||
|
</tbody></table></div>{% else %}<p>No spending activity for this month.</p>{% endif %} |
||||||
|
<h2>Account balances</h2> |
||||||
|
{% if balances %}<div class="table-wrap"><table><thead><tr><th>Account</th><th class="number">Balance</th></tr></thead><tbody> |
||||||
|
{% for balance in balances %}<tr><td>{{ balance.account_name }}</td><td class="number">{% if balance.balance < 0 %}<span>Balance (negative): </span>{% endif %}{{ balance.balance|floatformat:"2g" }} {{ summary.currency }}</td></tr>{% endfor %} |
||||||
|
</tbody></table></div>{% else %}<p>No accounts use {{ summary.currency }}.</p>{% endif %} |
||||||
|
{% endif %} |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,106 @@ |
|||||||
|
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 |
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db |
||||||
|
|
||||||
|
|
||||||
|
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 '<option value="BRL" selected>BRL</option>' 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 '<option value="BRL" selected>BRL</option>' 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 |
||||||
Loading…
Reference in new issue