Skip to content

feat(up): map Up category slugs to Sure categories on import#2487

Open
threatsurfer wants to merge 3 commits into
we-promise:mainfrom
threatsurfer:feat/up-apply-categories
Open

feat(up): map Up category slugs to Sure categories on import#2487
threatsurfer wants to merge 3 commits into
we-promise:mainfrom
threatsurfer:feat/up-apply-categories

Conversation

@threatsurfer

@threatsurfer threatsurfer commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Up tags each spending transaction with a category (the one the user picks in the Up app), exposed as a child slug on relationships.category. The merged provider captures that slug into extra but never applies it, so Up transactions import uncategorised. This PR maps Up's category slugs onto the family's existing/default Sure categories.

Approach

Mirrors PlaidAccount::Transactions::CategoryMatcher: a new UpAccount::Transactions::CategoryTaxonomy (Up child slugs to alias lists) plus a CategoryMatcher that resolves those aliases against the family's categories. It bootstraps Sure's default categories if the family has none, and never creates a provider-specific taxonomy. The matcher is built once per account in UpAccount::Transactions::Processor and passed to UpEntry::Processor, which sets category_id through the adapter.

Coverage (real data)

Against my own ~7yr / ~26k dataset, about 70% of categorised Up transactions (~5,300 of ~7,600) map cleanly to a default Sure category: Groceries, Takeaway/Restaurants to Food & Drink, TV/Music to Subscriptions, Health to Healthcare, Fuel/Taxis to Transportation, Fitness to Sports & Fitness, and so on. Up-specific categories with no honest Sure default (Apps & Games, Life Admin, Technology, Booze, Pets) are deliberately left unmapped so they stay uncategorised for the user's own rules / AI. High confidence only: a wrong auto-category is worse than none.

User edits are preserved

category_id is applied through the adapter's enrich_attribute (ignore_locks defaults to false), and the protection check skips user_modified entries entirely, so a category a user has set or locked survives re-sync. Same concern raised on #2460.

Stacking

This stacks on #2460 (both touch UpEntry::Processor) and is branched off a newer main, so the two will conflict on merge. Happy to rebase whichever lands second.

Tests

  • UpAccount::Transactions::CategoryMatcher unit test: high-confidence slugs resolve to the right default category; ambiguous / unknown / blank slugs return nil; nothing matches when the target category is absent.
  • UpEntry::Processor: a matched category is applied (and the raw slug stays in extra); an unmatched category leaves the transaction uncategorised.

Summary by CodeRabbit

  • New Features
    • Incoming Up transaction imports now auto-map external spending-category slugs to the closest in-app category using a taxonomy with aliases and confidence-based matching.
    • When a match is found, imported transactions are enriched with the mapped in-app category.
  • Bug Fixes
    • Blank, unknown, or unsupported external categories now correctly result in uncategorized transactions instead of mis-mapping.
    • Improves accuracy by matching related categories via parent/alias fallback.
  • Tests
    • Added coverage for exact matches, alias/parent fallback (including shared transport aliases), unmatched/blank cases, and behavior when family categories are missing.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 04afc9e4-e669-4c53-bca0-0b13ca3cb5cc

📥 Commits

Reviewing files that changed from the base of the PR and between c5f9fca and 93fa1a6.

📒 Files selected for processing (7)
  • app/models/up_account/transactions/category_matcher.rb
  • app/models/up_account/transactions/category_taxonomy.rb
  • app/models/up_account/transactions/processor.rb
  • app/models/up_entry/processor.rb
  • test/models/up_account/transactions/category_matcher_test.rb
  • test/models/up_account/transactions/processor_test.rb
  • test/models/up_entry/processor_test.rb
🚧 Files skipped from review as they are similar to previous changes (7)
  • test/models/up_account/transactions/category_matcher_test.rb
  • app/models/up_account/transactions/category_taxonomy.rb
  • app/models/up_entry/processor.rb
  • app/models/up_account/transactions/processor.rb
  • test/models/up_entry/processor_test.rb
  • test/models/up_account/transactions/processor_test.rb
  • app/models/up_account/transactions/category_matcher.rb

📝 Walkthrough

Walkthrough

The PR adds an Up category taxonomy, slug matching to Sure categories, and transaction import wiring that passes matched category ids into entry imports. Tests cover matcher resolution and import behavior.

Changes

Up category matching flow

Layer / File(s) Summary
Taxonomy map
app/models/up_account/transactions/category_taxonomy.rb
Defines the Up category taxonomy map with family classifications, aliases, and detailed child slugs.
Matcher lookup and normalization
app/models/up_account/transactions/category_matcher.rb, test/models/up_account/transactions/category_matcher_test.rb
Implements slug lookup, exact normalized matching, alias fallback, normalization helpers, and tests mapped, unmapped, and blank slugs.
Transaction processor wiring
app/models/up_account/transactions/processor.rb, test/models/up_account/transactions/processor_test.rb
Builds a memoized matcher from family categories, passes it into each entry processor call, and tests import behavior with and without family categories.
Entry import category mapping
app/models/up_entry/processor.rb, test/models/up_entry/processor_test.rb
Accepts the injected matcher, derives the matched category id for import payloads, and tests matched and unmatched imports.

Sequence Diagram(s)

sequenceDiagram
  participant UpAccountProcessor as UpAccount::Transactions::Processor
  participant EntryProcessor as UpEntry::Processor
  participant CategoryMatcher as UpAccount::Transactions::CategoryMatcher
  participant ImportAdapter as import_adapter

  UpAccountProcessor->>CategoryMatcher: build from family categories
  UpAccountProcessor->>EntryProcessor: initialize with category_matcher
  EntryProcessor->>CategoryMatcher: match up_category_slug
  CategoryMatcher-->>EntryProcessor: matched category or nil
  EntryProcessor->>ImportAdapter: import_transaction(category_id)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possible related issues

Possibly related PRs

  • we-promise/sure#2391: Introduces the Up transaction import path that this PR extends by threading a category matcher into UpAccount::Transactions::Processor and UpEntry::Processor.

Suggested labels

contributor:verified, pr:verified

Suggested reviewers

  • jjmata

Poem

I’m a bunny with a taxonomy map,
Hopping slugs to categories—snap, snap, snap.
If the match is fuzzy, I pause in the glade,
If it’s crisp and clear, the import is made. 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: mapping Up category slugs to Sure categories during import.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9ddced642

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

def category_details(up_category_slug)
return nil if up_category_slug.blank?

detailed_categories.find { |c| c[:key] == up_category_slug.to_s.downcase.to_sym }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Match Up taxonomy keys as strings

For every Up transaction with a category slug, this comparison converts the incoming slug to a symbol while CATEGORIES_MAP stores all child keys as string slugs, so checks like "groceries" == :groceries never succeed. That makes category_details return nil, matched_category_id stay nil, and the new Up auto-categorization path never applies any category despite the imported Up payload containing one.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
app/models/up_account/transactions/category_matcher.rb (1)

43-45: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Normalization strips and as a substring, not just the conjunction.

gsub(/(and|&|\s+)/, "") removes every occurrence of and inside a word (e.g. erranderr, grandgr), which can collapse distinct names and produce a wrong match against a user-defined category. No collision exists among Sure's current defaults, so this is latent, but a word-boundary form is safer.

♻️ Match the word, not the substring
-          normalized_name  = name.gsub(/(and|&|\s+)/, "").strip
-          normalized_alias = alias_str.gsub(/(and|&|\s+)/, "").strip
+          normalized_name  = name.gsub(/(\band\b|&|\s+)/, "").strip
+          normalized_alias = alias_str.gsub(/(\band\b|&|\s+)/, "").strip
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/up_account/transactions/category_matcher.rb` around lines 43 - 45,
The normalization in CategoryMatcher is removing the substring "and" anywhere in
a word, which can create false matches. Update the matching logic in the
name/alias normalization path to strip only the standalone conjunction (for
example by using a word-boundary-based pattern) while still removing "&" and
whitespace, and keep the comparison in the same method that computes
normalized_name and normalized_alias.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/models/up_account/transactions/category_matcher.rb`:
- Around line 43-45: The normalization in CategoryMatcher is removing the
substring "and" anywhere in a word, which can create false matches. Update the
matching logic in the name/alias normalization path to strip only the standalone
conjunction (for example by using a word-boundary-based pattern) while still
removing "&" and whitespace, and keep the comparison in the same method that
computes normalized_name and normalized_alias.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a481b2d9-b11c-479f-8d91-a81385e3c922

📥 Commits

Reviewing files that changed from the base of the PR and between 169fd13 and a9ddced.

📒 Files selected for processing (6)
  • app/models/up_account/transactions/category_matcher.rb
  • app/models/up_account/transactions/category_taxonomy.rb
  • app/models/up_account/transactions/processor.rb
  • app/models/up_entry/processor.rb
  • test/models/up_account/transactions/category_matcher_test.rb
  • test/models/up_entry/processor_test.rb

@threatsurfer

Copy link
Copy Markdown
Contributor Author

Re the Codex P1 on app/models/up_account/transactions/category_matcher.rb (taxonomy key matching): I believe this is a false positive. The keys in CATEGORIES_MAP are symbols (Ruby's key: / quoted-key shorthand produces symbol keys, not strings), so c[:key] compared against the symbolized incoming slug resolves correctly and categorization works. The matcher unit test exercises this directly: the groceries slug maps to the Groceries category, and it passes.

That said, the quoted-symbol syntax is easy to misread, and Up's category ids are strings anyway, so I have pushed 7d8c824 to compare them as strings (c[:key].to_s == slug) independent of key type. No behaviour change, and tests stay green.

jjmata commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

One concern:

bootstrap! called as a side effect of transaction import

In family_categories, when the linked account's family has no categories, account.family.categories.bootstrap! is called — creating Sure's full default category set during a routine sync. A family that has deliberately cleared all categories, or has never gone through onboarding, will have defaults silently created the next time their Up transactions are processed.

Consider making the matcher return nil for every slug when the family has no categories (i.e. return [] from family_categories without bootstrapping), keeping transaction import non-destructive with respect to the family's category state. The transactions stay uncategorized, and the user can set up or bootstrap categories at their own pace through the normal UI flow.


Generated by Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
test/models/up_account/transactions/processor_test.rb (1)

65-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid asserting against the localized category name.

Line 66 hard-codes "Groceries", so this test will fail if the bootstrapped label is renamed or translated even when the mapping still works. Assert against the expected category record/id instead of its display name. Based on learnings, avoid hard-coded category-name comparisons in Ruby/Rails code; prefer identity or predicate checks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/models/up_account/transactions/processor_test.rb` around lines 65 - 66,
The test in the transaction processor spec is asserting on the localized display
name of the mapped category, which is brittle. Update the assertion in the
`processor_test` around `entry.transaction.category` to check the expected
category record/identity (or a predicate on the category association) instead of
comparing `category&.name` to a hard-coded string. Keep the lookup by
`external_id`, but replace the name-based expectation with an assertion tied to
the category object or its stable identifier.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/models/up_account/transactions/processor_test.rb`:
- Around line 65-66: The test in the transaction processor spec is asserting on
the localized display name of the mapped category, which is brittle. Update the
assertion in the `processor_test` around `entry.transaction.category` to check
the expected category record/identity (or a predicate on the category
association) instead of comparing `category&.name` to a hard-coded string. Keep
the lookup by `external_id`, but replace the name-based expectation with an
assertion tied to the category object or its stable identifier.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3543612d-e34f-4b30-ac9f-b5e7984bb5e1

📥 Commits

Reviewing files that changed from the base of the PR and between 7d8c824 and c5f9fca.

📒 Files selected for processing (3)
  • app/models/up_account/transactions/category_matcher.rb
  • app/models/up_account/transactions/processor.rb
  • test/models/up_account/transactions/processor_test.rb
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/models/up_account/transactions/category_matcher.rb

@threatsurfer

Copy link
Copy Markdown
Contributor Author

Good call, agreed. Pushed c5f9fca: family_categories no longer bootstraps; it returns the family's existing categories, so import is non-destructive. A family with no categories now just gets uncategorised transactions, and matching resumes once they set categories up themselves. Added a processor test asserting nothing is created during import.

FWIW I had lifted the bootstrap behaviour from PlaidAccount::Transactions::Processor, which does the same thing, so you may want the same non-destructive treatment there for consistency.

Also folded in CodeRabbit's nitpick in the same commit: word-boundaried the and stripping in the matcher normalization so it no longer eats and inside a word.

Gavin Matthews and others added 3 commits June 30, 2026 23:17
UpEntry::Processor captured Up's category slug into extra but never applied it, so
Up transactions imported uncategorised even though the user had already tagged them
in the Up app.

Add UpAccount::Transactions::CategoryTaxonomy + CategoryMatcher, mirroring
PlaidAccount::Transactions::CategoryMatcher: map Up's child category slugs onto the
family's existing/default Sure categories by alias, and wire the matcher through
UpAccount::Transactions::Processor into UpEntry::Processor. The category is applied via
the adapter's enrich_attribute, so a category the user has set or locked is preserved
on re-sync.

High-confidence mappings only. Up-specific categories with no honest Sure default
(Booze, Pets, Apps & Games, Life Admin, Technology, ...) intentionally stay
uncategorised for the user's own rules / AI, since a wrong auto-category is worse than
none. Adds a matcher unit test and processor wiring tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Up category ids are string slugs; compare them against the taxonomy keys as strings
so the lookup does not depend on the keys being symbols. No behaviour change (the
"slug": hash syntax already produces symbol keys that matched the symbolized input,
covered by the matcher unit test), but it removes a subtle footgun and reads clearer.
Flagged by the Codex review on the PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s match

Per review feedback: do not bootstrap Sure's default categories during a sync.
family_categories now returns the family's existing categories without creating
defaults, so a family that has none (deliberately cleared, or pre-onboarding) gets
uncategorised transactions rather than having the full default set silently created.
Matching resumes once the user sets up categories through the normal UI flow.

Also word-boundary the "and" stripping in the matcher normalization so it strips only
the standalone conjunction, not "and" inside a word (e.g. errand). Adds a processor
test for the non-destructive guarantee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants