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

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