Browse Source

feat: support category groups

master
Codex 2 months ago
parent
commit
ac86f01887
  1. 3
      config/urls.py
  2. 10
      core/forms.py
  3. 2
      core/templates/core/dashboard.html
  4. 2
      core/templates/core/reports/monthly.html
  5. 2
      core/templates/core/transactions/list.html
  6. 19
      core/views.py
  7. 8
      finance/exports.py
  8. 12
      finance/queries.py
  9. 18
      finance/reports.py
  10. 4
      finance/services/__init__.py
  11. 13
      finance/services/posting.py
  12. 14
      finance/services/reference.py
  13. 10
      tests/finance/test_exports.py

3
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/<uuid:reference_id>/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/<uuid:reference_id>/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/<uuid:reference_id>/archive/", views.payee_archive, name="payee-archive"),

10
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<year>\d{4})-(?P<month>0[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)

2
core/templates/core/dashboard.html

@ -19,7 +19,7 @@
</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 %}
{% for row in spending_rows %}<tr>{% if row.is_group_total %}<th>{{ row.category_name }}</th>{% else %}<td>{% if row.group_name %}↳ {% endif %}{{ row.category_name }}</td>{% endif %}<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>

2
core/templates/core/reports/monthly.html

@ -19,7 +19,7 @@
</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 %}
{% for row in spending_rows %}<tr>{% if row.is_group_total %}<th>{{ row.category_name }}</th>{% else %}<td>{% if row.group_name %}↳ {% endif %}{{ row.category_name }}</td>{% endif %}<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>

2
core/templates/core/transactions/list.html

@ -16,7 +16,7 @@
{% if filter_errors %}<p class="error" role="alert">Review the invalid filters above.</p>{% endif %}
{% if page.object_list %}
<div class="table-wrap"><table><thead><tr><th>Date</th><th>Description</th><th>Account</th><th>Kind</th><th>Category</th><th class="number">Amount</th></tr></thead><tbody>
{% for row in page %}<tr><td>{{ row.posting_date|date:"Y-m-d" }}</td><td><a href="{% url 'transaction-detail' row.pk %}">{{ row.description|default:"(No description)" }}</a></td><td>{{ row.account.name }}</td><td>{{ row.get_kind_display }}</td><td>{{ row.category.name|default:"Uncategorized" }}</td><td class="number">{{ row.amount }} {{ row.account.currency }}</td></tr>{% endfor %}
{% for row in page %}<tr><td>{{ row.posting_date|date:"Y-m-d" }}</td><td><a href="{% url 'transaction-detail' row.pk %}">{{ row.description|default:"(No description)" }}</a></td><td>{{ row.account.name }}</td><td>{{ row.get_kind_display }}</td><td>{% if row.category %}{% if row.category.group %}{{ row.category.group.name }} · {% endif %}{{ row.category.name }}{% else %}Uncategorized{% endif %}</td><td class="number">{{ row.amount }} {{ row.account.currency }}</td></tr>{% endfor %}
</tbody></table></div>
{% else %}<p>No transactions match these filters.</p>{% endif %}
{% if page.has_other_pages %}<nav aria-label="Pagination">{% if page.has_previous %}<a href="?{% if pagination_query %}{{ pagination_query }}&amp;{% endif %}page={{ page.previous_page_number }}">Previous</a>{% endif %}<span>Page {{ page.number }} of {{ page.paginator.num_pages }}</span>{% if page.has_next %}<a href="?{% if pagination_query %}{{ pagination_query }}&amp;{% endif %}page={{ page.next_page_number }}">Next</a>{% endif %}</nav>{% endif %}

19
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")

8
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",

12
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]:

18
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, ...]:

4
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"]

13
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"]

14
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

10
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"),

Loading…
Cancel
Save