Skip to content

feat: manage categories -- edit built-ins, add your own, backfill#4

Merged
richardhapb merged 4 commits into
mainfrom
feat/manage-categories
Jul 26, 2026
Merged

feat: manage categories -- edit built-ins, add your own, backfill#4
richardhapb merged 4 commits into
mainfrom
feat/manage-categories

Conversation

@richardhapb

Copy link
Copy Markdown
Owner

Why

Categories were effectively read-only. You could create a new category, but not touch the built-in catalog -- so adding a keyword to Vivienda was impossible without editing categories.json and redeploying.

What

Per-user overrides. Built-in categories are global rows shared by every user, so they cannot be edited in place. An edit now becomes a private CategoryOverride (rename) plus forked CategoryPattern rows; once forked, only that user's keywords apply and the shared catalog is ignored for them. Reset drops both and the defaults come back. category_patterns.user_id distinguishes catalog rows (NULL) from user-owned ones, and the builtin sync only ever re-seeds catalog rows.

Transferences join the catalog. They stored their category as a built-in enum, so no user keyword could ever apply. They now carry a category_id FK like expenses, backfilled from the enum. TransferenceRead.category keeps its shape as the slug string, plus category_id / category_is_custom.

Backfill. POST /categories/recategorize re-applies the current keywords to already stored expenses and transferences. Rows that match no keyword keep their category, so manual assignments are never wiped.

API

Endpoint Behavior
PATCH /categories/{id} Rename and/or replace keywords (409 on name clash, 404 on another user's category)
POST /categories/{id}/reset Built-in only: back to catalog defaults
DELETE /categories/{id} Custom only: its transactions move to "Otros", count returned
POST /categories/recategorize Backfill; returns per-type updated counts
POST /categories Now also accepts a patterns list (pattern still works)

GET /categories gained is_modified, and expenses/transferences serialize the user's renamed category name.

UI

New /categories page linked from settings and the navbar: filter, keyword chips with add/remove, rename, reset for edited built-ins, delete for custom ones, and a "Re-apply to existing transactions" button.

Verification

  • uv run pytest -q -- 107 passed, including a new tests/test_category_management.py covering override isolation between users, keyword removal, fork survival across builtin sync, reset, delete-with-reassign, backfill (including "leaves unmatched rows alone" and "ignores other users") and the HTTP endpoints.
  • uv run ruff check src tests clean; bun run typecheck and bun run build clean.
  • Both migrations run up -> down -> up on Postgres 16 with legacy rows seeded (enum transference, user category patterns); the enum column is restored faithfully on downgrade.

Note

Two existing tests in test_email_task.py moved from a MagicMock session to a real in-memory one, since saving a transference now resolves its category against the DB.

Categories were effectively read-only: users could create new ones but
could not touch the built-in catalog, so adding a keyword to e.g. Vivienda
was impossible.

Built-in categories are global rows shared by every user, so they cannot be
edited in place. A user's edit now becomes a private CategoryOverride
(rename) plus forked CategoryPattern rows -- once forked, only their
keywords apply and the shared catalog is ignored for them. Reset drops both.

Transferences stored their category as a built-in enum, so no user keyword
could ever apply to them. They now point at the categories catalog with a
category_id FK, backfilled from the enum, mirroring expenses.

- PATCH /categories/{id}: rename and/or replace keywords
- POST /categories/{id}/reset: back to catalog defaults
- DELETE /categories/{id}: custom only; its transactions move to "Otros"
- POST /categories/recategorize: re-apply keywords to stored transactions,
  leaving unmatched rows on their current category so manual picks survive
- POST /categories also accepts a keyword list

New /categories page (linked from settings and the navbar) to edit names and
keyword chips, reset built-ins, delete custom ones and run the backfill.

Migrations verified up/down/up on Postgres with legacy rows.
@richardhapb
richardhapb force-pushed the feat/manage-categories branch from a55f235 to da5ccf3 Compare July 26, 2026 15:44

@richardhapb richardhapb left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Guardian review

Verdict: ✅ All good to merge

Severity Count
🟢 Nit 1

See the inline comments for details.

Comment thread src/db/categories.py Outdated
richardhapb

This comment was marked as resolved.

@richardhapb
richardhapb force-pushed the feat/manage-categories branch from 0c6de8c to 149b223 Compare July 26, 2026 20:45

@richardhapb richardhapb left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Guardian review

Verdict: ✅ All good to merge

Severity Count
🟢 Nit 2

See the inline comments for details.

Comment thread web/app/components/expenses/ExpenseForm.tsx Outdated
Comment thread alembic/versions/d5b8f1c07e42_add_transference_category_id.py
The UI always posts both name and patterns, so a pure rename set
patterns_overridden=True and silently opted the user out of any keyword
later seeded into the builtin catalog.

Compare the submitted set against the catalog: identical means stay on the
shared catalog (dropping an existing fork), and an override row with no
rename and no fork is deleted so the category stops reading as edited.
@richardhapb
richardhapb force-pushed the feat/manage-categories branch from 149b223 to 63404d7 Compare July 26, 2026 20:54

@richardhapb richardhapb left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Guardian review

Verdict: ✅ All good to merge

🎉 No findings.

♻️ Resolved 1 earlier comment(s) fixed by this push.

The reversed schema dropped the constraint the initial migration created.
Every row is backfilled just above, so the original nullability can come
back and down/up stops drifting.
Belongs in its own change, not in the categories feature.

@richardhapb richardhapb left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Guardian review

Verdict: ✅ All good to merge

🎉 No findings.

♻️ Resolved 2 earlier comment(s) fixed by this push.

@richardhapb
richardhapb merged commit 6c29842 into main Jul 26, 2026
6 checks passed
@richardhapb
richardhapb deleted the feat/manage-categories branch July 26, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant