Files
sure/app/models/up_account/transactions/processor.rb
T
4e010493c7 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>
2026-08-22 08:26:48 +02:00

109 lines
4.5 KiB
Ruby

class UpAccount::Transactions::Processor
attr_reader :up_account
# Build a transactions processor for the given +up_account+.
def initialize(up_account)
@up_account = up_account
end
# Process each stored raw transaction into a Sure entry, prune stale pending
# entries, and return a stats hash (total/imported/failed/pruned/errors).
def process
unless up_account.raw_transactions_payload.present?
Rails.logger.info "UpAccount::Transactions::Processor - No Up transactions available to process"
pruned_count = prune_stale_pending_entries([])
return { success: true, total: 0, imported: 0, failed: 0, pruned_pending: pruned_count, errors: [] }
end
total_count = up_account.raw_transactions_payload.count
imported_count = 0
failed_count = 0
errors = []
current_pending_external_ids = pending_external_ids
up_account.raw_transactions_payload.each_with_index do |transaction_data, index|
result = UpEntry::Processor.new(
transaction_data,
up_account: up_account,
category_matcher: category_matcher
).process
if result.nil?
failed_count += 1
errors << { index: index, transaction_id: transaction_id(transaction_data), error: "No linked account" }
else
imported_count += 1
end
rescue ArgumentError => e
failed_count += 1
errors << { index: index, transaction_id: transaction_id(transaction_data), error: "Validation error: #{e.message}" }
Rails.logger.error "UpAccount::Transactions::Processor - Validation error processing transaction #{transaction_id(transaction_data)}: #{e.message}"
rescue => e
failed_count += 1
errors << { index: index, transaction_id: transaction_id(transaction_data), error: "#{e.class}: #{e.message}" }
Rails.logger.error "UpAccount::Transactions::Processor - Error processing transaction #{transaction_id(transaction_data)}: #{e.class} - #{e.message}"
Rails.logger.error e.backtrace.join("\n")
end
pruned_count = prune_stale_pending_entries(current_pending_external_ids)
{
success: failed_count.zero?,
total: total_count,
imported: imported_count,
failed: failed_count,
pruned_pending: pruned_count,
errors: errors
}
end
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"
end
# Canonical external ids of the currently-HELD (pending) transactions.
def pending_external_ids
up_account.raw_transactions_payload.filter_map do |transaction_data|
next unless transaction_data.is_a?(Hash)
next unless UpEntry::Processor.pending?(transaction_data)
UpEntry::Processor.canonical_external_id(transaction_data)
end
end
# Delete previously-imported pending entries no longer present in the latest
# fetch (cancelled/settled holds), returning how many were removed.
def prune_stale_pending_entries(current_pending_external_ids)
account = up_account.current_account
return 0 unless account.present?
stale_pending_entries = account.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(source: "up")
.where("(transactions.extra -> 'up' ->> 'pending')::boolean = true")
stale_pending_entries = stale_pending_entries.where.not(external_id: current_pending_external_ids) if current_pending_external_ids.any?
count = stale_pending_entries.count
stale_pending_entries.find_each(&:destroy!) if count.positive?
count
end
end