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