Browse Source

feat: add monthly finance dashboard

master
Codex 2 months ago
parent
commit
b00c81779a
  1. 1
      config/urls.py
  2. 29
      core/forms.py
  3. 29
      core/templates/core/dashboard.html
  4. 29
      core/templates/core/reports/monthly.html
  5. 35
      core/views.py
  6. 106
      tests/test_report_views.py

1
config/urls.py

@ -6,6 +6,7 @@ from core import views
urlpatterns = [
path("", views.home, name="home"),
path("health/", views.health, name="health"),
path("reports/monthly/", views.monthly_report, name="monthly-report"),
path("transactions/", views.transaction_list, name="transaction-list"),
path("transactions/new/", views.transaction_create, name="transaction-create"),
path("transactions/<uuid:transaction_id>/", views.transaction_detail, name="transaction-detail"),

29
core/forms.py

@ -1,3 +1,5 @@
from datetime import date
import re
from uuid import UUID
from django import forms
@ -6,6 +8,9 @@ from finance.models import Account, Category, Payee, Transaction
from finance.queries import active_accounts, active_categories, active_payees
_MONTH_RE = re.compile(r"(?P<year>\d{4})-(?P<month>0[1-9]|1[0-2])", re.ASCII)
def _reference_choices(model, *, include=None):
rows = {Account: active_accounts, Category: active_categories, Payee: active_payees}[model]()
if include is not None:
@ -84,3 +89,27 @@ class CategoryForm(forms.Form):
class PayeeForm(forms.Form):
name = forms.CharField(max_length=255)
class ReportSelectionForm(forms.Form):
month = forms.CharField(
error_messages={"required": "invalid_month"},
widget=forms.TextInput(attrs={"type": "month"}),
)
currency = forms.ChoiceField(
error_messages={"required": "invalid_currency", "invalid_choice": "invalid_currency"}
)
def __init__(self, *args, currencies, **kwargs):
super().__init__(*args, **kwargs)
self.fields["currency"].choices = [(currency, currency) for currency in currencies]
def clean_month(self):
value = self.cleaned_data["month"]
match = _MONTH_RE.fullmatch(value)
if match is None:
raise forms.ValidationError("invalid_month", code="invalid_month")
try:
return date(int(match["year"]), int(match["month"]), 1)
except ValueError as exc:
raise forms.ValidationError("invalid_month", code="invalid_month") from exc

29
core/templates/core/dashboard.html

@ -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 }}&amp;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 %}

29
core/templates/core/reports/monthly.html

@ -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 }}&amp;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 %}

35
core/views.py

@ -3,6 +3,7 @@ from django.core.paginator import Paginator
from django.db import DatabaseError, connection
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_POST
from finance.models import Account, Category, Payee, Transaction
@ -10,12 +11,16 @@ from finance.queries import (
QueryInputError, TransactionFilters, filtered_transactions,
listed_accounts, listed_categories, listed_payees,
)
from finance.reports import account_balances, available_currencies, monthly_summary, spending_by_category
from finance.services import (
Actor, DomainError, annotate_transaction, archive, create_account,
create_category, create_payee, record_transaction,
)
from .forms import AccountForm, AnnotationForm, CategoryForm, ManualTransactionForm, PayeeForm
from .forms import (
AccountForm, AnnotationForm, CategoryForm, ManualTransactionForm, PayeeForm,
ReportSelectionForm,
)
LOCAL_ACTOR = Actor(kind="HUMAN", identifier="local-owner")
@ -43,7 +48,11 @@ def health(request):
def home(request):
return transaction_list(request)
return _monthly_report(request, "core/dashboard.html")
def monthly_report(request):
return _monthly_report(request, "core/reports/monthly.html")
def transaction_list(request):
@ -204,3 +213,25 @@ def _query_message(code):
"invalid_choice": "Select a valid choice.",
"invalid_currency": "Enter a three-letter uppercase currency code.",
}.get(code, "Invalid filter.")
def _monthly_report(request, template_name):
currencies = available_currencies() or ("BRL",)
parameters = {
"month": request.GET.get("month", timezone.localdate().strftime("%Y-%m")),
"currency": request.GET.get("currency", currencies[0]),
}
form = ReportSelectionForm(parameters, currencies=currencies)
context = {"selection_form": form}
status = 200
if form.is_valid():
month = form.cleaned_data["month"]
currency = form.cleaned_data["currency"]
context.update({
"summary": monthly_summary(month, currency),
"spending_rows": spending_by_category(month, currency),
"balances": account_balances(month, currency),
})
else:
status = 400
return render(request, template_name, context, status=status)

106
tests/test_report_views.py

@ -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…
Cancel
Save