diff --git a/app/models/up_account/transactions/category_matcher.rb b/app/models/up_account/transactions/category_matcher.rb new file mode 100644 index 0000000000..b142bbcd25 --- /dev/null +++ b/app/models/up_account/transactions/category_matcher.rb @@ -0,0 +1,86 @@ +# Auto-matches Up Bank's spending categories to the family's existing Sure categories. +# Mirrors PlaidAccount::Transactions::CategoryMatcher: a fast, cheap, high-confidence +# pass that never creates categories and never overwrites user data (the import adapter +# applies the result via enrich_attribute, which respects locks). Up tags a transaction +# with a child category slug (relationships.category.data.id); we map the honest +# equivalents onto Sure's default categories and leave the rest for user rules / AI. +class UpAccount::Transactions::CategoryMatcher + include UpAccount::Transactions::CategoryTaxonomy + + def initialize(user_categories = []) + @user_categories = user_categories + end + + # up_category_slug is the value of relationships.category.data.id, e.g. + # "restaurants-and-cafes". Returns a matching Category, or nil when the slug is + # unknown or has no confident equivalent among the user's categories. + def match(up_category_slug) + details = category_details(up_category_slug) + return nil unless details + + # Try an exact match against the slug name first (rare), then the category's own + # aliases, then its parent group's aliases. + exact = normalized_user_categories.find { |c| c[:name] == details[:key].to_s } + return user_categories.find { |c| c.id == exact[:id] } if exact + + match_aliases(details[:aliases]) || match_aliases(details[:parent_aliases]) + end + + private + attr_reader :user_categories + + def match_aliases(aliases) + return nil if aliases.blank? + + hit = normalized_user_categories.find do |category| + name = category[:name] + aliases.any? do |a| + alias_str = a.to_s + next true if name == alias_str + next true if name.singularize == alias_str || name.pluralize == alias_str + next true if alias_str.singularize == name || alias_str.pluralize == name + + # Strip the standalone "and" conjunction (word-boundaried so it does not eat + # "and" inside a word, e.g. errand), plus "&" and whitespace. + normalized_name = name.gsub(/(\band\b|&|\s+)/, "").strip + normalized_alias = alias_str.gsub(/(\band\b|&|\s+)/, "").strip + normalized_name == normalized_alias + end + end + + hit && user_categories.find { |c| c.id == hit[:id] } + end + + def category_details(up_category_slug) + return nil if up_category_slug.blank? + + # Up category ids are string slugs (e.g. "restaurants-and-cafes"); compare as + # strings so the lookup does not depend on the taxonomy keys being symbols. + slug = up_category_slug.to_s.downcase + detailed_categories.find { |c| c[:key].to_s == slug } + end + + def detailed_categories + @detailed_categories ||= CATEGORIES_MAP.flat_map do |parent_key, parent_data| + parent_data[:detailed_categories].map do |child_key, child_data| + { + key: child_key, + classification: child_data[:classification], + aliases: child_data[:aliases], + parent_key: parent_key, + parent_aliases: parent_data[:aliases] + } + end + end + end + + def normalized_user_categories + @normalized_user_categories ||= user_categories.map do |user_category| + { id: user_category.id, name: normalize_user_category_name(user_category.name) } + end + end + + def normalize_user_category_name(name) + name.to_s.downcase.gsub(/[^a-z0-9]/, " ").strip + end +end diff --git a/app/models/up_account/transactions/category_taxonomy.rb b/app/models/up_account/transactions/category_taxonomy.rb new file mode 100644 index 0000000000..c21f824615 --- /dev/null +++ b/app/models/up_account/transactions/category_taxonomy.rb @@ -0,0 +1,81 @@ +# Up Bank's spending category taxonomy (GET /api/v1/categories): four parent groups +# and ~40 child categories. A transaction references a CHILD slug via +# relationships.category.data.id (e.g. "restaurants-and-cafes"); Up does not tag +# transfers or income. +# +# We map those child slugs onto the family's existing/default Sure categories via +# aliases, mirroring PlaidAccount::Transactions::CategoryTaxonomy. Aliases are written +# in Sure's default-category vocabulary and matched case/punctuation-insensitively. +# +# Up categories with no honest Sure-default equivalent (e.g. Booze, Adult, Pets, Apps +# & Games, Life Admin, Technology) are intentionally left without a mapping alias, so +# they stay uncategorised for the user's own rules / AI to handle. A wrong +# auto-category is worse than none. +module UpAccount::Transactions::CategoryTaxonomy + CATEGORIES_MAP = { + "good-life": { + classification: :expense, + aliases: [], + detailed_categories: { + "restaurants-and-cafes": { classification: :expense, aliases: [ "food and drink", "dining", "restaurants" ] }, + "takeaway": { classification: :expense, aliases: [ "food and drink", "takeout" ] }, + "tv-and-music": { classification: :expense, aliases: [ "subscriptions" ] }, + "hobbies": { classification: :expense, aliases: [ "entertainment" ] }, + "events-and-gigs": { classification: :expense, aliases: [ "entertainment" ] }, + "holidays-and-travel": { classification: :expense, aliases: [ "travel" ] }, + "pubs-and-bars": { classification: :expense, aliases: [] }, + "booze": { classification: :expense, aliases: [] }, + "games-and-software": { classification: :expense, aliases: [] }, + "adult": { classification: :expense, aliases: [] }, + "lottery-and-gambling": { classification: :expense, aliases: [] }, + "tobacco-and-vaping": { classification: :expense, aliases: [] } + } + }, + "home": { + classification: :expense, + aliases: [], + detailed_categories: { + "groceries": { classification: :expense, aliases: [ "groceries" ] }, + "utilities": { classification: :expense, aliases: [ "utilities" ] }, + "internet": { classification: :expense, aliases: [ "utilities" ] }, + "rent-and-mortgage": { classification: :expense, aliases: [ "mortgage and rent", "rent" ] }, + "home-maintenance-and-improvements": { classification: :expense, aliases: [ "home improvement" ] }, + "homeware-and-appliances": { classification: :expense, aliases: [ "shopping" ] }, + "home-insurance-and-rates": { classification: :expense, aliases: [ "insurance" ] }, + "pets": { classification: :expense, aliases: [] } + } + }, + "personal": { + classification: :expense, + aliases: [], + detailed_categories: { + "health-and-medical": { classification: :expense, aliases: [ "healthcare" ] }, + "fitness-and-wellbeing": { classification: :expense, aliases: [ "sports and fitness" ] }, + "hair-and-beauty": { classification: :expense, aliases: [ "personal care" ] }, + "clothing-and-accessories": { classification: :expense, aliases: [ "shopping" ] }, + "gifts-and-charity": { classification: :expense, aliases: [ "gifts and donations" ] }, + "investments": { classification: :expense, aliases: [ "savings and investments" ] }, + "mobile-phone": { classification: :expense, aliases: [ "subscriptions" ] }, + "technology": { classification: :expense, aliases: [] }, + "life-admin": { classification: :expense, aliases: [] }, + "education-and-student-loans": { classification: :expense, aliases: [] }, + "family": { classification: :expense, aliases: [] }, + "news-magazines-and-books": { classification: :expense, aliases: [] } + } + }, + "transport": { + classification: :expense, + aliases: [ "transportation" ], + detailed_categories: { + "fuel": { classification: :expense, aliases: [ "transportation" ] }, + "public-transport": { classification: :expense, aliases: [ "transportation" ] }, + "taxis-and-share-cars": { classification: :expense, aliases: [ "transportation" ] }, + "parking": { classification: :expense, aliases: [ "transportation" ] }, + "toll-roads": { classification: :expense, aliases: [ "transportation" ] }, + "cycling": { classification: :expense, aliases: [ "transportation" ] }, + "car-insurance-and-maintenance": { classification: :expense, aliases: [ "transportation" ] }, + "car-repayments": { classification: :expense, aliases: [ "loan payments" ] } + } + } + }.freeze +end diff --git a/app/models/up_account/transactions/processor.rb b/app/models/up_account/transactions/processor.rb index c56fadb4d9..0efe87ceb1 100644 --- a/app/models/up_account/transactions/processor.rb +++ b/app/models/up_account/transactions/processor.rb @@ -24,7 +24,8 @@ def process up_account.raw_transactions_payload.each_with_index do |transaction_data, index| result = UpEntry::Processor.new( transaction_data, - up_account: up_account + up_account: up_account, + category_matcher: category_matcher ).process if result.nil? @@ -57,6 +58,22 @@ def process private + # A single category matcher reused across this account's transactions, built from + # the family's existing categories. + def category_matcher + @category_matcher ||= UpAccount::Transactions::CategoryMatcher.new(family_categories) + end + + # The family's existing categories. Importing transactions is intentionally + # non-destructive with respect to the family's category structure: we do NOT + # bootstrap Sure's defaults here. A family that has no categories (deliberately + # cleared, or pre-onboarding) simply gets uncategorised transactions, and matching + # resumes once the user sets up categories through the normal UI flow. Returns [] + # when the account isn't linked (each entry is skipped before the matcher is used). + def family_categories + @family_categories ||= up_account.current_account&.family&.categories&.to_a || [] + end + # Extract the Up transaction id from raw data, or "unknown". def transaction_id(transaction_data) transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" diff --git a/app/models/up_entry/processor.rb b/app/models/up_entry/processor.rb index 59e9b9e890..4aef5705b1 100644 --- a/app/models/up_entry/processor.rb +++ b/app/models/up_entry/processor.rb @@ -33,9 +33,14 @@ def self.content_hash_for(data) 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 @@ def process date: date, name: name, source: "up", + category_id: matched_category_id, kind: kind, merchant: merchant, notes: notes, @@ -99,6 +105,17 @@ def name 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. diff --git a/test/models/up_account/transactions/category_matcher_test.rb b/test/models/up_account/transactions/category_matcher_test.rb new file mode 100644 index 0000000000..0bd1b7fc0e --- /dev/null +++ b/test/models/up_account/transactions/category_matcher_test.rb @@ -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 diff --git a/test/models/up_account/transactions/processor_test.rb b/test/models/up_account/transactions/processor_test.rb new file mode 100644 index 0000000000..24f8c5f95d --- /dev/null +++ b/test/models/up_account/transactions/processor_test.rb @@ -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 diff --git a/test/models/up_entry/processor_test.rb b/test/models/up_entry/processor_test.rb index d7d07c4048..c58c0ac75f 100644 --- a/test/models/up_entry/processor_test.rb +++ b/test/models/up_entry/processor_test.rb @@ -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