Browse Source

feat: add category groups and tags

master
Codex 2 months ago
parent
commit
f2a439612e
  1. 40
      finance/migrations/0004_category_groups_and_tags.py
  2. 6
      finance/models/__init__.py
  3. 1
      finance/models/ledger.py
  4. 21
      finance/models/reference.py
  5. 41
      tests/finance/test_category_hierarchy_migration.py
  6. 11
      tests/finance/test_constraints.py

40
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")),
]

6
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",
]

1
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)

21
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")]

41
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]

11
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),

Loading…
Cancel
Save