Files
sure/app/models/brex_item/importer.rb
ghost 95f6451b39 feat(sync): add Brex provider connections (#1752)
* feat(sync): add Brex provider schema

Adds Brex item and account tables with per-family credentials, scoped upstream account uniqueness, encrypted token storage, and sanitized provider payload columns.

* feat(sync): add Brex provider core

Adds Brex item/account models, provider client and adapter support, family connection helpers, and provider enum registration for read-only Brex cash and card data.

* feat(sync): add Brex import pipeline

Adds Brex account discovery, linked-account sync, cash/card balance processors, transaction import, sanitized metadata handling, and idempotent provider entry processing.

* feat(sync): add Brex connection flows

Adds Mercury-style Brex connection management, explicit item-scoped account selection and linking, settings provider UI, account index visibility, localized copy, and per-item cache handling.

* test(sync): cover Brex provider workflows

Adds targeted coverage for Brex provider requests, adapter config, item/account guards, importer behavior, entry processing, and Mercury-style controller flows.

* fix(sync): align Brex API edge cases

Tightens Brex account fetching against the official card-account response shape, sends transaction start filters as RFC3339 date-times, and keeps provider error bodies out of user-facing messages while expanding provider client guard coverage.

* fix(sync): harden Brex provider integration

Restrict Brex API base URLs to official hosts, tighten account-selection UI behavior, and add tests for invalid credentials, cache scoping, and provider setup edge cases.

* test(sync): avoid Brex secret-shaped fixtures

* refactor(sync): extract Brex account flows

* fix(sync): address Brex provider review feedback

* fix(sync): address Brex review follow-ups

Move remaining Brex review cleanup into focused model behavior, tighten link/setup edge cases, localize summaries, and add regression coverage from CodeRabbit feedback.

Also records the security-review pass as no-findings after diff-scoped inspection and Brakeman validation.

* refactor(sync): split Brex account flow controllers

Route Brex account selection and setup actions through small namespaced controllers while keeping existing URLs and helpers stable.

Business flow remains in BrexItem::AccountFlow; the main Brex item controller now only handles connection CRUD, provider-panel rendering, destroy, and sync.

* fix(sync): address Brex CodeRabbit review

* fix(sync): address Brex follow-up review

* fix(sync): address Brex review follow-ups

* fix(sync): address Brex sync review findings

* fix(sync): polish Brex review copy and errors

* fix(sync): register Brex provider health

* fix(sync): polish Brex bank sync presentation

* fix(sync): address Brex review follow-ups

* fix(sync): tighten Brex setup params

* test(api): stabilize usage rate-limit window

* fix(sync): polish Brex setup flow nits

* fix(sync): harden Brex setup params

* fix(sync): finalize Brex review cleanup

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 18:13:48 +02:00

246 lines
9.0 KiB
Ruby

# frozen_string_literal: true
class BrexItem::Importer
attr_reader :brex_item, :brex_provider, :sync_start_date
def initialize(brex_item, brex_provider:, sync_start_date: nil)
@brex_item = brex_item
@brex_provider = brex_provider
@sync_start_date = sync_start_date
end
def import
Rails.logger.info "BrexItem::Importer - Starting import for item #{brex_item.id}"
accounts_data = fetch_accounts_data
return failed_result("Failed to fetch accounts data") unless accounts_data
store_item_snapshot(accounts_data)
account_result = import_accounts(accounts_data[:accounts].to_a)
transaction_result = import_transactions
brex_item.update!(status: :good) if account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero?
{
success: account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero?,
**account_result,
**transaction_result
}
end
private
def fetch_accounts_data
accounts_data = brex_provider.get_accounts
unless accounts_data.is_a?(Hash)
Rails.logger.error "BrexItem::Importer - Invalid accounts_data format: expected Hash, got #{accounts_data.class}"
return nil
end
accounts_data
rescue Provider::Brex::BrexError => e
mark_requires_update_if_credentials_error(e)
Rails.logger.error "BrexItem::Importer - Brex API error: #{e.message} trace_id=#{e.trace_id}"
nil
rescue JSON::ParserError => e
Rails.logger.error "BrexItem::Importer - Failed to parse Brex API response: #{e.message}"
nil
rescue => e
Rails.logger.error "BrexItem::Importer - Unexpected error fetching accounts: #{e.class} - #{e.message}"
Rails.logger.error Array(e.backtrace).join("\n")
nil
end
def store_item_snapshot(accounts_data)
brex_item.upsert_brex_snapshot!(accounts_data)
rescue => e
Rails.logger.error "BrexItem::Importer - Failed to store accounts snapshot: #{e.message}"
Sentry.capture_exception(e) do |scope|
scope.set_tags(brex_item_id: brex_item.id)
scope.set_context("brex_item_snapshot", {
brex_item_id: brex_item.id,
accounts_data: BrexAccount.sanitize_payload(accounts_data)
})
end
raise
end
def import_accounts(accounts)
accounts_updated = 0
accounts_created = 0
accounts_failed = 0
all_existing_ids = brex_item.brex_accounts.pluck("#{BrexAccount.table_name}.account_id").map(&:to_s)
accounts.each do |account_data|
snapshot = account_data.with_indifferent_access
account_id = snapshot[:id].to_s
account_name = BrexAccount.name_for(snapshot)
next if account_id.blank? || account_name.blank?
if all_existing_ids.include?(account_id)
import_account(snapshot)
accounts_updated += 1
else
import_account(snapshot)
accounts_created += 1
all_existing_ids << account_id
end
rescue => e
accounts_failed += 1
Rails.logger.error "BrexItem::Importer - Failed to import account #{account_id.presence || 'unknown'}: #{e.message}"
end
{
accounts_updated: accounts_updated,
accounts_created: accounts_created,
accounts_failed: accounts_failed
}
end
def import_account(account_data)
account_id = account_data[:id].to_s
raise ArgumentError, "Account ID is required" if account_id.blank?
brex_account = brex_item.brex_accounts.find_or_initialize_by(account_id: account_id)
brex_account.name ||= BrexAccount.name_for(account_data)
brex_account.currency ||= BrexAccount.currency_code_from_money(account_data[:current_balance] || account_data[:available_balance] || account_data[:account_limit])
brex_account.upsert_brex_snapshot!(account_data)
brex_account
end
def import_transactions
transactions_imported = 0
transactions_failed = 0
brex_item.brex_accounts.joins(:account).merge(Account.visible).find_each do |brex_account|
result = fetch_and_store_transactions(brex_account)
if result[:success]
transactions_imported += result[:transactions_count]
else
transactions_failed += 1
end
rescue => e
transactions_failed += 1
Rails.logger.error "BrexItem::Importer - Failed to fetch/store transactions for account #{brex_account.account_id}: #{e.message}"
end
{
transactions_imported: transactions_imported,
transactions_failed: transactions_failed
}
end
def fetch_and_store_transactions(brex_account)
start_date = determine_sync_start_date(brex_account)
Rails.logger.info "BrexItem::Importer - Fetching #{brex_account.account_kind} transactions for account #{brex_account.account_id} from #{start_date}"
transactions_data = if brex_account.card?
brex_provider.get_primary_card_transactions(start_date: start_date)
else
brex_provider.get_cash_transactions(brex_account.account_id, start_date: start_date)
end
unless transactions_data.is_a?(Hash)
Rails.logger.error "BrexItem::Importer - Invalid transactions_data format for account #{brex_account.account_id}"
return { success: false, transactions_count: 0, error: "Invalid response format" }
end
transactions = transactions_data[:transactions].to_a
created_count = store_new_transactions(brex_account, transactions, window_start_date: start_date)
{ success: true, transactions_count: created_count }
rescue Provider::Brex::BrexError => e
mark_requires_update_if_credentials_error(e)
Rails.logger.error "BrexItem::Importer - Brex API error for account #{brex_account.account_id}: #{e.message} trace_id=#{e.trace_id}"
{ success: false, transactions_count: 0, error: e.message }
rescue JSON::ParserError => e
Rails.logger.error "BrexItem::Importer - Failed to parse transaction response for account #{brex_account.account_id}: #{e.message}"
{ success: false, transactions_count: 0, error: "Failed to parse response" }
rescue => e
Rails.logger.error "BrexItem::Importer - Unexpected error fetching transactions for account #{brex_account.account_id}: #{e.class} - #{e.message}"
Rails.logger.error Array(e.backtrace).join("\n")
{ success: false, transactions_count: 0, error: "Unexpected error: #{e.message}" }
end
def store_new_transactions(brex_account, transactions, window_start_date:)
existing_payload = brex_account.raw_transactions_payload.to_a
existing_transactions = transactions_in_window(existing_payload, window_start_date)
existing_ids = existing_transactions.map { |tx| tx.with_indifferent_access[:id] }.to_set
new_transactions = transactions.select do |tx|
tx_id = tx.with_indifferent_access[:id]
tx_id.present? && !existing_ids.include?(tx_id) && transaction_in_window?(tx, window_start_date)
end
return 0 if new_transactions.empty? && existing_transactions.count == existing_payload.count
brex_account.upsert_brex_transactions_snapshot!(existing_transactions + new_transactions)
new_transactions.count
end
def transactions_in_window(transactions, window_start_date)
transactions.select { |transaction| transaction_in_window?(transaction, window_start_date) }
end
def transaction_in_window?(transaction, window_start_date)
return true if window_start_date.blank?
transaction_date = transaction_date_for(transaction)
return true if transaction_date.blank?
transaction_date >= window_start_date.to_date
end
def transaction_date_for(transaction)
data = transaction.with_indifferent_access
date_value = data[:posted_at_date].presence || data[:initiated_at_date].presence || data[:posted_at].presence || data[:created_at].presence
case date_value
when Date
date_value
when Time, DateTime
date_value.to_date
when String
Date.parse(date_value)
else
nil
end
rescue ArgumentError, TypeError
nil
end
def determine_sync_start_date(brex_account)
return sync_start_date if sync_start_date.present?
if brex_account.raw_transactions_payload.to_a.any?
brex_item.last_synced_at ? brex_item.last_synced_at - 7.days : 90.days.ago
else
account_baseline = brex_account.created_at || Time.current
[ account_baseline - 7.days, 90.days.ago ].max
end
end
def mark_requires_update_if_credentials_error(error)
return unless error.error_type.in?([ :unauthorized, :access_forbidden ])
brex_item.update!(status: :requires_update)
rescue => update_error
Rails.logger.error "BrexItem::Importer - Failed to update item status: #{update_error.message}"
end
def failed_result(error)
{
success: false,
error: error,
accounts_updated: 0,
accounts_created: 0,
accounts_failed: 0,
transactions_imported: 0,
transactions_failed: 0
}
end
end