feat: manage categories -- edit built-ins, add your own, backfill#4
Merged
Conversation
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
force-pushed
the
feat/manage-categories
branch
from
July 26, 2026 15:44
a55f235 to
da5ccf3
Compare
richardhapb
commented
Jul 26, 2026
richardhapb
left a comment
Owner
Author
There was a problem hiding this comment.
🛡️ Guardian review
Verdict: ✅ All good to merge
| Severity | Count |
|---|---|
| 🟢 Nit | 1 |
See the inline comments for details.
richardhapb
force-pushed
the
feat/manage-categories
branch
from
July 26, 2026 20:45
0c6de8c to
149b223
Compare
richardhapb
commented
Jul 26, 2026
richardhapb
left a comment
Owner
Author
There was a problem hiding this comment.
🛡️ Guardian review
Verdict: ✅ All good to merge
| Severity | Count |
|---|---|
| 🟢 Nit | 2 |
See the inline comments for details.
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
force-pushed
the
feat/manage-categories
branch
from
July 26, 2026 20:54
149b223 to
63404d7
Compare
richardhapb
commented
Jul 26, 2026
richardhapb
left a comment
Owner
Author
There was a problem hiding this comment.
🛡️ 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
commented
Jul 26, 2026
richardhapb
left a comment
Owner
Author
There was a problem hiding this comment.
🛡️ Guardian review
Verdict: ✅ All good to merge
🎉 No findings.
♻️ Resolved 2 earlier comment(s) fixed by this push.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Categories were effectively read-only. You could create a new category, but not touch the built-in catalog -- so adding a keyword to
Viviendawas impossible without editingcategories.jsonand 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 forkedCategoryPatternrows; 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_iddistinguishes 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_idFK like expenses, backfilled from the enum.TransferenceRead.categorykeeps its shape as the slug string, pluscategory_id/category_is_custom.Backfill.
POST /categories/recategorizere-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
PATCH /categories/{id}POST /categories/{id}/resetDELETE /categories/{id}POST /categories/recategorizePOST /categoriespatternslist (patternstill works)GET /categoriesgainedis_modified, and expenses/transferences serialize the user's renamed category name.UI
New
/categoriespage 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 newtests/test_category_management.pycovering 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 testsclean;bun run typecheckandbun run buildclean.Note
Two existing tests in
test_email_task.pymoved from aMagicMocksession to a real in-memory one, since saving a transference now resolves its category against the DB.