feat: rule-based auto-categorization, transaction tags, saved filter views#203
Merged
Conversation
…iews
Three new user-scoped tables (migration tags_rules_views_2026, revises
cascade_user_fks_2026, empty downgrade per convention):
- categorization_rules: match_field (note|account), pattern, category,
subcategory, is_active, sort_order. Case-insensitive substring match,
first-match-wins ordered by (sort_order, id).
- transaction_tags: single association table (user_id, transaction_id
CASCADE FK, tag string). Unique (user, txn, tag). No separate tags
lookup table -- rename stays a one-statement bulk UPDATE.
- saved_filter_views: (user_id, name unique, filters JSON text). The
filters blob is the frontend FilterValues object verbatim, opaque to
the backend, so new filter fields need zero backend churn.
Rule engine (core/rules.py):
- Applied PRE-HASH in SyncEngine._reconcile_and_log for both import
paths. category/subcategory feed the SHA-256 transaction_id, so
post-hash mutation would desync ids and self-revert on re-upload.
Transfer rows exempt (category synthesized from account names).
- Retroactive POST /apply bulk-rewrites matching live rows AND rehashes
transaction_id via TransactionHasher (occurrence-collision loop),
migrates transaction_tags to the new id, re-points anomalies rows,
commits every 500 rows (Neon 30s statement timeout), then re-runs
analytics non-fatally. Without the rehash the next full-snapshot
upload would soft-delete retro-updated rows and re-insert duplicates.
Endpoints:
- /api/categorization-rules: GET, POST 201, PUT, DELETE 204, POST /apply
-> {matched, updated, analytics_refreshed}
- /api/transactions/{id}/tags: PUT replace-all (trim/dedupe/max 10/1-50
chars), 404 on missing or soft-deleted
- /api/transactions + /search now return tags per row (batch-loaded, /all
intentionally excluded); facets gains tag facets with live-row counts
- /api/saved-views: GET, POST upsert-by-name, DELETE 204
Tests: 43 new (7 unit rule-engine + 36 integration incl. user-scoping,
import-time application, retro rehash, tag filter). Backend suite
318/318, ruff + mypy clean.
Transactions page:
- Tag chips render under the category cell (desktop) and in mobile cards
via the new TagChips component; per-row TagEditor popover (checkbox
list from facet tags + add-new input, 10-tag cap, PUT on Apply).
- Tag filter select in the advanced filter grid, options from the new
tag facets ("name (count)").
- SavedViewsMenu (bookmark dropdown) next to the Filters toggle: save
current filters under a name (upsert), apply a view, delete with
confirm. Applying remounts TransactionFilters via a filtersVersion
key + initialValues prop -- filter state is owned twice (page +
child, child only emits upward), so key-remount is the smallest
change that pushes state INTO the child.
Settings:
- New "Categorization Rules" section under Categories & Classification:
add/edit/toggle/delete rule rows (match_field, pattern, category,
subcategory), saved via the standard diff-then-sync handleSave, plus
an "Apply to existing transactions" button that reports
"updated/matched" counts and invalidates transactions + analytics
query caches.
Services/hooks: categorizationRulesService, savedViewsService,
useSavedViews/useSaveView/useDeleteView, useUpdateTransactionTags --
TanStack Query with staleTime Infinity + explicit invalidation.
tsc + eslint clean, 275/275 vitest.
Node 26 ships an experimental global localStorage that is undefined unless --localstorage-file is passed, and it shadows jsdom's working implementation under vitest. The zustand persist middleware then crashes on setItem, failing all 9 formatters currency tests (the only suite that touches the preferences store outside React). Pre-existing on this machine since the Node 26 upgrade -- not caused by the feature diff. Restore an in-memory Storage in test setup when the global is missing.
…ion) Quality gate failed on new-code duplication 5.2% (>3% threshold) plus 4 open issues. All addressed structurally, no suppressions: Issues: - rules.py S3776 (complexity 30 -> ~12): extract _rehash_with_collision_handling() and _move_transaction_id() from apply_rules_retroactively; the loop now reads as match -> rehash -> move -> commit-chunk. - categorization_rules.py S1192: CategorizationRuleUpdateRequest was a verbatim copy of CreateRequest (48 duplicated lines) -- now inherits it (PUT is a full replace, identical contract by design). - migration S1192: hoist "users.id" FK target to a module constant. - test S1481: unused user_a -> _user_a. - setup.ts S7741: typeof check -> direct undefined comparison. Duplication: - The two-user TestClient fixture was copy-pasted across 3 integration test files (~90 lines total) -- extracted to a shared two_user_client fixture in tests/conftest.py; the per-file fixtures are now one-line aliases so test call-sites keep their domain names. - The outside-click/Escape popover close effect was duplicated in SavedViewsMenu and TagEditor -- extracted to hooks/useDismissable.ts. Verified: backend 318/318 + ruff + mypy clean; frontend tsc + eslint clean, 275/275 vitest.
|
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.



What
Two features that turn the biggest recurring import pain into a one-time setup, plus lightweight organization on the transactions page:
Why the rule engine is hash-aware (reviewer note)
category/subcategoryare components of the SHA-256transaction_idused for dedup. Two consequences the implementation handles explicitly:SyncEngine._reconcile_and_log, shared by both import paths). Same raw row + same rules = same hash = re-uploads stay deterministic. Mutating after hashing would self-revert on the next upload.TransactionHasher(with occurrence-collision handling), migratestransaction_tagsrows to the new id, re-pointsanomaliesFKs, and commits in 500-row chunks to stay under Neon's 30s statement timeout. Without the rehash, the next full-snapshot upload would soft-delete every retro-updated row and re-insert duplicates.Transfers are exempt from rules everywhere -- their category is synthesized from account names and feeds transfer-leg pairing.
Schema
Migration
tags_rules_views_2026(revisescascade_user_fks_2026, empty downgrade per convention):categorization_rules,transaction_tags(single association table, unique (user, txn, tag)),saved_filter_views(unique (user, name)). All user-scoped with CASCADE FKs.Endpoints
GET/POST/PUT/DELETE /api/categorization-rules,POST /api/categorization-rules/apply->{matched, updated, analytics_refreshed}PUT /api/transactions/{id}/tags(replace-all, trim/dedupe/max 10)GET /api/transactions+/searchnow returntagsper row;/facetsgains tag facets with live countsGET/POST/DELETE /api/saved-views(POST upserts by name)Known limitations (documented, intentional v1 scope)
Testing