class EnableBankingAccount::Processor class ProcessingError < StandardError; end include CurrencyNormalizable attr_reader :enable_banking_account def initialize(enable_banking_account) @enable_banking_account = enable_banking_account end def process unless enable_banking_account.current_account.present? Rails.logger.info "EnableBankingAccount::Processor - No linked account for enable_banking_account #{enable_banking_account.id}, skipping processing" return end Rails.logger.info "EnableBankingAccount::Processor - Processing enable_banking_account #{enable_banking_account.id} (uid #{enable_banking_account.uid})" begin process_account! rescue StandardError => e Rails.logger.error "EnableBankingAccount::Processor - Failed to process account #{enable_banking_account.id}: #{e.message}" Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}" report_exception(e, "account") raise end process_transactions end private def process_account! if enable_banking_account.current_account.blank? Rails.logger.error("Enable Banking account #{enable_banking_account.id} has no associated Account") return end account = enable_banking_account.current_account balance = enable_banking_account.current_balance || 0 available_credit = nil # For liability accounts, ensure balance sign is correct. # DELIBERATE UX DECISION: For CreditCards, we display the available credit (credit_limit - outstanding debt) # rather than the raw outstanding debt. Do not revert this behavior, as future maintainers should understand # users expect to see how much credit they have left rather than their debt balance. # The 'available_credit' calculation overrides the 'balance' variable. if account.accountable_type == "Loan" balance = balance.abs elsif account.accountable_type == "CreditCard" if enable_banking_account.credit_limit.present? available = enable_banking_account.credit_limit - balance.abs available_credit = [ available, 0 ].max balance = available_credit unless account.accountable.present? Rails.logger.warn "EnableBankingAccount::Processor - CreditCard accountable missing for account #{account.id}" end elsif account.accountable&.available_credit.present? # Fallback: no credit_limit from API — compute it using available_credit defined at account level Rails.logger.info "Using stored available_credit fallback for account #{account.id}" available_credit = account.accountable.available_credit outstanding = balance.abs balance = [ available_credit - outstanding, 0 ].max else # Fallback: no credit_limit from API — display raw outstanding balance # We cannot derive available credit without knowing the limit; leave balance unchanged. end end currency = parse_currency(enable_banking_account.currency) || account.currency || "EUR" # Wrap both writes in a transaction so a failure on either rolls back both. ActiveRecord::Base.transaction do if account.accountable.present? && account.accountable.respond_to?(:available_credit=) account.accountable.update!(available_credit: available_credit) end account.update!(currency: currency, cash_balance: balance) # Use set_current_balance to create a current_anchor valuation entry. # This enables Balance::ReverseCalculator, which works backward from the # bank-reported balance — eliminating spurious cash adjustment spikes. result = account.set_current_balance(balance) raise ProcessingError, "Failed to set current balance: #{result.error}" unless result.success? end # TODO: pass explicit window_start_date to sync_later to avoid full history recalculation on every sync # Currently relies on set_current_balance's implicit sync trigger; window params would require refactor end def process_transactions EnableBankingAccount::Transactions::Processor.new(enable_banking_account).process rescue => e report_exception(e, "transactions") end def report_exception(error, context) Sentry.capture_exception(error) do |scope| scope.set_tags( enable_banking_account_id: enable_banking_account.id, context: context ) end end end