1 changed files with 200 additions and 0 deletions
@ -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. |
||||||
Loading…
Reference in new issue