From e994e505cbe0aeb6bc1193f9957d6fb184ac822d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 17:30:46 -0300 Subject: [PATCH] docs: plan category hierarchy and tags --- .../2026-09-14-category-hierarchy-tags.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-14-category-hierarchy-tags.md diff --git a/docs/superpowers/plans/2026-09-14-category-hierarchy-tags.md b/docs/superpowers/plans/2026-09-14-category-hierarchy-tags.md new file mode 100644 index 0000000..41b3eb7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-category-hierarchy-tags.md @@ -0,0 +1,200 @@ +# Category Hierarchy and Tags Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add category groups and leaf categories while laying down persistence-only transaction tags. + +**Architecture:** `CategoryGroup` owns optional groups of transaction-assignable `Category` leaves. Existing categories remain ungrouped. `Tag` is separate from accounting classification and is joined to transactions but has no product behavior in this release. + +**Tech Stack:** Python, Django 5.2, PostgreSQL, pytest, Docker Compose. + +**Spec:** `docs/superpowers/specs/2026-09-14-category-hierarchy-tags-design.md` + +## Global Constraints + +- Preserve every category primary key and transaction category relationship. +- Use only synthetic data; do not read or commit local YNAB data. +- Category leaves remain the only transaction-assignable classification. +- Tags have no UI, service mutation, filter, report, import, or export behavior. +- All writes use `finance.services`; run the health endpoint after implementation. + +--- + +### Task 1: Add the hierarchy and tag schema + +**Files:** +- Modify: `finance/models/reference.py`, `finance/models/ledger.py`, `finance/models/__init__.py` +- Create: `finance/migrations/0004_category_groups_and_tags.py`, `tests/finance/test_category_hierarchy_migration.py` +- Modify: `tests/finance/test_constraints.py` + +**Interfaces:** `CategoryGroup(name, archived)`, `Tag(name, archived)`, nullable `Category.group`, and `Transaction.tags` with `related_name="transactions"`. + +- [ ] **Step 1: Write failing schema tests** + +```python +def test_category_names_are_unique_within_a_group_and_tags_can_relate(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) + row = post(account) + tag = Tag.objects.create(name="Synthetic Tax") + row.tags.add(tag) + assert list(row.tags.all()) == [tag] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `docker compose run --rm test pytest -q tests/finance/test_constraints.py tests/finance/test_category_hierarchy_migration.py` + +Expected: FAIL because the new models and relations do not exist. + +- [ ] **Step 3: Implement models and migration** + +Create `CategoryGroup` and `Tag` from `TimestampedModel`, both with nonblank globally unique names and `archived`. Add `Category.group = ForeignKey("finance.CategoryGroup", null=True, blank=True, on_delete=PROTECT, related_name="categories")`; replace `Category.name` global uniqueness with `UniqueConstraint(fields=["group", "name"], name="category_group_name_unique")` and conditional `UniqueConstraint(fields=["name"], condition=Q(group__isnull=True), name="category_ungrouped_name_unique")`. Add `Transaction.tags = ManyToManyField("finance.Tag", blank=True, related_name="transactions")`. Generate the migration; it must not update rows. + +- [ ] **Step 4: Run schema verification** + +Run: `docker compose run --rm manage migrate; docker compose run --rm test pytest -q tests/finance/test_constraints.py tests/finance/test_category_hierarchy_migration.py; docker compose run --rm manage makemigrations --check --dry-run` + +Expected: PASS. + +- [ ] **Step 5: Commit the schema slice** + +Run: `git add finance/models finance/migrations/0004_category_groups_and_tags.py tests/finance/test_constraints.py tests/finance/test_category_hierarchy_migration.py; git diff --cached --check; git diff --cached; git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: add category groups and tags"` + +### Task 2: Support category groups in services, choices, and reference pages + +**Files:** +- Modify: `finance/services/reference.py`, `finance/services/posting.py`, `finance/services/__init__.py`, `finance/queries.py` +- Modify: `core/forms.py`, `core/views.py`, `config/urls.py`, `core/templates/core/references/list.html`, `core/templates/core/transactions/list.html` +- Modify: `tests/finance/test_services.py`, `tests/finance/test_queries.py`, `tests/test_web_workflows.py` + +**Interfaces:** `create_category_group(*, name, actor)`, `create_category(*, name, kind, group_id=None, actor)`, `active_category_groups()`, and `category_label(category)`. + +- [ ] **Step 1: Write failing behavior tests** + +```python +def test_archived_group_blocks_new_child_assignment(): + group = create_category_group(name="Synthetic Home", actor=ACTOR) + leaf = create_category(name="Synthetic Utilities", kind="EXPENSE", group_id=group.pk, actor=ACTOR) + archive(group, actor=ACTOR) + with pytest.raises(DomainError, match="archived_reference"): + record_transaction(account_id=account.pk, amount="-1", posting_date=date(2026, 1, 1), kind="EXPENSE", category_id=leaf.pk, actor=ACTOR) +``` + +- [ ] **Step 2: Run focused tests to verify failure** + +Run: `docker compose run --rm test pytest -q tests/finance/test_services.py tests/finance/test_queries.py tests/test_web_workflows.py` + +Expected: FAIL because groups cannot be created or archived. + +- [ ] **Step 3: Implement active hierarchy behavior** + +Add audited create/archive handling for `CategoryGroup`; extend category creation with optional locked active `group_id`. In posting, lock the category and then its group; reject an archived group using `DomainError("archived_reference")`. Make `active_categories()` exclude leaves with archived groups. Use `category_label` (`"Group ยท Leaf"` or `"Leaf"`) in form choices, filter choices, reference lists, and transaction rows. Add create/list/archive routes for groups only; add no tag route. + +- [ ] **Step 4: Run focused tests** + +Run: `docker compose run --rm test pytest -q tests/finance/test_services.py tests/finance/test_queries.py tests/test_web_workflows.py` + +Expected: PASS; existing flat-category flows still pass. + +- [ ] **Step 5: Commit the workflow slice** + +Run: `git add finance/services finance/queries.py core/forms.py core/views.py config/urls.py core/templates/core/references/list.html core/templates/core/transactions/list.html tests/finance/test_services.py tests/finance/test_queries.py tests/test_web_workflows.py; git diff --cached --check; git diff --cached; git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: manage grouped categories"` + +### Task 3: Add hierarchy roll-ups to spending reports + +**Files:** +- Modify: `finance/reports.py`, `core/templates/core/dashboard.html`, `core/templates/core/reports/monthly.html` +- Modify: `tests/finance/test_reports.py`, `tests/test_report_views.py` + +**Interfaces:** `spending_by_category()` returns display rows with `group_name`, `category_name`, `is_group_total`, `expense`, `refund`, and `net_spending`. + +- [ ] **Step 1: Write a failing grouped-total test** + +```python +assert [(row.is_group_total, row.group_name, row.category_name, row.net_spending) for row in rows] == [ + (True, "Synthetic Food", None, Decimal("50.00")), + (False, "Synthetic Food", "Synthetic Groceries", Decimal("30.00")), + (False, "Synthetic Food", "Synthetic Dining", Decimal("20.00")), +] +``` + +- [ ] **Step 2: Run report tests to verify failure** + +Run: `docker compose run --rm test pytest -q tests/finance/test_reports.py tests/test_report_views.py` + +Expected: FAIL because rows are flat. + +- [ ] **Step 3: Implement query-time group roll-ups** + +Aggregate by group and leaf fields, build each leaf row, and derive a group-total row by summing its leaves. Sort group totals by net spending then group name and their leaves by net spending then category name. Keep ungrouped categories and `Uncategorized` as leaf rows. Render visibly distinct group totals and indented child rows in both templates; do not change monthly summary arithmetic. + +- [ ] **Step 4: Run report verification** + +Run: `docker compose run --rm test pytest -q tests/finance/test_reports.py tests/test_report_views.py tests/test_mvp_acceptance.py` + +Expected: PASS. + +- [ ] **Step 5: Commit the report slice** + +Run: `git add finance/reports.py core/templates/core/dashboard.html core/templates/core/reports/monthly.html tests/finance/test_reports.py tests/test_report_views.py; git diff --cached --check; git diff --cached; git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: roll up spending by category group"` + +### Task 4: Export category hierarchy and document the supported behavior + +**Files:** +- Modify: `finance/exports.py`, `tests/finance/test_exports.py`, `docs/financial-domain.md`, `docs/local-workflows.md` + +**Interfaces:** Add `category_groups.csv` before `categories.csv`; append `group_id` to `categories.csv`; do not export tags or transaction-tag joins. + +- [ ] **Step 1: Write failing export tests** + +```python +assert "category_groups.csv" in package.namelist() +assert rows(package, "categories.csv")[0]["group_id"] == str(group.pk) +assert "tags.csv" not in package.namelist() +assert "transaction_tags.csv" not in package.namelist() +``` + +- [ ] **Step 2: Run the export test to verify failure** + +Run: `docker compose run --rm test pytest -q tests/finance/test_exports.py` + +Expected: FAIL because group data is not exported. + +- [ ] **Step 3: Implement export and documentation changes** + +Add `CategoryGroup` to export schemas ahead of categories and add `group_id` to the category fields. Update synthetic export seed data, expected member order, headers, manifest checks, and row assertions. Update the financial-domain and local-workflow guides: leaves classify transactions; groups organize/report; archiving a group disables its children for new assignments; tags are schema-only. + +- [ ] **Step 4: Run export verification** + +Run: `docker compose run --rm test pytest -q tests/finance/test_exports.py tests/test_mvp_acceptance.py` + +Expected: PASS with no tag archive members. + +- [ ] **Step 5: Commit the export slice** + +Run: `git add finance/exports.py tests/finance/test_exports.py docs/financial-domain.md docs/local-workflows.md; git diff --cached --check; git diff --cached; git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: export category hierarchy"` + +### Task 5: Run complete verification and health check + +**Files:** Modify only to correct a task-scoped defect found below. + +- [ ] **Step 1: Run migrations and the relevant suite** + +Run: `docker compose build; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; docker compose run --rm manage migrate; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; docker compose run --rm test pytest -q tests/finance tests/test_web_workflows.py tests/test_report_views.py tests/test_mvp_acceptance.py; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; docker compose run --rm manage makemigrations --check --dry-run; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; docker compose run --rm manage migrate --check` + +Expected: PASS. + +- [ ] **Step 2: Start the app and check health** + +Run: `docker compose up -d web; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Invoke-RestMethod http://127.0.0.1:8000/health/ | ConvertTo-Json -Compress` + +Expected: healthy JSON response. + +- [ ] **Step 3: Perform final privacy and diff review** + +Run: `git diff --check; git status --short; git log --oneline -4` + +Expected: no whitespace errors or real financial data, with the pre-existing `AGENTS.md` edit left untouched.