Files
sure/app/models/enable_banking_account.rb
Louis e96fb0c23f feat(enable-banking): enhance transaction import, metadata handling, and UI (#1406)
* feat(enable-banking): enhance transaction import, metadata handling, and UI

* fix(enable-banking): address security, sync edge cases and PR feedback

* fix(enable-banking): resolve silent failures, auth overrides, and sync logic bugs

* fix(enable-banking): resolve sync logic bugs, trailing whitespaces, and apply safe_psu_headers

* test(enable-banking): mock set_current_balance to return success result

* fix(budget): properly filter pending transactions and classify synced loan payments

* style: fix trailing whitespace detected by rubocop

* refactor: address code review feedback for Enable Banking sync and reporting

---------

Signed-off-by: Louis <contact@boul2gom.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-04-10 23:19:48 +02:00

172 lines
5.9 KiB
Ruby

class EnableBankingAccount < ApplicationRecord
include CurrencyNormalizable, Encryptable
# Encrypt raw payloads if ActiveRecord encryption is configured
if encryption_ready?
encrypts :raw_payload
encrypts :raw_transactions_payload
end
belongs_to :enable_banking_item
# New association through account_providers
has_one :account_provider, as: :provider, dependent: :destroy
has_one :account, through: :account_provider, source: :account
has_one :linked_account, through: :account_provider, source: :account
validates :name, :currency, presence: true
validates :uid, presence: true, uniqueness: { scope: :enable_banking_item_id }
# account_id is not uniquely scoped: uid already enforces one-account-per-identifier per item
# Helper to get account using account_providers system
def current_account
account
end
# Returns the API account ID (UUID) for Enable Banking API calls
# The Enable Banking API requires a valid UUID for balance/transaction endpoints
# Falls back to raw_payload["uid"] for existing accounts that have the wrong account_id stored
def api_account_id
# Check if account_id looks like a valid UUID (not an identification_hash)
if account_id.present? && account_id.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
account_id
else
# Fall back to raw_payload for existing accounts with incorrect account_id
raw_payload&.dig("uid") || account_id || uid
end
end
# Map PSD2 cash_account_type codes to user-friendly names
# Based on ISO 20022 External Cash Account Type codes
def account_type_display
return nil unless account_type.present?
type_mappings = {
"CACC" => "Current/Checking Account",
"SVGS" => "Savings Account",
"CARD" => "Card Account",
"CRCD" => "Credit Card",
"LOAN" => "Loan Account",
"MORT" => "Mortgage Account",
"ODFT" => "Overdraft Account",
"CASH" => "Cash Account",
"TRAN" => "Transacting Account",
"SALA" => "Salary Account",
"MOMA" => "Money Market Account",
"NREX" => "Non-Resident External Account",
"TAXE" => "Tax Account",
"TRAS" => "Cash Trading Account",
"ONDP" => "Overnight Deposit"
}
type_mappings[account_type.upcase] || account_type.titleize
end
CASH_ACCOUNT_TYPE_MAP = {
"CACC" => { type: "Depository", subtype: "checking" },
"SVGS" => { type: "Depository", subtype: "savings" },
"CARD" => { type: "CreditCard", subtype: "credit_card" },
"CRCD" => { type: "CreditCard", subtype: "credit_card" },
"LOAN" => { type: "Loan", subtype: nil },
"MORT" => { type: "Loan", subtype: "mortgage" },
"ODFT" => { type: "Depository", subtype: "checking" },
"TRAN" => { type: "Depository", subtype: "checking" },
"SALA" => { type: "Depository", subtype: "checking" },
"MOMA" => { type: "Depository", subtype: "savings" },
"NREX" => { type: "Depository", subtype: "checking" },
"TAXE" => { type: "Depository", subtype: "checking" },
"TRAS" => { type: "Depository", subtype: "checking" },
"ONDP" => { type: "Depository", subtype: "savings" },
"CASH" => { type: "Depository", subtype: "checking" },
"OTHR" => nil
}.freeze
def suggested_account_type
CASH_ACCOUNT_TYPE_MAP[account_type&.upcase]&.dig(:type)
end
def suggested_subtype
CASH_ACCOUNT_TYPE_MAP[account_type&.upcase]&.dig(:subtype)
end
def upsert_enable_banking_snapshot!(account_snapshot)
snapshot = account_snapshot.with_indifferent_access
raw_account_id = snapshot[:account_id]
account_id_data = if raw_account_id.is_a?(Hash)
raw_account_id
elsif raw_account_id.is_a?(Array) && raw_account_id.first.is_a?(Hash)
raw_account_id.find { |item| item[:iban].present? } || {}
else
{}
end
credit_limit_amount = snapshot.dig(:credit_limit, :amount)
update!(
current_balance: nil,
currency: parse_currency(snapshot[:currency]) || "EUR",
name: build_account_name(snapshot),
account_id: snapshot[:uid],
uid: snapshot[:identification_hash] || snapshot[:uid],
iban: account_id_data[:iban] || snapshot[:iban],
account_type: snapshot[:cash_account_type] || snapshot[:account_type],
account_status: "active",
provider: "enable_banking",
product: snapshot[:product],
credit_limit: parse_decimal_safe(credit_limit_amount),
identification_hashes: snapshot[:identification_hashes] || [],
institution_metadata: {
name: enable_banking_item&.aspsp_name,
aspsp_name: enable_banking_item&.aspsp_name,
bic: snapshot.dig(:account_servicer, :bic_fi),
servicer_name: snapshot.dig(:account_servicer, :name)
}.compact,
raw_payload: account_snapshot
)
end
def upsert_enable_banking_transactions_snapshot!(transactions_snapshot)
assign_attributes(
raw_transactions_payload: transactions_snapshot
)
save!
end
private
def build_account_name(snapshot)
# Try to build a meaningful name from the account data
raw_account_id = snapshot[:account_id]
account_id_data = if raw_account_id.is_a?(Hash)
raw_account_id
elsif raw_account_id.is_a?(Array) && raw_account_id.first.is_a?(Hash)
raw_account_id.find { |item| item[:iban].present? } || {}
else
{}
end
iban = account_id_data[:iban] || snapshot[:iban]
if snapshot[:name].present?
snapshot[:name]
elsif iban.present?
# Use last 4 digits of IBAN for privacy
"Account ...#{iban[-4..]}"
else
"Enable Banking Account"
end
end
def log_invalid_currency(currency_value)
Rails.logger.warn("Invalid currency code '#{currency_value}' for EnableBanking account #{id}, defaulting to EUR")
end
def parse_decimal_safe(value)
return nil if value.blank?
BigDecimal(value.to_s)
rescue ArgumentError, TypeError
nil
end
end