feat(enable-banking): enhance transaction import, metadata handling, and UI (#1406)

* feat(enable-banking): enhance transaction import, metadata handling, and UI

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

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

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

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

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

* style: fix trailing whitespace detected by rubocop

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

---------

Signed-off-by: Louis <contact@boul2gom.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Louis
2026-04-10 23:19:48 +02:00
committed by GitHub
parent d6d7df12fd
commit e96fb0c23f
28 changed files with 1118 additions and 160 deletions

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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 })

View File

@@ -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" } %>

View File

@@ -3,7 +3,7 @@
<% dialog.with_header(title: t(".title", default: "Select Your Bank")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<div class="space-y-4" data-controller="bank-search">
<p class="text-sm text-secondary">
<%= t(".description", default: "Choose the bank you want to connect to your account.") %>
</p>
@@ -15,29 +15,52 @@
<% end %>
<% if @aspsps.present? %>
<%# Search input — filters list client-side via Stimulus %>
<input
type="text"
placeholder="<%= t(".search_placeholder", default: "Search for your bank...") %>"
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>
<div class="space-y-2 max-h-80 overflow-y-auto">
<% @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? %>
<img src="<%= aspsp[:logo] %>" alt="<%= aspsp[:name] %>" class="w-10 h-10 rounded object-contain">
<% else %>
<div class="w-10 h-10 rounded bg-gray-100 flex items-center justify-center">
<%= icon "building-bank", class: "w-5 h-5 text-gray-400" %>
</div>
<% end %>
<div class="flex-1">
<p class="font-medium text-sm text-primary"><%= aspsp[:name] %></p>
<% if aspsp[:bic].present? %>
<p class="text-xs text-secondary">BIC: <%= aspsp[:bic] %></p>
<div data-bank-search-target="item" data-bank-name="<%= aspsp[:name].to_s.downcase(:fold) %>">
<%= 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? %>
<img src="<%= aspsp[:logo] %>" alt="<%= aspsp[:name] %>" class="w-10 h-10 rounded object-contain">
<% else %>
<div class="w-10 h-10 rounded bg-container-inset flex items-center justify-center">
<%= icon "building-bank", class: "w-5 h-5 text-tertiary" %>
</div>
<% end %>
</div>
<%= icon "chevron-right", class: "w-5 h-5 text-secondary" %>
<% end %>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<p class="font-medium text-sm text-primary"><%= aspsp[:name] %></p>
<% if aspsp[:beta] %>
<span class="text-xs font-medium text-warning bg-warning/10 px-2 py-0.5 rounded-full flex-shrink-0">
<%= t(".beta_label", default: "Beta") %>
</span>
<% end %>
</div>
<% if aspsp[:bic].present? %>
<p class="text-xs text-secondary truncate">BIC: <%= aspsp[:bic] %></p>
<% end %>
</div>
<%= icon "chevron-right", class: "w-5 h-5 text-secondary flex-shrink-0" %>
<% end %>
</div>
<% end %>
<div data-bank-search-target="emptyState" class="hidden py-4 text-center text-sm text-secondary">
<%= t(".no_search_results", default: "No banks match your search.") %>
</div>
</div>
<% else %>
<div class="text-center py-8">

View File

@@ -35,6 +35,18 @@
</div>
</div>
<!-- PSD2 savings accounts notice -->
<div class="bg-surface border border-primary p-4 rounded-lg">
<div class="flex items-start gap-3">
<%= icon "alert-triangle", size: "sm", class: "text-warning mt-0.5 flex-shrink-0" %>
<div>
<p class="text-sm text-primary">
<%= t("enable_banking_items.setup_accounts.psd2_savings_notice") %>
</p>
</div>
</div>
</div>
<!-- Sync Date Range Selection -->
<div class="bg-surface border border-primary p-4 rounded-lg">
<div class="flex items-start gap-3">
@@ -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." %>
</div>
</div>
</div>
@@ -75,12 +87,15 @@
</div>
</div>
<div class="space-y-3" data-controller="account-type-selector" data-account-type-selector-account-id-value="<%= enable_banking_account.id %>">
<div class="space-y-3"
data-controller="account-type-selector"
data-account-type-selector-account-id-value="<%= enable_banking_account.id %>"
data-account-type-selector-suggested-subtype-value="<%= enable_banking_account.suggested_subtype %>">
<div>
<%= 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"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1,5 @@
class AddPsuTypeToEnableBankingItems < ActiveRecord::Migration[7.2]
def change
add_column :enable_banking_items, :psu_type, :string
end
end

11
db/schema.rb generated
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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")