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
@@ -0,0 +1,62 @@
require "test_helper"
class UpAccount::Transactions::CategoryMatcherTest < ActiveSupport::TestCase
setup do
@family = families(:empty)
@family.categories.bootstrap! # creates Sure's default categories
@matcher = UpAccount::Transactions::CategoryMatcher.new(@family.categories.to_a)
end
test "maps high-confidence Up categories to default Sure categories" do
expectations = {
"groceries" => "Groceries",
"takeaway" => "Food & Drink",
"restaurants-and-cafes" => "Food & Drink",
"tv-and-music" => "Subscriptions",
"health-and-medical" => "Healthcare",
"fitness-and-wellbeing" => "Sports & Fitness",
"hair-and-beauty" => "Personal Care",
"clothing-and-accessories" => "Shopping",
"gifts-and-charity" => "Gifts & Donations",
"rent-and-mortgage" => "Mortgage / Rent",
"utilities" => "Utilities",
"fuel" => "Transportation",
"taxis-and-share-cars" => "Transportation",
"home-maintenance-and-improvements" => "Home Improvement"
}
expectations.each do |slug, expected_name|
matched = @matcher.match(slug)
assert_not_nil matched, "expected '#{slug}' to match a default category"
assert_equal expected_name, matched.name, "'#{slug}' mapped to the wrong category"
end
end
test "resolves transport children via the parent alias too" do
assert_equal "Transportation", @matcher.match("parking")&.name
assert_equal "Transportation", @matcher.match("public-transport")&.name
end
test "leaves Up-specific categories with no honest default uncategorised" do
%w[
booze adult tobacco-and-vaping lottery-and-gambling pubs-and-bars
games-and-software technology life-admin pets
].each do |slug|
assert_nil @matcher.match(slug), "expected '#{slug}' to have no confident match"
end
end
test "returns nil for unknown or blank slugs" do
assert_nil @matcher.match("not-a-real-up-category")
assert_nil @matcher.match(nil)
assert_nil @matcher.match("")
end
test "does not match when the family has none of the target categories" do
bare_family = families(:dylan_family)
bare_family.categories.destroy_all
matcher = UpAccount::Transactions::CategoryMatcher.new(bare_family.categories.reload.to_a)
assert_nil matcher.match("groceries"), "no Groceries category exists, so no match"
end
end
@@ -0,0 +1,68 @@
require "test_helper"
class UpAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:empty)
@up_item = UpItem.create!(family: @family, name: "Up", access_token: "tok")
@up_account = UpAccount.create!(
up_item: @up_item, name: "Spending", account_id: "acc_1", currency: "AUD"
)
@account = Account.create!(
family: @family, name: "Spending",
accountable: Depository.new(subtype: "checking"), balance: 100, currency: "AUD"
)
AccountProvider.create!(account: @account, provider: @up_account)
end
# Importing must not create categories as a side effect: a family that has none
# (deliberately cleared, or pre-onboarding) keeps none, and transactions stay
# uncategorised until the user sets up categories themselves.
test "does not bootstrap default categories during import" do
assert_equal 0, @family.categories.count, "family starts with no categories"
@up_account.update!(raw_transactions_payload: [
{
"id" => "tx_1",
"status" => "SETTLED",
"description" => "Woolworths",
"amount" => { "currencyCode" => "AUD", "value" => "-40.00", "valueInBaseUnits" => -4000 },
"settledAt" => "2026-01-15T00:00:00+11:00",
"createdAt" => "2026-01-15T00:00:00+11:00",
"account_id" => "acc_1",
"category_id" => "groceries"
}
])
result = UpAccount::Transactions::Processor.new(@up_account).process
assert result[:success]
assert_equal 0, @family.categories.reload.count, "import must not create categories"
entry = @account.entries.find_by(external_id: "up_tx_1")
assert_not_nil entry, "the transaction was still imported"
assert_nil entry.transaction.category_id, "stays uncategorised when the family has no categories"
end
# With the default categories present, the same Up category resolves and is applied.
test "applies matched categories when the family already has them" do
@family.categories.bootstrap!
@up_account.update!(raw_transactions_payload: [
{
"id" => "tx_2",
"status" => "SETTLED",
"description" => "Woolworths",
"amount" => { "currencyCode" => "AUD", "value" => "-40.00", "valueInBaseUnits" => -4000 },
"settledAt" => "2026-01-15T00:00:00+11:00",
"createdAt" => "2026-01-15T00:00:00+11:00",
"account_id" => "acc_1",
"category_id" => "groceries"
}
])
UpAccount::Transactions::Processor.new(@up_account).process
entry = @account.entries.find_by(external_id: "up_tx_2")
assert_equal "Groceries", entry.transaction.category&.name
end
end
+46
View File
@@ -151,4 +151,50 @@ class UpEntry::ProcessorTest < ActiveSupport::TestCase
assert_equal "standard", entry.entryable.kind
assert_nil entry.entryable.extra.dig("up", "transfer_account_id")
end
test "applies the matched Sure category when a category_matcher is provided" do
@family.categories.bootstrap!
matcher = UpAccount::Transactions::CategoryMatcher.new(@family.categories.to_a)
entry = UpEntry::Processor.new(
{
id: "tx_cat_1",
account_id: "acc_123",
status: "SETTLED",
description: "Woolworths",
amount: { currencyCode: "AUD", value: "-40.00", valueInBaseUnits: -4000 },
settledAt: "2026-01-15T00:00:00+11:00",
createdAt: "2026-01-15T00:00:00+11:00",
category_id: "groceries"
},
up_account: @up_account,
category_matcher: matcher
).process
assert_equal "Groceries", entry.transaction.category&.name
# The raw Up slug is still preserved in extra for reference.
assert_equal "groceries", entry.transaction.extra.dig("up", "category_id")
end
test "leaves the transaction uncategorised when the Up category has no confident match" do
@family.categories.bootstrap!
matcher = UpAccount::Transactions::CategoryMatcher.new(@family.categories.to_a)
entry = UpEntry::Processor.new(
{
id: "tx_cat_2",
account_id: "acc_123",
status: "SETTLED",
description: "BWS",
amount: { currencyCode: "AUD", value: "-25.00", valueInBaseUnits: -2500 },
settledAt: "2026-01-15T00:00:00+11:00",
createdAt: "2026-01-15T00:00:00+11:00",
category_id: "booze"
},
up_account: @up_account,
category_matcher: matcher
).process
assert_nil entry.transaction.category_id
end
end