Browse Source

docs: design category hierarchy and tags

master
Codex 2 months ago
parent
commit
2addbc9f0a
  1. 124
      docs/superpowers/specs/2026-09-14-category-hierarchy-tags-design.md

124
docs/superpowers/specs/2026-09-14-category-hierarchy-tags-design.md

@ -0,0 +1,124 @@
# Category Hierarchy and Tags Design
**Date:** 2026-09-14
**Status:** Approved
**Milestone:** Post-MVP financial reference-data evolution
## Goal
Represent YNAB-style category groups and their assignable subcategories in
Gizmoduck without changing existing transaction classifications. Establish a
minimal tag data model now, without exposing tag behavior, so a later feature
can use tags without reworking the transaction schema.
This is a prerequisite for a later, separately designed YNAB reference-data
synchronization that imports category hierarchy and payees and proposes
annotations for existing transactions.
## Scope
This change includes:
- category groups, leaf categories, hierarchy-aware category management, and
hierarchy-aware spending presentation;
- a migration that preserves every existing `Category` and its transaction
references as an ungrouped leaf category;
- a `Tag` model and a zero-or-more `Transaction.tags` relationship; and
- focused migration, model, service, query, and report tests using synthetic
data only.
It excludes YNAB parsing/import, account mapping, transaction matching,
payee synchronization, tag creation or assignment workflows, tag filters,
tag reports, and tag export. Those need their own approved designs.
## Category hierarchy
Add `CategoryGroup` as a UUID reference model with a nonblank unique `name`,
an `archived` flag, and timestamps. A group is organizational only: it has no
income/expense kind and cannot be assigned to a transaction.
Add nullable `group = ForeignKey(CategoryGroup, on_delete=PROTECT,
related_name="categories")` to `Category`. `Category` remains the sole
transaction-assignable leaf and retains its required `INCOME` or `EXPENSE`
kind. Existing categories retain a null group after migration, so their
identity and all historical transaction foreign keys remain unchanged.
Replace global category-name uniqueness with these PostgreSQL constraints:
- `(group, name)` is unique for grouped categories; and
- an ungrouped category name is unique among ungrouped categories.
This permits, for example, a `Fees` leaf under more than one group while
preventing ambiguous duplicates in the same group or among legacy ungrouped
categories. Group names remain globally unique.
Archived groups and categories remain visible on historical transactions but
are unavailable for new assignments. An otherwise unarchived leaf under an
archived group is likewise unavailable for new assignment. No archive action
cascades to children or changes transaction history.
## Application behavior
Add public reference-data operations to create and archive category groups,
and extend category creation to accept an optional group UUID. Category lists
and creation choices present active groups with their active leaf categories,
then active ungrouped categories. Existing ungrouped categories continue to
work unchanged.
Transaction category filtering remains a leaf-category filter. Transaction
creation, annotation, and import-review assignment continue to receive a
category UUID; their active-reference validation must reject both archived
leaves and leaves whose group is archived.
The spending report retains its existing accounting totals. It presents rows
under category groups and includes a roll-up total for each group; ungrouped
categories and `Uncategorized` remain separate. Group totals are derived from
the leaf-category transactions at query time and do not store cached amounts.
Exports include group identity and category-to-group relationship so a restore
or future migration can reconstruct the hierarchy. Tag tables and transaction
tag relationships are not exported until tags can be assigned through a
supported product workflow.
## Tags
Add `Tag` as a UUID reference model with a nonblank globally unique `name`,
an `archived` flag, and timestamps. Add a many-to-many relationship between
`Transaction` and `Tag` with a stable reverse relation.
No current public service, web endpoint, import decision, report, filter, or
export writes or reads tags. The migration creates no tags and attaches none to
existing transactions. The relation is deliberately separate from category:
a category remains the single accounting classification, while future tags
will be optional cross-cutting labels.
## Integrity and migration
The schema migration adds tables and nullable relations before removing the
old global category-name uniqueness constraint, so existing data remains
valid throughout. It does not create category groups, alter category names,
or update transaction records.
The category group foreign key uses `PROTECT`; supported application behavior
does not delete groups, categories, or tags.
Service-level active-reference checks enforce effective archival state because
the database cannot express a cross-table archived-group rule in a check
constraint.
## Verification
Tests will demonstrate that:
- the migration preserves existing categories and transaction category links;
- category groups cannot be assigned to transactions, while leaf category
kind rules remain intact;
- duplicate leaf names are allowed only across distinct groups;
- archived group membership prevents new assignments without hiding history;
- grouped report totals equal the sum of their leaf totals; and
- tags can exist and relate to transactions at the database level without
changing existing category, report, or export behavior.
Apply migrations against an existing local database and a clean database, run
the focused tests and the relevant finance suite, then start the local service
and verify its health endpoint. No real YNAB files or financial records may be
included in tests, commits, diagnostics, or generated artifacts.
Loading…
Cancel
Save