diff --git a/app/controllers/enable_banking_items_controller.rb b/app/controllers/enable_banking_items_controller.rb index 6cb8b1fc8..0bfc0dabb 100644 --- a/app/controllers/enable_banking_items_controller.rb +++ b/app/controllers/enable_banking_items_controller.rb @@ -103,14 +103,15 @@ class EnableBankingItemsController < ApplicationController return end - # Track if this is for creating a new connection (vs re-authorizing existing) @new_connection = params[:new_connection] == "true" begin provider = @enable_banking_item.enable_banking_provider response = provider.get_aspsps(country: @enable_banking_item.country_code) - # API returns { aspsps: [...] }, extract the array - @aspsps = response[:aspsps] || response["aspsps"] || [] + raw_aspsps = response[:aspsps] || response["aspsps"] || [] + + # Sort: non-beta alphabetically, then beta alphabetically + @aspsps = raw_aspsps.map(&:with_indifferent_access).sort_by { |a| [ a[:beta] ? 1 : 0, a[:name].to_s.downcase ] } rescue Provider::EnableBanking::EnableBankingError => e Rails.logger.error "Enable Banking API error in select_bank: #{e.message}" @error_message = e.message @@ -123,14 +124,47 @@ class EnableBankingItemsController < ApplicationController # Initiate authorization for a selected bank def authorize aspsp_name = params[:aspsp_name] + psu_type = params[:psu_type].presence || "personal" unless aspsp_name.present? redirect_to settings_providers_path, alert: t(".bank_required", default: "Please select a bank.") return end + # Re-fetch ASPSP list from provider to avoid session cookie overflow. + # We do not store full ASPSP metadata in the session to stay within the 4KB limit; + # instead, we re-query the provider here for the final authorization parameters. + aspsp_data = nil + begin + provider_for_lookup = @enable_banking_item.enable_banking_provider + if provider_for_lookup + response = provider_for_lookup.get_aspsps(country: @enable_banking_item.country_code) + raw_aspsps = response[:aspsps] || response["aspsps"] || [] + found = raw_aspsps.find { |a| a[:name] == aspsp_name || a["name"] == aspsp_name } + aspsp_data = found&.with_indifferent_access + end + rescue Provider::EnableBanking::EnableBankingError => e + Rails.logger.warn "Enable Banking: could not fetch ASPSP metadata in authorize: #{e.message}" + end + + # Block DECOUPLED banks — our OAuth redirect flow doesn't support them + if aspsp_data.present? + # Adjust psu_type if the bank does not support the requested type + supported_types = Array(aspsp_data[:psu_types]).map(&:to_s) + if supported_types.any? && !supported_types.include?(psu_type) + psu_type = supported_types.first + end + + first_method = Array(aspsp_data[:auth_methods]).first + approach = first_method&.dig(:approach) || first_method&.dig("approach") + if approach == "DECOUPLED" + redirect_to settings_providers_path, alert: t(".decoupled_not_supported", + default: "This bank uses a separate device authentication method which is not yet supported. Please add this account manually.") + return + end + end + begin - # If this is a new connection request, create the item now (when user has selected a bank) target_item = if params[:new_connection] == "true" Current.family.enable_banking_items.create!( name: "Enable Banking Connection", @@ -142,10 +176,18 @@ class EnableBankingItemsController < ApplicationController @enable_banking_item end + # Capture PSU IP for use in background sync PSU headers + target_item.update(last_psu_ip: request.remote_ip) if request.remote_ip.present? + + language = I18n.locale.to_s.split("-").first + redirect_url = target_item.start_authorization( aspsp_name: aspsp_name, redirect_url: enable_banking_callback_url, - state: target_item.id + state: target_item.id, + psu_type: psu_type, + aspsp_data: aspsp_data, + language: language ) safe_redirect_to_enable_banking( @@ -156,10 +198,13 @@ class EnableBankingItemsController < ApplicationController rescue Provider::EnableBanking::EnableBankingError => e if e.message.include?("REDIRECT_URI_NOT_ALLOWED") Rails.logger.error "Enable Banking redirect URI not allowed: #{e.message}" - redirect_to settings_providers_path, alert: t(".redirect_uri_not_allowed", default: "Redirect not allowed. Configure `%{callback_url}` in your Enable Banking application settings.", callback_url: enable_banking_callback_url) + redirect_to settings_providers_path, alert: t(".redirect_uri_not_allowed", + default: "Redirect not allowed. Configure `%{callback_url}` in your Enable Banking application settings.", + callback_url: enable_banking_callback_url) else Rails.logger.error "Enable Banking authorization error: #{e.message}" - redirect_to settings_providers_path, alert: t(".authorization_failed", default: "Failed to start authorization: %{message}", message: e.message) + redirect_to settings_providers_path, alert: t(".authorization_failed", + default: "Failed to start authorization: %{message}", message: e.message) end rescue => e Rails.logger.error "Unexpected error in authorize: #{e.class}: #{e.message}" @@ -193,6 +238,9 @@ class EnableBankingItemsController < ApplicationController return end + # Refresh PSU IP on callback (user's browser is present here) + enable_banking_item.update(last_psu_ip: request.remote_ip) if request.remote_ip.present? + begin enable_banking_item.complete_authorization(code: code) @@ -219,10 +267,14 @@ class EnableBankingItemsController < ApplicationController # Re-authorize an expired session def reauthorize begin + language = I18n.locale.to_s.split("-").first + redirect_url = @enable_banking_item.start_authorization( aspsp_name: @enable_banking_item.aspsp_name, redirect_url: enable_banking_callback_url, - state: @enable_banking_item.id + state: @enable_banking_item.id, + psu_type: @enable_banking_item.psu_type || "personal", + language: language ) safe_redirect_to_enable_banking( @@ -232,7 +284,8 @@ class EnableBankingItemsController < ApplicationController ) rescue Provider::EnableBanking::EnableBankingError => e Rails.logger.error "Enable Banking reauthorization error: #{e.message}" - redirect_to settings_providers_path, alert: t(".reauthorization_failed", default: "Failed to re-authorize: %{message}", message: e.message) + redirect_to settings_providers_path, alert: t(".reauthorization_failed", + default: "Failed to re-authorize: %{message}", message: e.message) end end diff --git a/app/javascript/controllers/bank_search_controller.js b/app/javascript/controllers/bank_search_controller.js new file mode 100644 index 000000000..a2c045cd9 --- /dev/null +++ b/app/javascript/controllers/bank_search_controller.js @@ -0,0 +1,19 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["input", "item", "emptyState"]; + + filter() { + const query = this.inputTarget.value.toLocaleLowerCase().trim(); + let visibleCount = 0; + + this.itemTargets.forEach(item => { + const name = item.dataset.bankName?.toLocaleLowerCase() ?? ""; + const match = name.includes(query); + item.style.display = match ? "" : "none"; + if (match) visibleCount++; + }); + + this.emptyStateTarget.classList.toggle("hidden", visibleCount > 0); + } +} diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 2d814e9e8..633c26f1b 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -83,7 +83,8 @@ class Account::ProviderImportAdapter incoming_pending = ActiveModel::Type::Boolean.new.cast(pending_extra.dig("simplefin", "pending")) || ActiveModel::Type::Boolean.new.cast(pending_extra.dig("plaid", "pending")) || - ActiveModel::Type::Boolean.new.cast(pending_extra.dig("lunchflow", "pending")) + ActiveModel::Type::Boolean.new.cast(pending_extra.dig("lunchflow", "pending")) || + ActiveModel::Type::Boolean.new.cast(pending_extra.dig("enable_banking", "pending")) end if entry.new_record? && !incoming_pending @@ -160,6 +161,10 @@ class Account::ProviderImportAdapter elsif detected_label == "Contribution" auto_kind = "investment_contribution" auto_category = account.family.investment_contributions_category + elsif account.accountable_type == "Loan" && amount.negative? + auto_kind = "loan_payment" + elsif account.accountable_type == "CreditCard" && amount.negative? + auto_kind = "cc_payment" end # Set investment activity label, kind, and category if detected @@ -691,6 +696,7 @@ class Account::ProviderImportAdapter (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true + OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true SQL .order(date: :desc) # Prefer most recent pending transaction @@ -737,6 +743,7 @@ class Account::ProviderImportAdapter (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true + OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true SQL # If merchant_id is provided, prioritize matching by merchant @@ -806,6 +813,7 @@ class Account::ProviderImportAdapter (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true + OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true SQL # For low confidence, require BOTH merchant AND name match (stronger signal needed) diff --git a/app/models/enable_banking_account.rb b/app/models/enable_banking_account.rb index a94815d0e..91bf07539 100644 --- a/app/models/enable_banking_account.rb +++ b/app/models/enable_banking_account.rb @@ -62,38 +62,65 @@ class EnableBankingAccount < ApplicationRecord 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) - # Convert to symbol keys or handle both string and symbol keys snapshot = account_snapshot.with_indifferent_access - # Map Enable Banking field names to our field names - # Enable Banking API returns: { uid, iban, account_id: { iban }, currency, cash_account_type, ... } - # account_id can be a hash with iban, or an array of account identifiers 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) - # If it's an array of hashes, find the one with iban raw_account_id.find { |item| item[:iban].present? } || {} else {} end + credit_limit_amount = snapshot.dig(:credit_limit, :amount) + update!( - current_balance: nil, # Balance fetched separately via /accounts/{uid}/balances + current_balance: nil, currency: parse_currency(snapshot[:currency]) || "EUR", name: build_account_name(snapshot), - # account_id stores the API UUID for fetching balances/transactions account_id: snapshot[:uid], - # uid is the stable identifier (identification_hash) for matching accounts across sessions 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 + 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 ) @@ -134,4 +161,11 @@ class EnableBankingAccount < ApplicationRecord 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 diff --git a/app/models/enable_banking_account/processor.rb b/app/models/enable_banking_account/processor.rb index ada55916f..f48abfa33 100644 --- a/app/models/enable_banking_account/processor.rb +++ b/app/models/enable_banking_account/processor.rb @@ -1,4 +1,5 @@ class EnableBankingAccount::Processor + class ProcessingError < StandardError; end include CurrencyNormalizable attr_reader :enable_banking_account @@ -37,23 +38,47 @@ class EnableBankingAccount::Processor account = enable_banking_account.current_account balance = enable_banking_account.current_balance || 0 + available_credit = nil - # For credit cards, compute balance based on credit limit - if account.accountable_type == "CreditCard" - available_credit = account.accountable.available_credit || 0 - balance = available_credit - balance - # For liability accounts, ensure positive balances - elsif account.accountable_type == "Loan" - balance = -balance + # 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 + 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" - account.update!( - balance: balance, - cash_balance: balance, - currency: currency - ) + # 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 diff --git a/app/models/enable_banking_account/transactions/processor.rb b/app/models/enable_banking_account/transactions/processor.rb index 9791ad96f..831539610 100644 --- a/app/models/enable_banking_account/transactions/processor.rb +++ b/app/models/enable_banking_account/transactions/processor.rb @@ -18,11 +18,16 @@ class EnableBankingAccount::Transactions::Processor failed_count = 0 errors = [] + shared_adapter = if enable_banking_account.current_account.present? + Account::ProviderImportAdapter.new(enable_banking_account.current_account) + end + enable_banking_account.raw_transactions_payload.each_with_index do |transaction_data, index| begin result = EnableBankingEntry::Processor.new( transaction_data, - enable_banking_account: enable_banking_account + enable_banking_account: enable_banking_account, + import_adapter: shared_adapter ).process if result.nil? diff --git a/app/models/enable_banking_entry/processor.rb b/app/models/enable_banking_entry/processor.rb index 643991491..8ffc6807a 100644 --- a/app/models/enable_banking_entry/processor.rb +++ b/app/models/enable_banking_entry/processor.rb @@ -10,9 +10,10 @@ class EnableBankingEntry::Processor # transaction_amount: { amount, currency }, # creditor_name, debtor_name, remittance_information, ... # } - def initialize(enable_banking_transaction, enable_banking_account:) + def initialize(enable_banking_transaction, enable_banking_account:, import_adapter: nil) @enable_banking_transaction = enable_banking_transaction @enable_banking_account = enable_banking_account + @import_adapter = import_adapter end def process @@ -30,7 +31,8 @@ class EnableBankingEntry::Processor name: name, source: "enable_banking", merchant: merchant, - notes: notes + notes: notes, + extra: extra ) rescue ArgumentError => e Rails.logger.error "EnableBankingEntry::Processor - Validation error for transaction #{external_id}: #{e.message}" @@ -123,10 +125,34 @@ class EnableBankingEntry::Processor end def notes - remittance = data[:remittance_information] - return nil unless remittance.is_a?(Array) && remittance.any? + parts = [] - remittance.join("\n") + remittance = data[:remittance_information] + if remittance.is_a?(Array) && remittance.any? + parts << remittance.join("\n") + elsif remittance.is_a?(String) && remittance.present? + parts << remittance + end + + parts << data[:note] if data[:note].present? + + parts.join("\n\n").presence + end + + def extra + eb = {} + + if data[:exchange_rate].present? + eb[:fx_rate] = data.dig(:exchange_rate, :exchange_rate) + eb[:fx_unit_currency] = data.dig(:exchange_rate, :unit_currency) + eb[:fx_instructed_amount] = data.dig(:exchange_rate, :instructed_amount, :amount) + end + + eb[:merchant_category_code] = data[:merchant_category_code] if data[:merchant_category_code].present? + eb[:pending] = true if data[:_pending] == true + + eb.compact! + eb.empty? ? nil : { enable_banking: eb } end def amount_value @@ -143,8 +169,9 @@ class EnableBankingEntry::Processor BigDecimal("0") end - # CRDT (credit) = money coming in = positive - # DBIT (debit) = money going out = negative + # Sure convention: positive = outflow (expense/debit from account), negative = inflow (income/credit) + # Enable Banking: DBIT = debit from account (outflow), CRDT = credit to account (inflow) + # Therefore: DBIT → +absolute_amount, CRDT → -absolute_amount credit_debit_indicator == "CRDT" ? -absolute_amount : absolute_amount rescue ArgumentError => e Rails.logger.error "Failed to parse Enable Banking transaction amount: #{raw_amount.inspect} - #{e.message}" @@ -157,9 +184,8 @@ class EnableBankingEntry::Processor end def amount - # Enable Banking uses PSD2 Berlin Group convention: negative = debit (outflow), positive = credit (inflow) - # Sure uses the same convention: negative = expense, positive = income - # Therefore, use the amount as-is from the API without inversion + # Sure convention: positive = outflow (debit/expense), negative = inflow (credit/income) + # amount_value already applies this: DBIT → +absolute, CRDT → -absolute amount_value end diff --git a/app/models/enable_banking_item.rb b/app/models/enable_banking_item.rb index d1e8684c9..7f3b6a540 100644 --- a/app/models/enable_banking_item.rb +++ b/app/models/enable_banking_item.rb @@ -48,24 +48,63 @@ class EnableBankingItem < ApplicationRecord !session_valid? end + # TODO: implement data retention policy for last_psu_ip (GDPR/CCPA — nullify after session expiry or 90 days) + + validate :psu_type_in_aspsp_types + + def psu_type_in_aspsp_types + return if psu_type.blank? || aspsp_psu_types.blank? + unless aspsp_psu_types.include?(psu_type) + errors.add(:psu_type, "must be one of the ASPSP supported types") + end + end + # Start the OAuth authorization flow - # Returns a redirect URL for the user - def start_authorization(aspsp_name:, redirect_url:, state: nil) + # @param aspsp_name [String] Name of the selected ASPSP + # @param redirect_url [String] Callback URL + # @param state [String, nil] State parameter (passed through to callback) + # @param psu_type [String] "personal" or "business" + # @param aspsp_data [Hash, nil] Full ASPSP object from GET /aspsps (used to store metadata) + # @param language [String, nil] Two-letter language code + # @return [String] Redirect URL for the user + def start_authorization(aspsp_name:, redirect_url:, state: nil, psu_type: "personal", + aspsp_data: nil, language: nil) provider = enable_banking_provider raise StandardError.new("Enable Banking provider is not configured") unless provider + validated_psu_type = psu_type + + # Store ASPSP metadata before calling provider so it's available even if auth fails + if aspsp_data.present? + aspsp_data = aspsp_data.with_indifferent_access + first_auth_method = aspsp_data.dig(:auth_methods, 0) || aspsp_data.dig("auth_methods", 0) + aspsp_types = aspsp_data[:psu_types] || [] + update!( + aspsp_required_psu_headers: aspsp_data[:required_psu_headers] || [], + aspsp_maximum_consent_validity: aspsp_data[:maximum_consent_validity], + aspsp_auth_approach: first_auth_method&.dig(:approach) || first_auth_method&.dig("approach"), + aspsp_psu_types: aspsp_types + ) + validated_psu_type = psu_type.present? && aspsp_types.include?(psu_type) ? psu_type : nil + end + result = provider.start_authorization( aspsp_name: aspsp_name, aspsp_country: country_code, redirect_url: redirect_url, - state: state + state: state, + psu_type: validated_psu_type, + maximum_consent_validity: aspsp_maximum_consent_validity, + language: language ) - # Store the authorization ID for later use - update!( + attributes = { authorization_id: result[:authorization_id], aspsp_name: aspsp_name - ) + } + attributes[:psu_type] = validated_psu_type if validated_psu_type.present? + + update!(attributes) result[:url] end @@ -250,14 +289,13 @@ class EnableBankingItem < ApplicationRecord private def parse_session_expiry(session_result) - # Enable Banking sessions typically last 90 days - # The exact expiry depends on the ASPSP consent if session_result[:access].present? && session_result[:access][:valid_until].present? - Time.parse(session_result[:access][:valid_until]) + parsed = Time.zone.parse(session_result[:access][:valid_until]) + parsed || 90.days.from_now else 90.days.from_now end - rescue => e + rescue ArgumentError, TypeError => e Rails.logger.warn "EnableBankingItem #{id} - Failed to parse session expiry: #{e.message}" 90.days.from_now end diff --git a/app/models/enable_banking_item/importer.rb b/app/models/enable_banking_item/importer.rb index 9facd18d2..fb20f3ada 100644 --- a/app/models/enable_banking_item/importer.rb +++ b/app/models/enable_banking_item/importer.rb @@ -29,6 +29,8 @@ class EnableBankingItem::Importer Rails.logger.error "EnableBankingItem::Importer - Failed to store session snapshot: #{e.message}" end + sync_uids_from_accounts_data(session_data[:accounts]) + # Update accounts from session accounts_updated = 0 accounts_failed = 0 @@ -147,7 +149,7 @@ class EnableBankingItem::Importer # 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) + enable_banking_account = find_enable_banking_account_by_hash(uid) return unless enable_banking_account enable_banking_account.upsert_enable_banking_snapshot!(account_data) @@ -155,26 +157,40 @@ class EnableBankingItem::Importer 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) + balance_data = enable_banking_provider.get_account_balances( + account_id: enable_banking_account.api_account_id, + psu_headers: enable_banking_item.build_psu_headers + ) - # Enable Banking returns an array of balances + # Enable Banking returns an array of balances. We prioritize types based on reliability. + # closingBooked (CLBD) > interimAvailable (ITAV) > expected (XPCD) balances = balance_data[:balances] || [] return if balances.empty? - # Find the most relevant balance (prefer "ITAV" or "CLAV" types) - balance = balances.find { |b| b[:balance_type] == "ITAV" } || - balances.find { |b| b[:balance_type] == "CLAV" } || - balances.find { |b| b[:balance_type] == "ITBD" } || - balances.find { |b| b[:balance_type] == "CLBD" } || - balances.first + priority_types = [ "CLBD", "ITAV", "XPCD", "CLAV", "ITBD" ] + balance = nil + + priority_types.each do |type| + balance = balances.find { |b| b[:balance_type] == type } + break if balance + end + + balance ||= balances.first if balance.present? amount = balance.dig(:balance_amount, :amount) || balance[:amount] currency = balance.dig(:balance_amount, :currency) || balance[:currency] if amount.present? + indicator = balance[:credit_debit_indicator] + parsed_amount = amount.to_d + + # Enable Banking uses positive amounts for both credit and debit. + # DBIT indicates a negative balance (money owed/withdrawn). + parsed_amount = -parsed_amount if indicator == "DBIT" + enable_banking_account.update!( - current_balance: amount.to_d, + current_balance: parsed_amount, currency: currency.presence || enable_banking_account.currency ) end @@ -186,59 +202,71 @@ class EnableBankingItem::Importer 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 + all_transactions = fetch_paginated_transactions( + enable_banking_account, + start_date: start_date, + transaction_status: "BOOK", + psu_headers: enable_banking_item.build_psu_headers + ) - # Paginate through all transactions with safeguards against infinite loops - loop do - page_count += 1 + # Also fetch pending transactions (visible for 1-3 days before they become BOOK) + pending_transactions = fetch_paginated_transactions( + enable_banking_account, + start_date: start_date, + transaction_status: "PDNG", + psu_headers: enable_banking_item.build_psu_headers + ) - # 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 + book_ids = all_transactions + .map { |tx| tx.with_indifferent_access[:transaction_id].presence } + .compact.to_set - transactions_data = enable_banking_provider.get_account_transactions( - account_id: enable_banking_account.api_account_id, - date_from: start_date, - continuation_key: continuation_key - ) + book_entry_refs = all_transactions + .select { |tx| tx.with_indifferent_access[:transaction_id].blank? } + .map { |tx| tx.with_indifferent_access[:entry_reference].presence } + .compact.to_set - 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? + pending_transactions.reject! do |tx| + tx = tx.with_indifferent_access + tx[:transaction_id].present? ? book_ids.include?(tx[:transaction_id]) : book_entry_refs.include?(tx[:entry_reference].presence) end + all_transactions = all_transactions + tag_as_pending(pending_transactions) + # Deduplicate API response: Enable Banking sometimes returns the same logical # transaction with different entry_reference IDs in the same response. # Remove content-level duplicates before storing. (Issue #954) all_transactions = deduplicate_api_transactions(all_transactions) + # Post-fetch safety filter: some ASPSPs ignore date_from or return extra transactions + all_transactions = filter_transactions_by_date(all_transactions, start_date) + transactions_count = all_transactions.count if all_transactions.any? existing_transactions = enable_banking_account.raw_transactions_payload.to_a + + # C4: Remove stored PDNG entries that have now settled as BOOK. + # When a BOOK transaction arrives with the same transaction_id as a stored + # PDNG entry, the pending entry is stale — drop it to avoid duplicates. + book_ids = all_transactions + .reject { |tx| tx.with_indifferent_access[:_pending] } + .map { |tx| tx.with_indifferent_access[:transaction_id].presence } + .compact.to_set + + # Fallback: collect entry_references for BOOK rows that have no transaction_id + book_entry_refs = all_transactions + .reject { |tx| tx.with_indifferent_access[:_pending] } + .map { |tx| tx.with_indifferent_access[:entry_reference].presence } + .compact.to_set + + removed_pending = existing_transactions.reject! do |tx| + tx = tx.with_indifferent_access + pending_flag = tx.dig(:extra, :enable_banking, :pending) || tx[:_pending] + next false unless pending_flag + tx[:transaction_id].present? ? book_ids.include?(tx[:transaction_id]) : book_entry_refs.include?(tx[:entry_reference].presence) + end + existing_ids = existing_transactions.map { |tx| tx = tx.with_indifferent_access tx[:transaction_id].presence || tx[:entry_reference].presence @@ -250,7 +278,7 @@ class EnableBankingItem::Importer tx_id.present? && !existing_ids.include?(tx_id) end - if new_transactions.any? + if new_transactions.any? || removed_pending enable_banking_account.upsert_enable_banking_transactions_snapshot!(existing_transactions + new_transactions) end end @@ -308,6 +336,8 @@ class EnableBankingItem::Importer # unique. credit_debit_indicator (CRDT/DBIT) is included because # transaction_amount.amount is always positive — without it, a payment # and a same-day refund of the same amount would produce identical keys. + # status (BOOK/PDNG) is intentionally excluded: the same logical transaction + # may appear as PDNG then BOOK across imports and must not create duplicates. # Known limitation: when transaction_id is nil for both, pure content # comparison applies. This means two genuinely distinct transactions # with identical content (same date, amount, direction, creditor, etc.) @@ -322,11 +352,106 @@ class EnableBankingItem::Importer debtor = tx.dig(:debtor, :name).presence || tx[:debtor_name] remittance = tx[:remittance_information] remittance_key = remittance.is_a?(Array) ? remittance.compact.map(&:to_s).sort.join("|") : remittance.to_s - status = tx[:status] tid = tx[:transaction_id] direction = tx[:credit_debit_indicator] - [ date, amount, currency, creditor, debtor, remittance_key, status, tid, direction ].map(&:to_s).join("\x1F") + [ date, amount, currency, creditor, debtor, remittance_key, tid, direction ].map(&:to_s).join("\x1F") + end + + class PaginationTruncatedError < StandardError; end + + def fetch_paginated_transactions(enable_banking_account, start_date:, transaction_status:, psu_headers: {}) + all_transactions = [] + continuation_key = nil + previous_continuation_key = nil + page_count = 0 + + loop do + page_count += 1 + + if page_count > MAX_PAGINATION_PAGES + msg = "EnableBankingItem::Importer - Pagination limit exceeded for account #{enable_banking_account.uid} (status=#{transaction_status}). Stopped after #{MAX_PAGINATION_PAGES} pages." + raise PaginationTruncatedError, msg + 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, + transaction_status: transaction_status, + psu_headers: psu_headers + ) + + transactions = transactions_data[:transactions] || [] + all_transactions.concat(transactions) + + previous_continuation_key = continuation_key + continuation_key = transactions_data[:continuation_key] + + if continuation_key.present? && continuation_key == previous_continuation_key + msg = "EnableBankingItem::Importer - Repeated continuation_key detected for account #{enable_banking_account.uid} (status=#{transaction_status}). Breaking after #{page_count} pages." + raise PaginationTruncatedError, msg + end + + break if continuation_key.blank? + end + + all_transactions + rescue PaginationTruncatedError => e + # Log as warning and return collected partial data instead of failing entirely. + # This ensures accounts with huge history don't lose all synced data. + Rails.logger.warn(e.message) + all_transactions + end + + def filter_transactions_by_date(transactions, start_date) + return transactions unless start_date + + transactions.reject do |tx| + tx = tx.with_indifferent_access + date_str = tx[:booking_date] || tx[:value_date] || tx[:transaction_date] + next false if date_str.blank? # Keep if no date (cannot determine) + + begin + Date.parse(date_str.to_s) < start_date + rescue ArgumentError + false # Keep if date is unparseable + end + end + end + + def tag_as_pending(transactions) + transactions.map { |tx| tx.merge(_pending: true) } + end + + def find_enable_banking_account_by_hash(hash_value) + return nil if hash_value.blank? + + # First: exact uid match (primary identification_hash) + account = enable_banking_item.enable_banking_accounts.find_by(uid: hash_value.to_s) + return account if account + + # Second: search in identification_hashes array (PostgreSQL JSONB contains operator) + enable_banking_item.enable_banking_accounts + .where("identification_hashes @> ?", [ hash_value.to_s ].to_json) + .first + end + + def sync_uids_from_accounts_data(accounts_data) + return if accounts_data.blank? + + accounts_data.each do |ad| + next unless ad.is_a?(Hash) + ad = ad.with_indifferent_access + identification_hash = ad[:identification_hash] + current_uid = ad[:uid] + next if identification_hash.blank? || current_uid.blank? + + eb_acc = find_enable_banking_account_by_hash(identification_hash) + next unless eb_acc + # Update the API account_id (UUID) if it has changed (UIDs are session-scoped) + eb_acc.update!(account_id: current_uid) if eb_acc.account_id != current_uid + end end def determine_sync_start_date(enable_banking_account) diff --git a/app/models/enable_banking_item/provided.rb b/app/models/enable_banking_item/provided.rb index bd57d2795..1466d79cb 100644 --- a/app/models/enable_banking_item/provided.rb +++ b/app/models/enable_banking_item/provided.rb @@ -9,4 +9,22 @@ module EnableBankingItem::Provided client_certificate: client_certificate ) end + + # Build PSU context headers for data endpoint calls. + # The Enable Banking API spec mandates: "either all required PSU headers or none". + # We can only provide Psu-Ip-Address (from last_psu_ip stored at request time). + # If the ASPSP requires other PSU headers we cannot satisfy server-side, we send none + # to avoid a PSU_HEADER_NOT_PROVIDED error for partially-supplied headers. + def build_psu_headers + return {} if aspsp_required_psu_headers.blank? + + required = aspsp_required_psu_headers.map(&:downcase) + + # Only attempt to satisfy the headers if the only required one is Psu-Ip-Address + # (the one we can populate from stored data) + satisfiable = required.all? { |h| h == "psu-ip-address" } + return {} unless satisfiable && last_psu_ip.present? + + { "Psu-Ip-Address" => last_psu_ip } + end end diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb index f9757da16..4dc40af05 100644 --- a/app/models/income_statement/category_stats.rb +++ b/app/models/income_statement/category_stats.rb @@ -45,6 +45,10 @@ class IncomeStatement::CategoryStats @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") end + def pending_providers_sql + Transaction.pending_providers_sql("t") + end + def exclude_tax_advantaged_sql ids = @family.tax_advantaged_account_ids return "" if ids.empty? @@ -62,8 +66,8 @@ class IncomeStatement::CategoryStats SELECT c.id as category_id, date_trunc(:interval, ae.date) as period, - CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(CASE WHEN t.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total + CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + SUM(CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total FROM transactions t JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id @@ -76,11 +80,10 @@ class IncomeStatement::CategoryStats WHERE a.family_id = :family_id AND t.kind NOT IN (#{budget_excluded_kinds_sql}) AND ae.excluded = false - AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true - AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + #{pending_providers_sql} #{exclude_tax_advantaged_sql} #{scope_to_account_ids_sql} - GROUP BY c.id, period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY c.id, period, CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT category_id, diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb index d172d4ebf..c3925025f 100644 --- a/app/models/income_statement/family_stats.rb +++ b/app/models/income_statement/family_stats.rb @@ -44,6 +44,10 @@ class IncomeStatement::FamilyStats @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") end + def pending_providers_sql + Transaction.pending_providers_sql("t") + end + def exclude_tax_advantaged_sql ids = @family.tax_advantaged_account_ids return "" if ids.empty? @@ -60,8 +64,8 @@ class IncomeStatement::FamilyStats WITH period_totals AS ( SELECT date_trunc(:interval, ae.date) as period, - CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(CASE WHEN t.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total + CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + SUM(CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total FROM transactions t JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id @@ -73,11 +77,10 @@ class IncomeStatement::FamilyStats WHERE a.family_id = :family_id AND t.kind NOT IN (#{budget_excluded_kinds_sql}) AND ae.excluded = false - AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true - AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + #{pending_providers_sql} #{exclude_tax_advantaged_sql} #{scope_to_account_ids_sql} - GROUP BY period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY period, CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT classification, diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index 54fb56732..81920038f 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -60,8 +60,8 @@ class IncomeStatement::Totals SELECT c.id as category_id, c.parent_id as parent_category_id, - CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(CASE WHEN at.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, + CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, COUNT(ae.id) as transactions_count, false as is_uncategorized_investment FROM (#{@transactions_scope.to_sql}) at @@ -79,7 +79,7 @@ class IncomeStatement::Totals AND a.status IN ('draft', 'active') #{exclude_tax_advantaged_sql} #{include_finance_accounts_sql} - GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; + GROUP BY c.id, c.parent_id, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; SQL end @@ -88,8 +88,8 @@ class IncomeStatement::Totals SELECT c.id as category_id, c.parent_id as parent_category_id, - CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(CASE WHEN at.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, + CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, COUNT(ae.id) as entry_count, false as is_uncategorized_investment FROM (#{@transactions_scope.to_sql}) at @@ -111,7 +111,7 @@ class IncomeStatement::Totals AND a.status IN ('draft', 'active') #{exclude_tax_advantaged_sql} #{include_finance_accounts_sql} - GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY c.id, c.parent_id, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END SQL end diff --git a/app/models/provider/enable_banking.rb b/app/models/provider/enable_banking.rb index eb7ebd025..8acfad9c5 100644 --- a/app/models/provider/enable_banking.rb +++ b/app/models/provider/enable_banking.rb @@ -35,13 +35,21 @@ class Provider::EnableBanking # @param aspsp_name [String] Name of the ASPSP from get_aspsps # @param aspsp_country [String] Country code for the ASPSP # @param redirect_url [String] URL to redirect user back to after auth - # @param state [String] Optional state parameter to pass through + # @param state [String, nil] State parameter to pass through # @param psu_type [String] "personal" or "business" + # @param maximum_consent_validity [Integer, nil] Max consent duration in seconds from ASPSP (nil = use 90 days) + # @param language [String, nil] Two-letter language code (e.g. "fr", "en") # @return [Hash] Contains :url and :authorization_id - def start_authorization(aspsp_name:, aspsp_country:, redirect_url:, state: nil, psu_type: "personal") + def start_authorization(aspsp_name:, aspsp_country:, redirect_url:, state: nil, + psu_type: "personal", maximum_consent_validity: nil, language: nil) + max_seconds = maximum_consent_validity ? [ maximum_consent_validity, 1 ].max : 90.days.to_i + valid_until = [ Time.current + max_seconds.seconds, Time.current + 90.days ].min + body = { access: { - valid_until: (Time.current + 90.days).iso8601 + valid_until: valid_until.iso8601, + balances: true, + transactions: true }, aspsp: { name: aspsp_name, @@ -50,7 +58,9 @@ class Provider::EnableBanking state: state, redirect_url: redirect_url, psu_type: psu_type - }.compact + } + body[:language] = language if language.present? + body = body.compact response = self.class.post( "#{BASE_URL}/auth", @@ -111,12 +121,13 @@ class Provider::EnableBanking # Get account details # @param account_id [String] The account ID (UID from Enable Banking) + # @param psu_headers [Hash] Optional PSU context headers required by some ASPSPs # @return [Hash] Account details - def get_account_details(account_id:) + def get_account_details(account_id:, psu_headers: {}) encoded_id = CGI.escape(account_id.to_s) response = self.class.get( "#{BASE_URL}/accounts/#{encoded_id}/details", - headers: auth_headers + headers: auth_headers.merge(safe_psu_headers(psu_headers)) ) handle_response(response) @@ -126,12 +137,13 @@ class Provider::EnableBanking # Get account balances # @param account_id [String] The account ID (UID from Enable Banking) + # @param psu_headers [Hash] Optional PSU context headers required by some ASPSPs # @return [Hash] Balance information - def get_account_balances(account_id:) + def get_account_balances(account_id:, psu_headers: {}) encoded_id = CGI.escape(account_id.to_s) response = self.class.get( "#{BASE_URL}/accounts/#{encoded_id}/balances", - headers: auth_headers + headers: auth_headers.merge(safe_psu_headers(psu_headers)) ) handle_response(response) @@ -144,18 +156,21 @@ class Provider::EnableBanking # @param date_from [Date, nil] Start date for transactions # @param date_to [Date, nil] End date for transactions # @param continuation_key [String, nil] For pagination + # @param transaction_status [String, nil] Filter: "BOOK", "PDNG", or nil for all + # @param psu_headers [Hash] Optional PSU context headers required by some ASPSPs # @return [Hash] Transactions and continuation_key for pagination - def get_account_transactions(account_id:, date_from: nil, date_to: nil, continuation_key: nil) + def get_account_transactions(account_id:, date_from: nil, date_to: nil, + continuation_key: nil, transaction_status: nil, psu_headers: {}) encoded_id = CGI.escape(account_id.to_s) query_params = {} - query_params[:transaction_status] = "BOOK" # Only accounted transactions + query_params[:transaction_status] = transaction_status if transaction_status.present? query_params[:date_from] = date_from.to_date.iso8601 if date_from query_params[:date_to] = date_to.to_date.iso8601 if date_to query_params[:continuation_key] = continuation_key if continuation_key response = self.class.get( "#{BASE_URL}/accounts/#{encoded_id}/transactions", - headers: auth_headers, + headers: auth_headers.merge(safe_psu_headers(psu_headers)), query: query_params.presence ) @@ -166,6 +181,10 @@ class Provider::EnableBanking private + def safe_psu_headers(headers) + headers.except("Authorization", :Authorization, "Accept", :Accept, "Content-Type", :"Content-Type") + end + def extract_private_key(certificate_pem) # Extract private key from PEM certificate OpenSSL::PKey::RSA.new(certificate_pem) @@ -215,6 +234,8 @@ class Provider::EnableBanking raise EnableBankingError.new("Access forbidden - check your application permissions", :access_forbidden) when 404 raise EnableBankingError.new("Resource not found", :not_found) + when 408 + raise EnableBankingError.new("Request timeout from Enable Banking API", :timeout) when 422 raise EnableBankingError.new("Validation error from Enable Banking API: #{response.body}", :validation_error) when 429 diff --git a/app/models/transaction.rb b/app/models/transaction.rb index aeba12276..e20370ead 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -93,7 +93,7 @@ class Transaction < ApplicationRecord INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].freeze # Providers that support pending transaction flags - PENDING_PROVIDERS = %w[simplefin plaid lunchflow].freeze + PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking].freeze # Pending transaction scopes - filter based on provider pending flags in extra JSONB # Works with any provider that stores pending status in extra["provider_name"]["pending"] @@ -107,6 +107,14 @@ class Transaction < ApplicationRecord where(conditions.join(" AND ")) } + # SQL snippet for raw queries that must exclude pending transactions. + # Use in income statements, balance sheets, and raw analytics. + def self.pending_providers_sql(table_alias = "t") + PENDING_PROVIDERS.map do |provider| + "AND (#{table_alias}.extra -> '#{provider}' ->> 'pending')::boolean IS DISTINCT FROM true" + end.join("\n") + end + # Family-scoped query for Enrichable#clear_ai_cache def self.family_scope(family) joins(entry: :account).where(accounts: { family_id: family.id }) diff --git a/app/views/enable_banking_items/_subtype_select.html.erb b/app/views/enable_banking_items/_subtype_select.html.erb index a9bab3848..c5969396c 100644 --- a/app/views/enable_banking_items/_subtype_select.html.erb +++ b/app/views/enable_banking_items/_subtype_select.html.erb @@ -2,9 +2,7 @@ <% if subtype_config[:options].present? %> <%= label_tag "account_subtypes[#{enable_banking_account.id}]", subtype_config[:label], class: "block text-sm font-medium text-primary mb-2" %> - <% selected_value = account_type == "Depository" ? - (enable_banking_account.name.downcase.include?("checking") ? "checking" : - enable_banking_account.name.downcase.include?("savings") ? "savings" : "") : "" %> + <% selected_value = enable_banking_account.suggested_subtype.presence || "" %> <%= select_tag "account_subtypes[#{enable_banking_account.id}]", options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value), { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full" } %> diff --git a/app/views/enable_banking_items/select_bank.html.erb b/app/views/enable_banking_items/select_bank.html.erb index 561639dd8..124c8e3ec 100644 --- a/app/views/enable_banking_items/select_bank.html.erb +++ b/app/views/enable_banking_items/select_bank.html.erb @@ -3,7 +3,7 @@ <% dialog.with_header(title: t(".title", default: "Select Your Bank")) %> <% dialog.with_body do %> -
+

<%= t(".description", default: "Choose the bank you want to connect to your account.") %>

@@ -15,29 +15,52 @@ <% end %> <% if @aspsps.present? %> + <%# Search input — filters list client-side via Stimulus %> + " + data-bank-search-target="input" + data-action="input->bank-search#filter" + class="w-full px-3 py-2 text-sm rounded-md border border-primary bg-container-inset text-primary placeholder:text-secondary focus:outline-none focus:ring-1 focus:ring-primary" + autocomplete="off" + aria-label="<%= t(".search_label", default: "Search for your bank") %>" + autofocus> +
<% @aspsps.each do |aspsp| %> - <%= button_to authorize_enable_banking_item_path(@enable_banking_item), - method: :post, - params: { aspsp_name: aspsp[:name], new_connection: @new_connection }, - class: "w-full flex items-center gap-4 p-3 rounded-lg border border-primary bg-container hover:bg-subtle transition-colors text-left", - data: { turbo: false } do %> - <% if aspsp[:logo].present? %> - <%= aspsp[:name] %> - <% else %> -
- <%= icon "building-bank", class: "w-5 h-5 text-gray-400" %> -
- <% end %> -
-

<%= aspsp[:name] %>

- <% if aspsp[:bic].present? %> -

BIC: <%= aspsp[:bic] %>

+
+ <%= button_to authorize_enable_banking_item_path(@enable_banking_item), + method: :post, + params: { aspsp_name: aspsp[:name], new_connection: @new_connection }, + class: "w-full flex items-center gap-4 p-3 rounded-lg border border-primary bg-container hover:bg-subtle transition-colors text-left", + data: { turbo: false } do %> + <% if aspsp[:logo].present? %> + <%= aspsp[:name] %> + <% else %> +
+ <%= icon "building-bank", class: "w-5 h-5 text-tertiary" %> +
<% end %> -
- <%= icon "chevron-right", class: "w-5 h-5 text-secondary" %> - <% end %> +
+
+

<%= aspsp[:name] %>

+ <% if aspsp[:beta] %> + + <%= t(".beta_label", default: "Beta") %> + + <% end %> +
+ <% if aspsp[:bic].present? %> +

BIC: <%= aspsp[:bic] %>

+ <% end %> +
+ <%= icon "chevron-right", class: "w-5 h-5 text-secondary flex-shrink-0" %> + <% end %> +
<% end %> +
<% else %>
diff --git a/app/views/enable_banking_items/setup_accounts.html.erb b/app/views/enable_banking_items/setup_accounts.html.erb index 2db41d72d..5da8e3016 100644 --- a/app/views/enable_banking_items/setup_accounts.html.erb +++ b/app/views/enable_banking_items/setup_accounts.html.erb @@ -35,6 +35,18 @@
+ +
+
+ <%= icon "alert-triangle", size: "sm", class: "text-warning mt-0.5 flex-shrink-0" %> +
+

+ <%= t("enable_banking_items.setup_accounts.psd2_savings_notice") %> +

+
+
+
+
@@ -46,10 +58,10 @@ <%= form.date_field :sync_start_date, label: "Start syncing transactions from:", value: @enable_banking_item.sync_start_date || 3.months.ago.to_date, - min: 1.year.ago.to_date, + min: 2.years.ago.to_date, max: Date.current, class: "w-full max-w-xs rounded-md border border-primary px-3 py-2 text-sm bg-container-inset text-primary", - help_text: "Select how far back you want to sync transaction history. Maximum 1 year of history available." %> + help_text: "Select how far back you want to sync transaction history. Maximum 2 years of history available." %>
@@ -75,12 +87,15 @@ -
+
<%= label_tag "account_types[#{enable_banking_account.id}]", "Account Type:", class: "block text-sm font-medium text-primary mb-2" %> <%= select_tag "account_types[#{enable_banking_account.id}]", - options_for_select(@account_type_options, "skip"), + options_for_select(@account_type_options, enable_banking_account.suggested_account_type || "skip"), { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full", data: { action: "change->account-type-selector#updateSubtype" diff --git a/config/locales/views/enable_banking_items/en.yml b/config/locales/views/enable_banking_items/en.yml index dfbcd8501..b143fe1a8 100644 --- a/config/locales/views/enable_banking_items/en.yml +++ b/config/locales/views/enable_banking_items/en.yml @@ -2,8 +2,9 @@ en: enable_banking_items: authorize: - authorization_failed: Failed to initiate authorization + authorization_failed: "Failed to initiate authorization: %{message}" bank_required: Please select a bank. + decoupled_not_supported: This bank uses a separate device authentication method which is not yet supported. Please add this account manually. invalid_redirect: The authorization URL received is invalid. Please try again. redirect_uri_not_allowed: Redirect not allowed. Please configure `%{callback_url}` in your Enable Banking app settings. unexpected_error: An unexpected error occurred. Please try again. @@ -39,11 +40,17 @@ en: invalid_redirect: The authorization URL received is invalid. Please try again. reauthorization_failed: Reauthorization failed select_bank: + beta_label: Beta cancel: Cancel check_country: Please check your country code settings. credentials_required: Please configure your Enable Banking credentials first. description: Select the bank you want to connect to your accounts. no_banks: No banks available for this country/region. + no_search_results: No banks match your search. + search_label: Search for your bank + search_placeholder: Search for your bank... title: Select Your Bank + setup_accounts: + psd2_savings_notice: "Note: Some regulated French savings accounts (Livret A, PEL, LEP, LDDS) may have limited or no access via Open Banking (PSD2). If a savings account is missing, you can add it manually." update: success: Enable Banking configuration updated. diff --git a/config/locales/views/enable_banking_items/fr.yml b/config/locales/views/enable_banking_items/fr.yml index d4e3bccb0..1d68e177c 100644 --- a/config/locales/views/enable_banking_items/fr.yml +++ b/config/locales/views/enable_banking_items/fr.yml @@ -4,6 +4,7 @@ fr: authorize: authorization_failed: Échec de l'initiation de l'autorisation bank_required: Veuillez sélectionner une banque. + decoupled_not_supported: Cette banque utilise une méthode d'authentification sur un appareil séparé qui n'est pas encore prise en charge. Veuillez ajouter ce compte manuellement. invalid_redirect: L'URL d'autorisation reçue est invalide. Veuillez réessayer. redirect_uri_not_allowed: Redirection non autorisée. Veuillez configurer `%{callback_url}` dans les paramètres de votre application Enable Banking. unexpected_error: Une erreur inattendue s'est produite. Veuillez réessayer. @@ -39,11 +40,17 @@ fr: invalid_redirect: L'URL d'autorisation reçue est invalide. Veuillez réessayer. reauthorization_failed: Échec de la réautorisation select_bank: + beta_label: Bêta cancel: Annuler check_country: Veuillez vérifier les paramètres de votre code pays. credentials_required: Veuillez d'abord configurer vos identifiants Enable Banking. description: Sélectionnez la banque que vous souhaitez connecter à vos comptes. no_banks: Aucune banque disponible pour ce pays/région. + no_search_results: "Aucun établissement ne correspond à votre recherche." + search_placeholder: Recherchez votre banque... + search_label: "Rechercher votre banque" title: Sélectionnez votre banque + setup_accounts: + psd2_savings_notice: "Remarque : Certains comptes d'épargne réglementés français (Livret A, PEL, LEP, LDDS) peuvent avoir un accès limité ou inexistant via l'Open Banking (DSP2). Si un compte d'épargne est manquant, vous pouvez l'ajouter manuellement." update: success: Configuration d'Enable Banking mise à jour. diff --git a/db/migrate/20260405120000_add_aspsp_metadata_to_enable_banking.rb b/db/migrate/20260405120000_add_aspsp_metadata_to_enable_banking.rb new file mode 100644 index 000000000..b1553e87e --- /dev/null +++ b/db/migrate/20260405120000_add_aspsp_metadata_to_enable_banking.rb @@ -0,0 +1,35 @@ +class AddAspspMetadataToEnableBanking < ActiveRecord::Migration[7.2] + def change + # ASPSP-level metadata on the item (stored when user selects a bank) + add_column :enable_banking_items, :aspsp_required_psu_headers, :jsonb, default: [] + add_column :enable_banking_items, :aspsp_maximum_consent_validity, :integer # in seconds + add_column :enable_banking_items, :aspsp_auth_approach, :string # REDIRECT | EMBEDDED | DECOUPLED + add_column :enable_banking_items, :aspsp_psu_types, :jsonb, default: [] + # PII/GDPR Notice: last_psu_ip stores the user's IP address. + # - Required for the Psu-Ip-Address header in Enable Banking API requests + # - Must be declared in the privacy policy + # - Data retention: consider nullifying after session expiry or 90 days + add_column :enable_banking_items, :last_psu_ip, :string # user IP captured at request time + + # Fix sync_start_date type: was datetime, should be date + reversible do |dir| + dir.up do + # Truncate any non-midnight time components before converting datetime→date. + # sync_start_date is a user-configured date — time components are meaningless. + execute(<<~SQL) + UPDATE enable_banking_items + SET sync_start_date = DATE_TRUNC('day', sync_start_date) + WHERE sync_start_date IS NOT NULL + AND sync_start_date != DATE_TRUNC('day', sync_start_date) + SQL + change_column :enable_banking_items, :sync_start_date, :date + end + dir.down { change_column :enable_banking_items, :sync_start_date, :datetime } + end + + # Account-level fields from AccountResource + add_column :enable_banking_accounts, :product, :string # bank's proprietary product name + add_column :enable_banking_accounts, :credit_limit, :decimal, precision: 19, scale: 4 + add_column :enable_banking_accounts, :identification_hashes, :jsonb, default: [] + end +end diff --git a/db/migrate/20260406120000_add_psu_type_to_enable_banking_items.rb b/db/migrate/20260406120000_add_psu_type_to_enable_banking_items.rb new file mode 100644 index 000000000..2767601c0 --- /dev/null +++ b/db/migrate/20260406120000_add_psu_type_to_enable_banking_items.rb @@ -0,0 +1,5 @@ +class AddPsuTypeToEnableBankingItems < ActiveRecord::Migration[7.2] + def change + add_column :enable_banking_items, :psu_type, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index cd59bffdc..ba39edf6d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -403,6 +403,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_10_114435) do t.jsonb "raw_transactions_payload" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "product" + t.decimal "credit_limit", precision: 19, scale: 4 + t.jsonb "identification_hashes", default: [] t.index ["account_id"], name: "index_enable_banking_accounts_on_account_id" t.index ["enable_banking_item_id"], name: "index_enable_banking_accounts_on_enable_banking_item_id" end @@ -418,7 +421,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_10_114435) do t.string "status", default: "good" t.boolean "scheduled_for_deletion", default: false t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" + t.date "sync_start_date" t.jsonb "raw_payload" t.jsonb "raw_institution_payload" t.string "country_code" @@ -431,6 +434,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_10_114435) do t.string "authorization_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "aspsp_required_psu_headers", default: [] + t.integer "aspsp_maximum_consent_validity" + t.string "aspsp_auth_approach" + t.jsonb "aspsp_psu_types", default: [] + t.string "last_psu_ip" + t.string "psu_type" t.index ["family_id"], name: "index_enable_banking_items_on_family_id" t.index ["status"], name: "index_enable_banking_items_on_status" end diff --git a/test/models/enable_banking_account/processor_test.rb b/test/models/enable_banking_account/processor_test.rb new file mode 100644 index 000000000..811df535b --- /dev/null +++ b/test/models/enable_banking_account/processor_test.rb @@ -0,0 +1,72 @@ +require "test_helper" + +class EnableBankingAccount::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @account = accounts(:depository) + @enable_banking_item = EnableBankingItem.create!( + family: @family, + name: "Test EB", + country_code: "FR", + application_id: "app_id", + client_certificate: "cert" + ) + @enable_banking_account = EnableBankingAccount.create!( + enable_banking_item: @enable_banking_item, + name: "Compte courant", + uid: "hash_abc", + currency: "EUR", + current_balance: 1500.00 + ) + AccountProvider.create!(account: @account, provider: @enable_banking_account) + end + + test "calls set_current_balance instead of direct account update" do + EnableBankingAccount::Processor.new(@enable_banking_account).process + + assert_equal 1500.0, @account.reload.cash_balance + end + + test "updates account currency" do + @enable_banking_account.update!(currency: "USD") + + EnableBankingAccount::Processor.new(@enable_banking_account).process + + assert_equal "USD", @account.reload.currency + end + + test "does nothing when no linked account" do + @account.account_providers.destroy_all + + result = EnableBankingAccount::Processor.new(@enable_banking_account).process + assert_nil result + end + + test "sets CC balance to available_credit when credit_limit is present" do + cc_account = accounts(:credit_card) + @enable_banking_account.update!( + current_balance: 450.00, + credit_limit: 1000.00 + ) + AccountProvider.find_by(provider: @enable_banking_account)&.destroy + AccountProvider.create!(account: cc_account, provider: @enable_banking_account) + + EnableBankingAccount::Processor.new(@enable_banking_account).process + + assert_equal 550.0, cc_account.reload.cash_balance + if cc_account.accountable.respond_to?(:available_credit) + assert_equal 550.0, cc_account.accountable.reload.available_credit + end + end + + test "sets CC balance to raw outstanding when credit_limit is absent" do + cc_account = accounts(:credit_card) + @enable_banking_account.update!(current_balance: 300.00, credit_limit: nil) + AccountProvider.find_by(provider: @enable_banking_account)&.destroy + AccountProvider.create!(account: cc_account, provider: @enable_banking_account) + + EnableBankingAccount::Processor.new(@enable_banking_account).process + + assert_equal 300.0, cc_account.reload.cash_balance + end +end diff --git a/test/models/enable_banking_account_test.rb b/test/models/enable_banking_account_test.rb new file mode 100644 index 000000000..d638e0ec0 --- /dev/null +++ b/test/models/enable_banking_account_test.rb @@ -0,0 +1,152 @@ +require "test_helper" + +class EnableBankingAccountTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = EnableBankingItem.create!( + family: @family, + name: "Test EB", + country_code: "FR", + application_id: "app_id", + client_certificate: "cert" + ) + @account = EnableBankingAccount.create!( + enable_banking_item: @item, + name: "Mon compte", + uid: "hash_abc123", + currency: "EUR" + ) + end + + # suggested_account_type / suggested_subtype mapping + test "suggests Depository checking for CACC" do + @account.update!(account_type: "CACC") + assert_equal "Depository", @account.suggested_account_type + assert_equal "checking", @account.suggested_subtype + end + + test "suggests Depository savings for SVGS" do + @account.update!(account_type: "SVGS") + assert_equal "Depository", @account.suggested_account_type + assert_equal "savings", @account.suggested_subtype + end + + test "suggests CreditCard for CARD" do + @account.update!(account_type: "CARD") + assert_equal "CreditCard", @account.suggested_account_type + assert_equal "credit_card", @account.suggested_subtype + end + + test "suggests Loan for LOAN" do + @account.update!(account_type: "LOAN") + assert_equal "Loan", @account.suggested_account_type + assert_nil @account.suggested_subtype + end + + test "suggests Loan mortgage for MORT" do + @account.update!(account_type: "MORT") + assert_equal "Loan", @account.suggested_account_type + assert_equal "mortgage", @account.suggested_subtype + end + + test "returns nil for OTHR" do + @account.update!(account_type: "OTHR") + assert_nil @account.suggested_account_type + assert_nil @account.suggested_subtype + end + + test "suggests Depository savings for MOMA and ONDP" do + @account.update!(account_type: "MOMA") + assert_equal "Depository", @account.suggested_account_type + assert_equal "savings", @account.suggested_subtype + + @account.update!(account_type: "ONDP") + assert_equal "Depository", @account.suggested_account_type + assert_equal "savings", @account.suggested_subtype + end + + test "suggests Depository checking for NREX, TAXE, and TRAS" do + @account.update!(account_type: "NREX") + assert_equal "Depository", @account.suggested_account_type + assert_equal "checking", @account.suggested_subtype + + @account.update!(account_type: "TAXE") + assert_equal "Depository", @account.suggested_account_type + assert_equal "checking", @account.suggested_subtype + + @account.update!(account_type: "TRAS") + assert_equal "Depository", @account.suggested_account_type + assert_equal "checking", @account.suggested_subtype + end + + test "returns nil when account_type is blank" do + @account.update!(account_type: nil) + assert_nil @account.suggested_account_type + assert_nil @account.suggested_subtype + end + + test "is case insensitive for account type mapping" do + @account.update!(account_type: "svgs") + assert_equal "Depository", @account.suggested_account_type + assert_equal "savings", @account.suggested_subtype + end + + # upsert_enable_banking_snapshot! stores new fields + test "stores product from snapshot" do + @account.upsert_enable_banking_snapshot!({ + uid: "hash_abc123", + identification_hash: "hash_abc123", + currency: "EUR", + cash_account_type: "SVGS", + product: "Livret A" + }) + assert_equal "Livret A", @account.reload.product + end + + test "stores identification_hashes from snapshot" do + @account.upsert_enable_banking_snapshot!({ + uid: "uid_uuid_123", + identification_hash: "hash_abc123", + identification_hashes: [ "hash_abc123", "hash_old456" ], + currency: "EUR", + cash_account_type: "CACC" + }) + reloaded_account = @account.reload + assert_includes reloaded_account.identification_hashes, "hash_abc123" + assert_includes reloaded_account.identification_hashes, "hash_old456" + end + + test "stores credit_limit from snapshot" do + @account.upsert_enable_banking_snapshot!({ + uid: "uid_uuid_123", + identification_hash: "hash_abc123", + currency: "EUR", + cash_account_type: "CARD", + credit_limit: { amount: "2000.00", currency: "EUR" } + }) + assert_equal 2000.00, @account.reload.credit_limit.to_f + end + + test "stores account_servicer bic in institution_metadata" do + @account.upsert_enable_banking_snapshot!({ + uid: "uid_uuid_123", + identification_hash: "hash_abc123", + currency: "EUR", + cash_account_type: "CACC", + account_servicer: { bic_fi: "BOURFRPPXXX", name: "Boursobank" } + }) + metadata = @account.reload.institution_metadata + assert_equal "BOURFRPPXXX", metadata["bic"] + assert_equal "Boursobank", metadata["servicer_name"] + end + + test "stores empty identification_hashes when not in snapshot" do + @account.upsert_enable_banking_snapshot!({ + uid: "uid_uuid_123", + identification_hash: "hash_abc123", + currency: "EUR", + cash_account_type: "CACC" + }) + assert_equal [], @account.reload.identification_hashes + end +end diff --git a/test/models/enable_banking_entry/processor_test.rb b/test/models/enable_banking_entry/processor_test.rb index 33e55f46e..f8fae7323 100644 --- a/test/models/enable_banking_entry/processor_test.rb +++ b/test/models/enable_banking_entry/processor_test.rb @@ -117,4 +117,95 @@ class EnableBankingEntry::ProcessorTest < ActiveSupport::TestCase entry = @account.entries.find_by!(external_id: "enable_banking_string_key_ref", source: "enable_banking") assert_equal 15.0, entry.amount.to_f end + + test "includes note field in transaction notes alongside remittance_information" do + tx = { + entry_reference: "ref_note", + transaction_id: nil, + booking_date: Date.current.to_s, + transaction_amount: { amount: "10.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + remittance_information: [ "Facture 2026-001" ], + note: "Détail comptable interne", + status: "BOOK" + } + + EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + entry = @account.entries.find_by!(external_id: "enable_banking_ref_note") + assert_includes entry.notes, "Facture 2026-001" + assert_includes entry.notes, "Détail comptable interne" + end + + test "stores exchange_rate in extra when present" do + tx = { + entry_reference: "ref_fx", + transaction_id: nil, + booking_date: Date.current.to_s, + transaction_amount: { amount: "100.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + exchange_rate: { + unit_currency: "USD", + exchange_rate: "1.0821", + rate_type: "SPOT", + instructed_amount: { amount: "108.21", currency: "USD" } + }, + status: "BOOK" + } + + EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + entry = @account.entries.find_by!(external_id: "enable_banking_ref_fx") + eb_extra = entry.transaction&.extra&.dig("enable_banking") + assert_equal "1.0821", eb_extra["fx_rate"] + assert_equal "USD", eb_extra["fx_unit_currency"] + assert_equal "108.21", eb_extra["fx_instructed_amount"] + end + + test "stores merchant_category_code in extra when present" do + tx = { + entry_reference: "ref_mcc", + transaction_id: nil, + booking_date: Date.current.to_s, + transaction_amount: { amount: "25.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + merchant_category_code: "5411", + status: "BOOK" + } + + EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + entry = @account.entries.find_by!(external_id: "enable_banking_ref_mcc") + eb_extra = entry.transaction&.extra&.dig("enable_banking") + assert_equal "5411", eb_extra["merchant_category_code"] + end + + test "stores pending true in extra for PDNG-tagged transactions" do + tx = { + entry_reference: "ref_pdng", + transaction_id: nil, + booking_date: Date.current.to_s, + transaction_amount: { amount: "15.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + status: "PDNG", + _pending: true + } + + EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + entry = @account.entries.find_by!(external_id: "enable_banking_ref_pdng") + eb_extra = entry.transaction&.extra&.dig("enable_banking") + assert_equal true, eb_extra["pending"] + end + + test "does not add enable_banking extra key when no extra data present" do + tx = { + entry_reference: "ref_noextra", + transaction_id: nil, + booking_date: Date.current.to_s, + transaction_amount: { amount: "5.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + + EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + entry = @account.entries.find_by!(external_id: "enable_banking_ref_noextra") + assert_nil entry.transaction&.extra&.dig("enable_banking") + end end diff --git a/test/models/enable_banking_item/importer_pdng_test.rb b/test/models/enable_banking_item/importer_pdng_test.rb new file mode 100644 index 000000000..3d444ebf4 --- /dev/null +++ b/test/models/enable_banking_item/importer_pdng_test.rb @@ -0,0 +1,146 @@ +require "test_helper" + +class EnableBankingItem::ImporterPdngTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @account = accounts(:depository) + + @enable_banking_item = EnableBankingItem.create!( + family: @family, + name: "Test EB", + country_code: "FR", + application_id: "test_app_id", + client_certificate: "test_cert", + session_id: "test_session", + session_expires_at: 1.day.from_now, + sync_start_date: Date.new(2026, 3, 1) + ) + @enable_banking_account = EnableBankingAccount.create!( + enable_banking_item: @enable_banking_item, + name: "Compte courant", + uid: "hash_abc123", + account_id: "uuid-1234-5678-abcd", + currency: "EUR" + ) + AccountProvider.create!(account: @account, provider: @enable_banking_account) + + @mock_provider = mock() + @importer = EnableBankingItem::Importer.new(@enable_banking_item, enable_banking_provider: @mock_provider) + end + + # --- Post-fetch date filtering --- + + test "filters out transactions before sync_start_date" do + old_tx = { + entry_reference: "old_ref", + transaction_id: nil, + booking_date: "2024-01-15", # Before sync_start_date of 2026-03-01 + transaction_amount: { amount: "50.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + recent_tx = { + entry_reference: "recent_ref", + transaction_id: nil, + booking_date: "2026-03-10", + transaction_amount: { amount: "30.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + + result = @importer.send(:filter_transactions_by_date, [ old_tx, recent_tx ], Date.new(2026, 3, 1)) + + assert_equal 1, result.count + assert_equal "recent_ref", result.first[:entry_reference] + end + + test "uses value_date when booking_date is absent for filtering" do + tx_only_value_date = { + entry_reference: "vd_ref", + transaction_id: nil, + value_date: "2024-06-01", # Before sync_start_date + transaction_amount: { amount: "10.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + + result = @importer.send(:filter_transactions_by_date, [ tx_only_value_date ], Date.new(2026, 3, 1)) + + assert_equal 0, result.count + end + + test "keeps transactions with no date (cannot determine, keep to avoid data loss)" do + tx_no_date = { + entry_reference: "nodate_ref", + transaction_id: nil, + transaction_amount: { amount: "10.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + + result = @importer.send(:filter_transactions_by_date, [ tx_no_date ], Date.new(2026, 3, 1)) + + assert_equal 1, result.count + end + + test "keeps transactions on exactly sync_start_date" do + boundary_tx = { + entry_reference: "boundary_ref", + transaction_id: nil, + booking_date: "2026-03-01", # Exactly on sync_start_date + transaction_amount: { amount: "10.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + + result = @importer.send(:filter_transactions_by_date, [ boundary_tx ], Date.new(2026, 3, 1)) + + assert_equal 1, result.count + end + + # --- PDNG transaction tagging --- + + test "tags PDNG transactions with pending: true in extra" do + pdng_tx = { + entry_reference: "pdng_ref", + transaction_id: "pdng_txn", + booking_date: Date.current.to_s, + transaction_amount: { amount: "20.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + status: "PDNG" + } + + result = @importer.send(:tag_as_pending, [ pdng_tx ]) + + assert_equal true, result.first[:_pending] + end + + test "tags all passed transactions regardless of status (caller is responsible for filtering)" do + # tag_as_pending blindly marks everything passed to it. + # The caller (fetch_and_store_transactions) is responsible for only passing PDNG transactions. + any_tx = { + entry_reference: "any_ref", + transaction_id: "any_txn", + booking_date: Date.current.to_s, + transaction_amount: { amount: "20.00", currency: "EUR" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + + result = @importer.send(:tag_as_pending, [ any_tx ]) + + assert_equal true, result.first[:_pending] + end + + # --- identification_hashes matching --- + + test "find_enable_banking_account_by_hash uses identification_hashes for matching" do + # Account already exists with uid = identification_hash + @enable_banking_account.update!(identification_hashes: [ "hash_abc123", "hash_old_xyz" ]) + + # Lookup by a secondary hash that is in identification_hashes + found = @importer.send(:find_enable_banking_account_by_hash, "hash_old_xyz") + + assert_equal @enable_banking_account.id, found.id + end +end diff --git a/test/models/transaction_test.rb b/test/models/transaction_test.rb index e3fbf9c26..b6086d5c7 100644 --- a/test/models/transaction_test.rb +++ b/test/models/transaction_test.rb @@ -25,6 +25,18 @@ class TransactionTest < ActiveSupport::TestCase assert_not transaction.pending? end + test "pending? returns true for enable_banking pending transactions" do + transaction = Transaction.new(extra: { "enable_banking" => { "pending" => true } }) + + assert transaction.pending? + end + + test "pending? returns false for enable_banking non-pending transactions" do + transaction = Transaction.new(extra: { "enable_banking" => { "pending" => false } }) + + assert_not transaction.pending? + end + test "investment_contribution is a valid kind" do transaction = Transaction.new(kind: "investment_contribution")