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 .ledger import CardStatement, InstallmentPlan, Transaction, Transfer
from .provenance import AuditEvent, SourceRecord from .provenance import AuditEvent, SourceRecord
from .reconciliation import ReconciliationCheckpoint from .reconciliation import ReconciliationCheckpoint
from .reference import Account, Category, Payee from .reference import Account, Category, CategoryGroup, Payee, Tag
__all__ = [ __all__ = [
"Account", "AuditEvent", "CardStatement", "Category", "InstallmentPlan", "Account", "AuditEvent", "CardStatement", "Category", "CategoryGroup", "InstallmentPlan",
"Payee", "ReconciliationCheckpoint", "SourceRecord", "Transaction", "Transfer", "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) 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) 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) 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) 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) 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) 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")] 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 Category(TimestampedModel):
class Kind(models.TextChoices): class Kind(models.TextChoices):
INCOME = "INCOME", "Income" INCOME = "INCOME", "Income"
EXPENSE = "EXPENSE", "Expense" 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) 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) archived = models.BooleanField(default=False)
class Meta: class Meta:
constraints = [ constraints = [
models.CheckConstraint(condition=Q(kind__in=["INCOME", "EXPENSE"]), name="category_valid_kind"), 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.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 django.db.models.deletion import ProtectedError
from finance.models import ( from finance.models import (
Account, AuditEvent, CardStatement, Category, InstallmentPlan, Payee, Account, AuditEvent, CardStatement, Category, CategoryGroup, InstallmentPlan, Payee,
SourceRecord, Transaction, Transfer, SourceRecord, Transaction, Transfer,
) )
@ -75,6 +75,15 @@ def test_transfer_cannot_have_category(account):
post(account, kind="TRANSFER", category=category) 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): def test_installment_pair_number_kind_and_uniqueness(account):
plan = InstallmentPlan.objects.create(account=account, total_installments=10) plan = InstallmentPlan.objects.create(account=account, total_installments=10)
for changes in [dict(installment_plan=plan), dict(installment_number=3), for changes in [dict(installment_plan=plan), dict(installment_number=3),

Loading…
Cancel
Save