mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
* 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>
109 lines
4.5 KiB
Ruby
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
|