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> +<%= aspsp[:name] %>
- <% if aspsp[:bic].present? %> -BIC: <%= aspsp[:bic] %>
+<%= aspsp[:name] %>
+ <% if aspsp[:beta] %> + + <%= t(".beta_label", default: "Beta") %> + + <% end %> +BIC: <%= aspsp[:bic] %>
+ <% end %> ++ <%= t("enable_banking_items.setup_accounts.psd2_savings_notice") %> +
+