diff --git a/config/urls.py b/config/urls.py index 52d44d5..6e29bfc 100644 --- a/config/urls.py +++ b/config/urls.py @@ -26,6 +26,9 @@ urlpatterns = [ path("categories/", views.category_list, name="category-list"), path("categories/new/", views.category_create, name="category-create"), path("categories//archive/", views.category_archive, name="category-archive"), + path("category-groups/", views.category_group_list, name="category-group-list"), + path("category-groups/new/", views.category_group_create, name="category-group-create"), + path("category-groups//archive/", views.category_group_archive, name="category-group-archive"), path("payees/", views.payee_list, name="payee-list"), path("payees/new/", views.payee_create, name="payee-create"), path("payees//archive/", views.payee_archive, name="payee-archive"), diff --git a/core/forms.py b/core/forms.py index 0511892..4e0b673 100644 --- a/core/forms.py +++ b/core/forms.py @@ -4,8 +4,8 @@ from uuid import UUID from django import forms -from finance.models import Account, Category, Payee, Transaction -from finance.queries import active_accounts, active_categories, active_payees +from finance.models import Account, Category, CategoryGroup, Payee, Transaction +from finance.queries import active_accounts, active_categories, active_payees, category_label _MONTH_RE = re.compile(r"(?P\d{4})-(?P0[1-9]|1[0-2])", re.ASCII) @@ -16,7 +16,7 @@ def _reference_choices(model, *, include=None): if include is not None: rows = model.objects.filter(pk=include.pk) | rows rows = rows.order_by("name", "id") - return [(str(row.pk), row.name) for row in rows] + return [(str(row.pk), category_label(row) if model is Category else row.name) for row in rows] def _uuid(value): @@ -87,6 +87,10 @@ class CategoryForm(forms.Form): kind = forms.ChoiceField(choices=Category.Kind.choices) +class CategoryGroupForm(forms.Form): + name = forms.CharField(max_length=255) + + class PayeeForm(forms.Form): name = forms.CharField(max_length=255) diff --git a/core/templates/core/dashboard.html b/core/templates/core/dashboard.html index b3d6ad0..cb1769c 100644 --- a/core/templates/core/dashboard.html +++ b/core/templates/core/dashboard.html @@ -19,7 +19,7 @@

Spending by category

{% if spending_rows %}
-{% for row in spending_rows %}{% endfor %} +{% for row in spending_rows %}{% if row.is_group_total %}{% else %}{% endif %}{% endfor %}
CategoryExpenseRefundNet spending
{{ row.category_name }}{{ row.expense|floatformat:"2g" }}{{ row.refund|floatformat:"2g" }}{{ row.net_spending|floatformat:"2g" }} {{ summary.currency }}
{{ row.category_name }}{% if row.group_name %}↳ {% endif %}{{ 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 %}
diff --git a/core/templates/core/reports/monthly.html b/core/templates/core/reports/monthly.html index cd1b1db..8db2824 100644 --- a/core/templates/core/reports/monthly.html +++ b/core/templates/core/reports/monthly.html @@ -19,7 +19,7 @@

Spending by category

{% if spending_rows %}
AccountBalance
-{% for row in spending_rows %}{% endfor %} +{% for row in spending_rows %}{% if row.is_group_total %}{% else %}{% endif %}{% endfor %}
CategoryExpenseRefundNet spending
{{ row.category_name }}{{ row.expense|floatformat:"2g" }}{{ row.refund|floatformat:"2g" }}{{ row.net_spending|floatformat:"2g" }} {{ summary.currency }}
{{ row.category_name }}{% if row.group_name %}↳ {% endif %}{{ 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 %}
diff --git a/core/templates/core/transactions/list.html b/core/templates/core/transactions/list.html index 325db0a..11770db 100644 --- a/core/templates/core/transactions/list.html +++ b/core/templates/core/transactions/list.html @@ -16,7 +16,7 @@ {% if filter_errors %}{% endif %} {% if page.object_list %}
AccountBalance
-{% for row in page %}{% endfor %} +{% for row in page %}{% endfor %}
DateDescriptionAccountKindCategoryAmount
{{ row.posting_date|date:"Y-m-d" }}{{ row.description|default:"(No description)" }}{{ row.account.name }}{{ row.get_kind_display }}{{ row.category.name|default:"Uncategorized" }}{{ row.amount }} {{ row.account.currency }}
{{ row.posting_date|date:"Y-m-d" }}{{ row.description|default:"(No description)" }}{{ row.account.name }}{{ row.get_kind_display }}{% if row.category %}{% if row.category.group %}{{ row.category.group.name }} · {% endif %}{{ row.category.name }}{% else %}Uncategorized{% endif %}{{ row.amount }} {{ row.account.currency }}
{% else %}

No transactions match these filters.

{% endif %} {% if page.has_other_pages %}{% endif %} diff --git a/core/views.py b/core/views.py index 90acd02..25cbd1f 100644 --- a/core/views.py +++ b/core/views.py @@ -11,7 +11,7 @@ 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 +from finance.models import Account, Category, CategoryGroup, Payee, Transaction from finance.exports import build_export from finance.queries import ( QueryInputError, TransactionFilters, filtered_transactions, @@ -25,13 +25,13 @@ from finance.reconciliation import ( 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, create_reconciliation_checkpoint, + create_category, create_category_group, create_payee, create_reconciliation_checkpoint, record_transaction, ) from ingestion import guided_imports from .forms import ( - AccountForm, AnnotationForm, CategoryForm, ManualTransactionForm, PayeeForm, + AccountForm, AnnotationForm, CategoryForm, CategoryGroupForm, ManualTransactionForm, PayeeForm, ImportMappingForm, ReconciliationForm, ReportSelectionForm, ) @@ -375,6 +375,10 @@ def category_list(request): return _reference_list(request, listed_categories(), "Categories", "category-create", "category-archive") +def category_group_list(request): + return _reference_list(request, CategoryGroup.objects.order_by("archived", "name", "id"), "Category groups", "category-group-create", "category-group-archive") + + def payee_list(request): return _reference_list(request, listed_payees(), "Payees", "payee-create", "payee-archive") @@ -387,6 +391,10 @@ def category_create(request): return _reference_create(request, CategoryForm, create_category, "New category", "category-list") +def category_group_create(request): + return _reference_create(request, CategoryGroupForm, create_category_group, "New category group", "category-group-list") + + def payee_create(request): return _reference_create(request, PayeeForm, create_payee, "New payee", "payee-list") @@ -401,6 +409,11 @@ def category_archive(request, reference_id): return _reference_archive(request, Category, reference_id, "category-list") +@require_POST +def category_group_archive(request, reference_id): + return _reference_archive(request, CategoryGroup, reference_id, "category-group-list") + + @require_POST def payee_archive(request, reference_id): return _reference_archive(request, Payee, reference_id, "payee-list") diff --git a/finance/exports.py b/finance/exports.py index 8ede348..9cf6f49 100644 --- a/finance/exports.py +++ b/finance/exports.py @@ -20,6 +20,7 @@ from finance.models import ( Account, CardStatement, Category, + CategoryGroup, InstallmentPlan, Payee, SourceRecord, @@ -69,10 +70,15 @@ SCHEMAS = ( Payee, ("id", "name", "archived", "created_at", "updated_at"), ), + ( + "category_groups.csv", + CategoryGroup, + ("id", "name", "archived", "created_at", "updated_at"), + ), ( "categories.csv", Category, - ("id", "name", "kind", "archived", "created_at", "updated_at"), + ("id", "name", "kind", "group_id", "archived", "created_at", "updated_at"), ), ( "statements.csv", diff --git a/finance/queries.py b/finance/queries.py index 161d61c..d63107c 100644 --- a/finance/queries.py +++ b/finance/queries.py @@ -4,7 +4,7 @@ import re from typing import Mapping from uuid import UUID -from django.db.models import QuerySet +from django.db.models import Q, QuerySet from finance.models import Account, Category, Payee, Transaction @@ -74,7 +74,7 @@ class TransactionFilters: def filtered_transactions(filters: TransactionFilters) -> QuerySet[Transaction]: rows = Transaction.objects.select_related( - "account", "payee", "category", "statement", "refund_of", "installment_plan" + "account", "payee", "category", "category__group", "statement", "refund_of", "installment_plan" ) if filters.date_from is not None: rows = rows.filter(posting_date__gte=filters.date_from) @@ -100,7 +100,7 @@ def listed_accounts() -> QuerySet[Account]: def listed_categories() -> QuerySet[Category]: - return Category.objects.order_by("archived", "name", "id") + return Category.objects.select_related("group").order_by("group__name", "archived", "name", "id") def listed_payees() -> QuerySet[Payee]: @@ -112,7 +112,11 @@ def active_accounts() -> QuerySet[Account]: def active_categories() -> QuerySet[Category]: - return Category.objects.filter(archived=False).order_by("name", "id") + return Category.objects.filter(archived=False).filter(Q(group__isnull=True) | Q(group__archived=False)).select_related("group").order_by("group__name", "name", "id") + + +def category_label(category: Category) -> str: + return f"{category.group.name} · {category.name}" if category.group_id else category.name def active_payees() -> QuerySet[Payee]: diff --git a/finance/reports.py b/finance/reports.py index 050c09a..13a4f4e 100644 --- a/finance/reports.py +++ b/finance/reports.py @@ -35,7 +35,9 @@ class MonthlySummary: @dataclass(frozen=True) class SpendingCategory: category_id: UUID | None + group_name: str | None category_name: str + is_group_total: bool expense: Decimal refund: Decimal net_spending: Decimal @@ -83,23 +85,33 @@ def spending_by_category(month: date, currency: str) -> tuple[SpendingCategory, grouped = ( _monthly_rows(month, month_after, currency) .filter(kind__in=[Transaction.Kind.EXPENSE, Transaction.Kind.REFUND]) - .values("category_id", "category__name") + .values("category_id", "category__name", "category__group__name") .annotate( expense=_sum(-F("amount"), Q(kind=Transaction.Kind.EXPENSE)), refund=_sum("amount", Q(kind=Transaction.Kind.REFUND)), ) ) - rows = tuple( + leaves = tuple( SpendingCategory( category_id=row["category_id"], + group_name=row["category__group__name"], category_name=row["category__name"] or "Uncategorized", + is_group_total=False, expense=row["expense"], refund=row["refund"], net_spending=row["expense"] - row["refund"], ) for row in grouped ) - return tuple(sorted(rows, key=lambda row: (-row.net_spending, row.category_name))) + grouped_rows = [] + for group_name in sorted({row.group_name for row in leaves if row.group_name}): + children = [row for row in leaves if row.group_name == group_name] + grouped_rows.append(SpendingCategory(None, group_name, group_name, True, + sum((row.expense for row in children), _ZERO), sum((row.refund for row in children), _ZERO), + sum((row.net_spending for row in children), _ZERO))) + grouped_rows.extend(sorted(children, key=lambda row: (-row.net_spending, row.category_name))) + grouped_rows.extend(sorted((row for row in leaves if not row.group_name), key=lambda row: (-row.net_spending, row.category_name))) + return tuple(grouped_rows) def account_balances(month: date, currency: str) -> tuple[AccountBalance, ...]: diff --git a/finance/services/__init__.py b/finance/services/__init__.py index bf03072..3e26677 100644 --- a/finance/services/__init__.py +++ b/finance/services/__init__.py @@ -1,8 +1,8 @@ """Supported financial writes. Direct ORM mutation bypasses cross-row invariants.""" from .base import Actor, DomainError -from .reference import create_account, create_payee, create_category, archive, create_statement, create_plan +from .reference import create_account, create_payee, create_category, create_category_group, archive, create_statement, create_plan from .posting import record_transaction, annotate_transaction, attach_source from .reconciliation import create_reconciliation_checkpoint from .transfers import create_transfer, match_transfer -__all__ = ["Actor", "DomainError", "create_account", "create_payee", "create_category", "archive", "create_statement", "create_plan", "record_transaction", "annotate_transaction", "attach_source", "create_reconciliation_checkpoint", "create_transfer", "match_transfer"] +__all__ = ["Actor", "DomainError", "create_account", "create_payee", "create_category", "create_category_group", "archive", "create_statement", "create_plan", "record_transaction", "annotate_transaction", "attach_source", "create_reconciliation_checkpoint", "create_transfer", "match_transfer"] diff --git a/finance/services/posting.py b/finance/services/posting.py index ccd5244..70c9bab 100644 --- a/finance/services/posting.py +++ b/finance/services/posting.py @@ -1,4 +1,4 @@ -from finance.models import Account, Payee, Category, CardStatement, InstallmentPlan, Transaction, SourceRecord +from finance.models import Account, Payee, Category, CategoryGroup, CardStatement, InstallmentPlan, Transaction, SourceRecord from .base import DomainError, identity, lock, money, save, write @@ -10,13 +10,20 @@ def validate_category(kind, category): raise DomainError("category_kind_mismatch") +def lock_category(identifier): + category = lock(Category, identifier, active=True) + if category.group_id is not None: + lock(CategoryGroup, category.group_id, active=True) + return category + + def record(*, account_id, amount, posting_date, kind, description="", occurrence_date=None, payee_id=None, category_id=None, statement_id=None, refund_of_id=None, installment_plan_id=None, installment_number=None): amount = money(amount) account = lock(Account, account_id, active=True) payee = lock(Payee, payee_id, active=True) if payee_id is not None else None - category = lock(Category, category_id, active=True) if category_id is not None else None + category = lock_category(category_id) if category_id is not None else None validate_category(kind, category) statement = lock(CardStatement, statement_id) if statement_id is not None else None if statement and (account.kind != "CREDIT_CARD" or statement.account_id != account.pk): @@ -55,7 +62,7 @@ def annotate_transaction(*, transaction_id, **changes): for field, model in (("payee_id", Payee), ("category_id", Category)): if field in changes: value = changes[field] - references[field] = lock(model, value, active=True) if value is not None else None + references[field] = (lock_category(value) if model is Category else lock(model, value, active=True)) if value is not None else None obj = lock(Transaction, transaction_id) if "description" in changes: obj.description = changes["description"] diff --git a/finance/services/reference.py b/finance/services/reference.py index 615e9ac..5ce2fe4 100644 --- a/finance/services/reference.py +++ b/finance/services/reference.py @@ -1,4 +1,4 @@ -from finance.models import Account, Payee, Category, CardStatement, InstallmentPlan +from finance.models import Account, Payee, Category, CategoryGroup, CardStatement, InstallmentPlan from .base import DomainError, lock, money, save, write @@ -20,13 +20,19 @@ def create_payee(*, name): @write("create_category") -def create_category(*, name, kind): - return save(Category(name=normalized_name(name), kind=kind)), ["name", "kind"] +def create_category(*, name, kind, group_id=None): + group = lock(CategoryGroup, group_id, active=True) if group_id is not None else None + return save(Category(name=normalized_name(name), kind=kind, group=group)), ["name", "kind", "group"] + + +@write("create_category_group") +def create_category_group(*, name): + return save(CategoryGroup(name=normalized_name(name))), ["name"] @write("archive") def archive(entity): - if type(entity) not in (Account, Payee, Category): + if type(entity) not in (Account, Payee, Category, CategoryGroup): raise DomainError("invalid_reference") obj = lock(type(entity), entity.pk) obj.archived = True diff --git a/tests/finance/test_exports.py b/tests/finance/test_exports.py index 0949c61..3556177 100644 --- a/tests/finance/test_exports.py +++ b/tests/finance/test_exports.py @@ -18,6 +18,7 @@ from finance.models import ( AuditEvent, CardStatement, Category, + CategoryGroup, InstallmentPlan, Payee, SourceRecord, @@ -31,6 +32,7 @@ pytestmark = pytest.mark.django_db(transaction=True) EXPECTED_FILES = [ "accounts.csv", "payees.csv", + "category_groups.csv", "categories.csv", "statements.csv", "installment_plans.csv", @@ -41,7 +43,8 @@ EXPECTED_FILES = [ EXPECTED_HEADERS = { "accounts.csv": "id,name,kind,currency,archived,created_at,updated_at", "payees.csv": "id,name,archived,created_at,updated_at", - "categories.csv": "id,name,kind,archived,created_at,updated_at", + "category_groups.csv": "id,name,archived,created_at,updated_at", + "categories.csv": "id,name,kind,group_id,archived,created_at,updated_at", "statements.csv": ( "id,account_id,period_start,period_end,due_date,statement_balance,created_at,updated_at" ), @@ -83,10 +86,15 @@ def seed_complete_export(): id=UUID("20000000-0000-4000-8000-000000000001"), name="Synthetic Merchant", ) + group = CategoryGroup.objects.create( + id=UUID("30000000-0000-4000-8000-000000000002"), + name="Synthetic Household", + ) category = Category.objects.create( id=UUID("30000000-0000-4000-8000-000000000001"), name="Synthetic Food", kind=Category.Kind.EXPENSE, + group=group, ) statement = CardStatement.objects.create( id=UUID("40000000-0000-4000-8000-000000000001"),