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