diff --git a/config/urls.py b/config/urls.py index 3aa6669..1805e86 100644 --- a/config/urls.py +++ b/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//", views.transaction_detail, name="transaction-detail"), diff --git a/core/forms.py b/core/forms.py index 8ba5c9c..4587b2e 100644 --- a/core/forms.py +++ b/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\d{4})-(?P0[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 diff --git a/core/templates/core/dashboard.html b/core/templates/core/dashboard.html new file mode 100644 index 0000000..232c803 --- /dev/null +++ b/core/templates/core/dashboard.html @@ -0,0 +1,29 @@ +{% extends "core/base.html" %} +{% block title %}Dashboard · Gizmoduck{% endblock %} +{% block content %} + +
+
{{ selection_form.month }}{% for error in selection_form.month.errors %}

{{ error }}

{% endfor %}
+
{{ selection_form.currency }}{% for error in selection_form.currency.errors %}

{{ error }}

{% endfor %}
+ +
+{% if selection_form.errors %}{% endif %} +{% if summary %} +

Cash flow

+
+
Inflow
{{ summary.inflow|floatformat:"2g" }} {{ summary.currency }}
+
Expense
{{ summary.expense|floatformat:"2g" }} {{ summary.currency }}
+
Refund
{{ summary.refund|floatformat:"2g" }} {{ summary.currency }}
+
Net spending{% if summary.net_spending < 0 %} (negative){% endif %}
{{ summary.net_spending|floatformat:"2g" }} {{ summary.currency }}
+
Net activity
{{ summary.net_activity|floatformat:"2g" }} {{ summary.currency }}
+
+

Spending by category

+{% if spending_rows %}
+{% for row in spending_rows %}{% endfor %} +
CategoryExpenseRefundNet spending
{{ row.category_name }}{{ row.expense|floatformat:"2g" }}{{ row.refund|floatformat:"2g" }}{{ row.net_spending|floatformat:"2g" }} {{ summary.currency }}
{% else %}

No spending activity for this month.

{% endif %} +

Account balances

+{% if balances %}
+{% for balance in balances %}{% endfor %} +
AccountBalance
{{ balance.account_name }}{% if balance.balance < 0 %}Balance (negative): {% endif %}{{ balance.balance|floatformat:"2g" }} {{ summary.currency }}
{% else %}

No accounts use {{ summary.currency }}.

{% endif %} +{% endif %} +{% endblock %} diff --git a/core/templates/core/reports/monthly.html b/core/templates/core/reports/monthly.html new file mode 100644 index 0000000..cd1b1db --- /dev/null +++ b/core/templates/core/reports/monthly.html @@ -0,0 +1,29 @@ +{% extends "core/base.html" %} +{% block title %}Monthly report · Gizmoduck{% endblock %} +{% block content %} +

Monthly report

Dashboard
+
+
{{ selection_form.month }}{% for error in selection_form.month.errors %}

{{ error }}

{% endfor %}
+
{{ selection_form.currency }}{% for error in selection_form.currency.errors %}

{{ error }}

{% endfor %}
+ +
+{% if selection_form.errors %}{% endif %} +{% if summary %} +

Cash flow

+
+
Inflow
{{ summary.inflow|floatformat:"2g" }} {{ summary.currency }}
+
Expense
{{ summary.expense|floatformat:"2g" }} {{ summary.currency }}
+
Refund
{{ summary.refund|floatformat:"2g" }} {{ summary.currency }}
+
Net spending{% if summary.net_spending < 0 %} (negative){% endif %}
{{ summary.net_spending|floatformat:"2g" }} {{ summary.currency }}
+
Net activity
{{ summary.net_activity|floatformat:"2g" }} {{ summary.currency }}
+
+

Spending by category

+{% if spending_rows %}
+{% for row in spending_rows %}{% endfor %} +
CategoryExpenseRefundNet spending
{{ row.category_name }}{{ row.expense|floatformat:"2g" }}{{ row.refund|floatformat:"2g" }}{{ row.net_spending|floatformat:"2g" }} {{ summary.currency }}
{% else %}

No spending activity for this month.

{% endif %} +

Account balances

+{% if balances %}
+{% for balance in balances %}{% endfor %} +
AccountBalance
{{ balance.account_name }}{% if balance.balance < 0 %}Balance (negative): {% endif %}{{ balance.balance|floatformat:"2g" }} {{ summary.currency }}
{% else %}

No accounts use {{ summary.currency }}.

{% endif %} +{% endif %} +{% endblock %} diff --git a/core/views.py b/core/views.py index 222cc9d..5287a91 100644 --- a/core/views.py +++ b/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) diff --git a/tests/test_report_views.py b/tests/test_report_views.py new file mode 100644 index 0000000..1458122 --- /dev/null +++ b/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 '' 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 '' 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