Skip to content

feat: rule-based auto-categorization, transaction tags, saved filter views#203

Merged
Sagargupta16 merged 6 commits into
mainfrom
feat/rules-tags-saved-views
Jul 7, 2026
Merged

feat: rule-based auto-categorization, transaction tags, saved filter views#203
Sagargupta16 merged 6 commits into
mainfrom
feat/rules-tags-saved-views

Conversation

@Sagargupta16

Copy link
Copy Markdown
Owner

What

Two features that turn the biggest recurring import pain into a one-time setup, plus lightweight organization on the transactions page:

  1. Rule-based auto-categorization -- user-defined "field contains X -> category Y" rules (case-insensitive substring on note or account, first-match-wins by sort order). Applied automatically on every import, plus an explicit "Apply to existing transactions" button for the back catalog. Managed in Settings > Categories & Classification.
  2. Transaction tags -- free-form tags (max 10/txn) editable per row via a popover, shown as chips on desktop rows and mobile cards, filterable via a new tag facet in the filter bar.
  3. Saved filter views -- bookmark the current filter combination under a name, re-apply or delete from a dropdown next to the Filters toggle. Upsert-by-name, filters stored as an opaque JSON blob so future filter fields need zero backend change.

Why the rule engine is hash-aware (reviewer note)

category/subcategory are components of the SHA-256 transaction_id used for dedup. Two consequences the implementation handles explicitly:

  • Import-time rules run PRE-HASH (top of 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.
  • Retroactive apply REHASHES each rewritten row via TransactionHasher (with occurrence-collision handling), migrates transaction_tags rows to the new id, re-points anomalies FKs, 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 (revises cascade_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 + /search now return tags per row; /facets gains tag facets with live counts
  • GET/POST/DELETE /api/saved-views (POST upserts by name)

Known limitations (documented, intentional v1 scope)

  • Rules match note/account substring only -- no regex, no amount/date predicates
  • Tag filter is single-select; no multi-tag AND/OR
  • Tags orphan if a tagged row's RAW source data changes on re-upload (new hash) -- the /apply path migrates correctly; upload churn does not
  • Saved views store filters only, not sort/pagination state

Testing

  • 43 new backend tests (7 unit rule-engine, 36 integration: CRUD roundtrips, user-scoping isolation, import-time application, retro rehash + tag migration, tag filter, facets). Backend suite 318/318.
  • Frontend: tsc + eslint clean, 275/275 vitest.
  • Also fixes a pre-existing local test failure: Node 26's experimental localStorage global shadowed jsdom's, crashing zustand persist in the formatters suite (shimmed in test setup).

…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.
@sonarqubecloud

sonarqubecloud Bot commented Jul 7, 2026

Copy link
Copy Markdown

@Sagargupta16 Sagargupta16 merged commit 7df546a into main Jul 7, 2026
8 checks passed
@Sagargupta16 Sagargupta16 deleted the feat/rules-tags-saved-views branch July 7, 2026 08:05
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