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

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

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>

* refactor(up): match category slugs as strings in CategoryMatcher

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>

* fix(up): make category import non-destructive; word-boundary the alias 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>

---------

Co-authored-by: Gavin Matthews <matthews.gav@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
threatsurfer
2026-08-22 08:26:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 Gavin Matthews
parent 4d1d33f91d
commit 4e010493c7
7 changed files with 379 additions and 2 deletions
+18 -1
View File
@@ -33,9 +33,14 @@ class UpEntry::Processor
end
# Build a processor for a single raw Up transaction tied to +up_account+.
def initialize(up_transaction, up_account:)
#
# category_matcher (optional) maps Up's category slug onto one of the family's
# existing Sure categories. It is injected by UpAccount::Transactions::Processor so a
# single matcher (built once per account) is reused across the account's transactions.
def initialize(up_transaction, up_account:, category_matcher: nil)
@up_transaction = up_transaction
@up_account = up_account
@category_matcher = category_matcher
end
# Import the transaction into the linked Sure account via the import adapter.
@@ -53,6 +58,7 @@ class UpEntry::Processor
date: date,
name: name,
source: "up",
category_id: matched_category_id,
kind: kind,
merchant: merchant,
notes: notes,
@@ -99,6 +105,17 @@ class UpEntry::Processor
data[:description].presence || I18n.t("transactions.unknown_name")
end
# The id of the Sure category that Up's category slug maps to, or nil when no
# matcher was injected, the transaction has no Up category (transfers/income), or
# the slug has no confident equivalent among the family's categories. The import
# adapter applies this via enrich_attribute, so it never overwrites a category the
# user has set or locked.
def matched_category_id
return nil unless @category_matcher
@category_matcher.match(data[:category_id])&.id
end
# The id of the other account in an internal money movement, if any (see
# Provider::Up#flatten_transaction). Present for transfers between the user's own
# accounts and for round-ups swept into a Saver; nil for ordinary income/expense.