From f2a439612eb785afba56965923d1333d759d5168 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 18:14:42 -0300 Subject: [PATCH] feat: add category groups and tags --- .../0004_category_groups_and_tags.py | 40 ++++++++++++++++++ finance/models/__init__.py | 6 +-- finance/models/ledger.py | 1 + finance/models/reference.py | 21 +++++++++- .../test_category_hierarchy_migration.py | 41 +++++++++++++++++++ tests/finance/test_constraints.py | 11 ++++- 6 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 finance/migrations/0004_category_groups_and_tags.py create mode 100644 tests/finance/test_category_hierarchy_migration.py diff --git a/finance/migrations/0004_category_groups_and_tags.py b/finance/migrations/0004_category_groups_and_tags.py new file mode 100644 index 0000000..01d7498 --- /dev/null +++ b/finance/migrations/0004_category_groups_and_tags.py @@ -0,0 +1,40 @@ +# Generated manually because the container image has no write access to the workspace. + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("finance", "0003_reconciliationcheckpoint")] + + operations = [ + migrations.CreateModel( + name="Tag", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=255, unique=True)), + ("archived", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name="CategoryGroup", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=255, unique=True)), + ("archived", models.BooleanField(default=False)), + ], + ), + migrations.AlterField(model_name="category", name="name", field=models.CharField(max_length=255)), + migrations.AddField(model_name="category", name="group", field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="categories", to="finance.categorygroup")), + migrations.AddConstraint(model_name="categorygroup", constraint=models.CheckConstraint(condition=models.Q(("name__regex", "\\S")), name="category_group_nonblank_name")), + migrations.AddConstraint(model_name="tag", constraint=models.CheckConstraint(condition=models.Q(("name__regex", "\\S")), name="tag_nonblank_name")), + migrations.AddConstraint(model_name="category", constraint=models.UniqueConstraint(fields=("group", "name"), name="category_group_name_unique")), + migrations.AddConstraint(model_name="category", constraint=models.UniqueConstraint(condition=models.Q(("group__isnull", True)), fields=("name",), name="category_ungrouped_name_unique")), + migrations.AddField(model_name="transaction", name="tags", field=models.ManyToManyField(blank=True, related_name="transactions", to="finance.tag")), + ] diff --git a/finance/models/__init__.py b/finance/models/__init__.py index bf9d0f5..6552535 100644 --- a/finance/models/__init__.py +++ b/finance/models/__init__.py @@ -1,9 +1,9 @@ from .ledger import CardStatement, InstallmentPlan, Transaction, Transfer from .provenance import AuditEvent, SourceRecord from .reconciliation import ReconciliationCheckpoint -from .reference import Account, Category, Payee +from .reference import Account, Category, CategoryGroup, Payee, Tag __all__ = [ - "Account", "AuditEvent", "CardStatement", "Category", "InstallmentPlan", - "Payee", "ReconciliationCheckpoint", "SourceRecord", "Transaction", "Transfer", + "Account", "AuditEvent", "CardStatement", "Category", "CategoryGroup", "InstallmentPlan", + "Payee", "ReconciliationCheckpoint", "SourceRecord", "Tag", "Transaction", "Transfer", ] diff --git a/finance/models/ledger.py b/finance/models/ledger.py index ed97c85..1bbdb99 100644 --- a/finance/models/ledger.py +++ b/finance/models/ledger.py @@ -45,6 +45,7 @@ class Transaction(TimestampedModel): kind = models.CharField(max_length=16, choices=Kind.choices) payee = models.ForeignKey("finance.Payee", on_delete=models.PROTECT, related_name="transactions", null=True, blank=True) category = models.ForeignKey("finance.Category", on_delete=models.PROTECT, related_name="transactions", null=True, blank=True) + tags = models.ManyToManyField("finance.Tag", blank=True, related_name="transactions") statement = models.ForeignKey(CardStatement, on_delete=models.PROTECT, related_name="transactions", null=True, blank=True) refund_of = models.ForeignKey("self", on_delete=models.PROTECT, related_name="refunds", null=True, blank=True) installment_plan = models.ForeignKey(InstallmentPlan, on_delete=models.PROTECT, related_name="transactions", null=True, blank=True) diff --git a/finance/models/reference.py b/finance/models/reference.py index 392ccf7..83e2ff5 100644 --- a/finance/models/reference.py +++ b/finance/models/reference.py @@ -32,17 +32,36 @@ class Payee(TimestampedModel): constraints = [models.CheckConstraint(condition=Q(name__regex=r"\S"), name="payee_nonblank_name")] +class CategoryGroup(TimestampedModel): + name = models.CharField(max_length=255, unique=True) + archived = models.BooleanField(default=False) + + class Meta: + constraints = [models.CheckConstraint(condition=Q(name__regex=r"\S"), name="category_group_nonblank_name")] + + class Category(TimestampedModel): class Kind(models.TextChoices): INCOME = "INCOME", "Income" EXPENSE = "EXPENSE", "Expense" - name = models.CharField(max_length=255, unique=True) + name = models.CharField(max_length=255) kind = models.CharField(max_length=16, choices=Kind.choices) + group = models.ForeignKey(CategoryGroup, on_delete=models.PROTECT, related_name="categories", null=True, blank=True) archived = models.BooleanField(default=False) class Meta: constraints = [ models.CheckConstraint(condition=Q(kind__in=["INCOME", "EXPENSE"]), name="category_valid_kind"), models.CheckConstraint(condition=Q(name__regex=r"\S"), name="category_nonblank_name"), + models.UniqueConstraint(fields=["group", "name"], name="category_group_name_unique"), + models.UniqueConstraint(fields=["name"], condition=Q(group__isnull=True), name="category_ungrouped_name_unique"), ] + + +class Tag(TimestampedModel): + name = models.CharField(max_length=255, unique=True) + archived = models.BooleanField(default=False) + + class Meta: + constraints = [models.CheckConstraint(condition=Q(name__regex=r"\S"), name="tag_nonblank_name")] diff --git a/tests/finance/test_category_hierarchy_migration.py b/tests/finance/test_category_hierarchy_migration.py new file mode 100644 index 0000000..d092887 --- /dev/null +++ b/tests/finance/test_category_hierarchy_migration.py @@ -0,0 +1,41 @@ +from datetime import date +from decimal import Decimal + +import pytest + +from finance.models import Account, Category, Tag, Transaction + + +pytestmark = pytest.mark.django_db + + +def test_ungrouped_categories_and_transaction_links_remain_valid(): + account = Account.objects.create(name="Synthetic bank", kind="BANK") + category = Category.objects.create(name="Synthetic Food", kind="EXPENSE") + transaction = Transaction.objects.create( + account=account, + amount=Decimal("-10.00"), + posting_date=date(2026, 9, 14), + kind="EXPENSE", + category=category, + ) + + category.refresh_from_db() + + assert category.group_id is None + assert transaction.category_id == category.pk + + +def test_tags_can_be_attached_without_changing_category(): + account = Account.objects.create(name="Synthetic bank", kind="BANK") + transaction = Transaction.objects.create( + account=account, + amount=Decimal("-10.00"), + posting_date=date(2026, 9, 14), + kind="EXPENSE", + ) + tag = Tag.objects.create(name="Synthetic Tax") + + transaction.tags.add(tag) + + assert list(transaction.tags.all()) == [tag] diff --git a/tests/finance/test_constraints.py b/tests/finance/test_constraints.py index 52b596b..c82bc4d 100644 --- a/tests/finance/test_constraints.py +++ b/tests/finance/test_constraints.py @@ -8,7 +8,7 @@ from django.db import IntegrityError from django.db.models.deletion import ProtectedError from finance.models import ( - Account, AuditEvent, CardStatement, Category, InstallmentPlan, Payee, + Account, AuditEvent, CardStatement, Category, CategoryGroup, InstallmentPlan, Payee, SourceRecord, Transaction, Transfer, ) @@ -75,6 +75,15 @@ def test_transfer_cannot_have_category(account): post(account, kind="TRANSFER", category=category) +def test_category_name_is_unique_within_each_group(account): + home = CategoryGroup.objects.create(name="Synthetic Home") + travel = CategoryGroup.objects.create(name="Synthetic Travel") + Category.objects.create(name="Synthetic Fees", kind="EXPENSE", group=home) + Category.objects.create(name="Synthetic Fees", kind="EXPENSE", group=travel) + with pytest.raises(IntegrityError): + Category.objects.create(name="Synthetic Fees", kind="EXPENSE", group=home) + + def test_installment_pair_number_kind_and_uniqueness(account): plan = InstallmentPlan.objects.create(account=account, total_installments=10) for changes in [dict(installment_plan=plan), dict(installment_number=3),