mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 20:14:08 +00:00
Initial enable banking implementation (#382)
* Initial enable banking implementation * Handle multiple connections * Amount fixes * Account type mapping * Add option to skip accounts * Update schema.rb * Transaction fixes * Provider fixes * FIX account identifier * FIX support unlinking * UI style fixes * FIX safe redirect and brakeman issue * FIX - pagination max fix - wrap crud in transaction logic * FIX api uid access - The Enable Banking API expects the UUID (uid from the API response) to fetch balances/transactions, not the identification_hash * FIX add new connection * FIX erb code * Alert/notice box overflow protection * Give alert/notification boxes room to grow (3 lines max) * Add "Enable Banking (beta)" to `/settings/bank_sync` * Make Enable Banking section collapsible like all others * Add callback hint to error message --------- Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
251
app/models/enable_banking_item/importer.rb
Normal file
251
app/models/enable_banking_item/importer.rb
Normal file
@@ -0,0 +1,251 @@
|
||||
class EnableBankingItem::Importer
|
||||
# Maximum number of pagination requests to prevent infinite loops
|
||||
# Enable Banking typically returns ~100 transactions per page, so 100 pages = ~10,000 transactions
|
||||
MAX_PAGINATION_PAGES = 100
|
||||
|
||||
attr_reader :enable_banking_item, :enable_banking_provider
|
||||
|
||||
def initialize(enable_banking_item, enable_banking_provider:)
|
||||
@enable_banking_item = enable_banking_item
|
||||
@enable_banking_provider = enable_banking_provider
|
||||
end
|
||||
|
||||
def import
|
||||
unless enable_banking_item.session_valid?
|
||||
enable_banking_item.update!(status: :requires_update)
|
||||
return { success: false, error: "Session expired or invalid", accounts_updated: 0, transactions_imported: 0 }
|
||||
end
|
||||
|
||||
session_data = fetch_session_data
|
||||
unless session_data
|
||||
return { success: false, error: "Failed to fetch session data", accounts_updated: 0, transactions_imported: 0 }
|
||||
end
|
||||
|
||||
# Store raw payload
|
||||
begin
|
||||
enable_banking_item.upsert_enable_banking_snapshot!(session_data)
|
||||
rescue => e
|
||||
Rails.logger.error "EnableBankingItem::Importer - Failed to store session snapshot: #{e.message}"
|
||||
end
|
||||
|
||||
# Update accounts from session
|
||||
accounts_updated = 0
|
||||
accounts_failed = 0
|
||||
|
||||
if session_data[:accounts].present?
|
||||
existing_uids = enable_banking_item.enable_banking_accounts
|
||||
.joins(:account_provider)
|
||||
.pluck(:uid)
|
||||
.map(&:to_s)
|
||||
|
||||
# Enable Banking API returns accounts as an array of UIDs (strings) in the session response
|
||||
# We need to handle both array of strings and array of hashes
|
||||
session_data[:accounts].each do |account_data|
|
||||
# Handle both string UIDs and hash objects
|
||||
# Use identification_hash as the stable identifier across sessions
|
||||
uid = if account_data.is_a?(String)
|
||||
account_data
|
||||
elsif account_data.is_a?(Hash)
|
||||
(account_data[:identification_hash] || account_data[:uid] || account_data["identification_hash"] || account_data["uid"])&.to_s
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
next unless uid.present?
|
||||
|
||||
# Only update if this account was previously linked
|
||||
next unless existing_uids.include?(uid)
|
||||
|
||||
begin
|
||||
# For string UIDs, we don't have account data to update - skip the import_account call
|
||||
# The account data will be fetched via balances/transactions endpoints
|
||||
if account_data.is_a?(Hash)
|
||||
import_account(account_data)
|
||||
accounts_updated += 1
|
||||
end
|
||||
rescue => e
|
||||
accounts_failed += 1
|
||||
Rails.logger.error "EnableBankingItem::Importer - Failed to update account #{uid}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch balances and transactions for linked accounts
|
||||
transactions_imported = 0
|
||||
transactions_failed = 0
|
||||
|
||||
linked_accounts_query = enable_banking_item.enable_banking_accounts.joins(:account_provider).joins(:account).merge(Account.visible)
|
||||
|
||||
linked_accounts_query.each do |enable_banking_account|
|
||||
begin
|
||||
fetch_and_update_balance(enable_banking_account)
|
||||
|
||||
result = fetch_and_store_transactions(enable_banking_account)
|
||||
if result[:success]
|
||||
transactions_imported += result[:transactions_count]
|
||||
else
|
||||
transactions_failed += 1
|
||||
end
|
||||
rescue => e
|
||||
transactions_failed += 1
|
||||
Rails.logger.error "EnableBankingItem::Importer - Failed to process account #{enable_banking_account.uid}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
success: accounts_failed == 0 && transactions_failed == 0,
|
||||
accounts_updated: accounts_updated,
|
||||
accounts_failed: accounts_failed,
|
||||
transactions_imported: transactions_imported,
|
||||
transactions_failed: transactions_failed
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_session_data
|
||||
enable_banking_provider.get_session(session_id: enable_banking_item.session_id)
|
||||
rescue Provider::EnableBanking::EnableBankingError => e
|
||||
if e.error_type == :unauthorized || e.error_type == :not_found
|
||||
enable_banking_item.update!(status: :requires_update)
|
||||
end
|
||||
Rails.logger.error "EnableBankingItem::Importer - Enable Banking API error: #{e.message}"
|
||||
nil
|
||||
rescue => e
|
||||
Rails.logger.error "EnableBankingItem::Importer - Unexpected error fetching session: #{e.class} - #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def import_account(account_data)
|
||||
# Use identification_hash as the stable identifier across sessions
|
||||
uid = account_data[:identification_hash] || account_data[:uid]
|
||||
|
||||
enable_banking_account = enable_banking_item.enable_banking_accounts.find_by(uid: uid.to_s)
|
||||
return unless enable_banking_account
|
||||
|
||||
enable_banking_account.upsert_enable_banking_snapshot!(account_data)
|
||||
enable_banking_account.save!
|
||||
end
|
||||
|
||||
def fetch_and_update_balance(enable_banking_account)
|
||||
balance_data = enable_banking_provider.get_account_balances(account_id: enable_banking_account.api_account_id)
|
||||
|
||||
# Enable Banking returns an array of balances
|
||||
balances = balance_data[:balances] || []
|
||||
return if balances.empty?
|
||||
|
||||
# Find the most relevant balance (prefer "closingBooked" or "expected")
|
||||
balance = balances.find { |b| b[:balance_type] == "closingBooked" } ||
|
||||
balances.find { |b| b[:balance_type] == "expected" } ||
|
||||
balances.first
|
||||
|
||||
if balance.present?
|
||||
amount = balance.dig(:balance_amount, :amount) || balance[:amount]
|
||||
currency = balance.dig(:balance_amount, :currency) || balance[:currency]
|
||||
|
||||
if amount.present?
|
||||
enable_banking_account.update!(
|
||||
current_balance: amount.to_d,
|
||||
currency: currency.presence || enable_banking_account.currency
|
||||
)
|
||||
end
|
||||
end
|
||||
rescue Provider::EnableBanking::EnableBankingError => e
|
||||
Rails.logger.error "EnableBankingItem::Importer - Error fetching balance for account #{enable_banking_account.uid}: #{e.message}"
|
||||
end
|
||||
|
||||
def fetch_and_store_transactions(enable_banking_account)
|
||||
start_date = determine_sync_start_date(enable_banking_account)
|
||||
|
||||
all_transactions = []
|
||||
continuation_key = nil
|
||||
previous_continuation_key = nil
|
||||
page_count = 0
|
||||
|
||||
# Paginate through all transactions with safeguards against infinite loops
|
||||
loop do
|
||||
page_count += 1
|
||||
|
||||
# Safeguard: prevent infinite loops from excessive pagination
|
||||
if page_count > MAX_PAGINATION_PAGES
|
||||
Rails.logger.error(
|
||||
"EnableBankingItem::Importer - Pagination limit exceeded for account #{enable_banking_account.uid}. " \
|
||||
"Stopped after #{MAX_PAGINATION_PAGES} pages (#{all_transactions.count} transactions). " \
|
||||
"Last continuation_key: #{continuation_key.inspect}"
|
||||
)
|
||||
break
|
||||
end
|
||||
|
||||
transactions_data = enable_banking_provider.get_account_transactions(
|
||||
account_id: enable_banking_account.api_account_id,
|
||||
date_from: start_date,
|
||||
continuation_key: continuation_key
|
||||
)
|
||||
|
||||
transactions = transactions_data[:transactions] || []
|
||||
all_transactions.concat(transactions)
|
||||
|
||||
previous_continuation_key = continuation_key
|
||||
continuation_key = transactions_data[:continuation_key]
|
||||
|
||||
# Safeguard: detect repeated continuation_key (provider returning same key)
|
||||
if continuation_key.present? && continuation_key == previous_continuation_key
|
||||
Rails.logger.error(
|
||||
"EnableBankingItem::Importer - Repeated continuation_key detected for account #{enable_banking_account.uid}. " \
|
||||
"Breaking loop after #{page_count} pages (#{all_transactions.count} transactions). " \
|
||||
"Repeated key: #{continuation_key.inspect}, last response had #{transactions.count} transactions"
|
||||
)
|
||||
break
|
||||
end
|
||||
|
||||
break if continuation_key.blank?
|
||||
end
|
||||
|
||||
transactions_count = all_transactions.count
|
||||
|
||||
if all_transactions.any?
|
||||
existing_transactions = enable_banking_account.raw_transactions_payload.to_a
|
||||
existing_ids = existing_transactions.map { |tx|
|
||||
tx = tx.with_indifferent_access
|
||||
tx[:transaction_id].presence || tx[:entry_reference].presence
|
||||
}.compact.to_set
|
||||
|
||||
new_transactions = all_transactions.select do |tx|
|
||||
# Use transaction_id if present, otherwise fall back to entry_reference
|
||||
tx_id = tx[:transaction_id].presence || tx[:entry_reference].presence
|
||||
tx_id.present? && !existing_ids.include?(tx_id)
|
||||
end
|
||||
|
||||
if new_transactions.any?
|
||||
enable_banking_account.upsert_enable_banking_transactions_snapshot!(existing_transactions + new_transactions)
|
||||
end
|
||||
end
|
||||
|
||||
{ success: true, transactions_count: transactions_count }
|
||||
rescue Provider::EnableBanking::EnableBankingError => e
|
||||
Rails.logger.error "EnableBankingItem::Importer - Error fetching transactions for account #{enable_banking_account.uid}: #{e.message}"
|
||||
{ success: false, transactions_count: 0, error: e.message }
|
||||
rescue => e
|
||||
Rails.logger.error "EnableBankingItem::Importer - Unexpected error fetching transactions for account #{enable_banking_account.uid}: #{e.class} - #{e.message}"
|
||||
{ success: false, transactions_count: 0, error: e.message }
|
||||
end
|
||||
|
||||
def determine_sync_start_date(enable_banking_account)
|
||||
has_stored_transactions = enable_banking_account.raw_transactions_payload.to_a.any?
|
||||
|
||||
# Use user-configured sync_start_date if set, otherwise default
|
||||
user_start_date = enable_banking_item.sync_start_date
|
||||
|
||||
if has_stored_transactions
|
||||
# For incremental syncs, get transactions from 7 days before last sync
|
||||
if enable_banking_item.last_synced_at
|
||||
enable_banking_item.last_synced_at.to_date - 7.days
|
||||
else
|
||||
user_start_date || 90.days.ago.to_date
|
||||
end
|
||||
else
|
||||
# Initial sync: use user's configured date or default to 3 months
|
||||
user_start_date || 3.months.ago.to_date
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user