Files
sure/app/controllers/enable_banking_items_controller.rb
Alessio Cappa dd461faf84 feat: Allow account linking for Enable Banking accounts (#428)
* feat: Allow account linking for Enable Banking accounts

* fix: Typo in function name

* fix: naming issue

* fix: Add missing Enable Banking route

* feat: Add ability to link Enable Banking when adding a new account

* Mispelling

* fix: typo in method call

* fix: typo in column name

* Review suggestions

* Linter noise

* Small copy changes to avoid mobile UI blowout

* Provider generator (#364)

* Move provider config to family

* Update schema.rb

* Add provier generator

* Add table creation also

* FIX generator namespace

* Add support for global providers also

* Remove over-engineered stuff

* FIX parser

* FIX linter

* Some generator fixes

* Update generator with fixes

* Update item_model.rb.tt

* Add missing linkable concern

* Add missing routes

* Update adapter.rb.tt

* Update connectable_concern.rb.tt

* Update unlinking_concern.rb.tt

* Update family_generator.rb

* Update family_generator.rb

* Delete .claude/settings.local.json

Signed-off-by: soky srm <sokysrm@gmail.com>

* Move docs under API related folder

* Rename Rails generator doc

* Light edits to LLM generated doc

* Small Lunch Flow config panel regressions.

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>

* Skip generators autoloading (#430)

* Include Enable Banking items in Syncer (#434)

* feat: Include Enable Banking items in Syncer

* feat: include only active Enable Banking accounts

* Fix budgets page UI (#427)

* fix: Budget UI improvements

* feat: Reduce padding for sub-categories

* fix: Adjust padding for sub-category arrow

* Revert "feat: Reduce padding for sub-categories"

This reverts commit 7516c5a8e0.

* Revert "fix: Adjust padding for sub-category arrow"

This reverts commit ebc82542cf.

* fix: adjust padding for sub-categories

* fix: Add padding to uncategorized budget

* fix: Remove unnecessary HTML tag

* feat: Add translation keys for budgeted/actual

* feat(lang): add all brazilian portuguese translations (#416)

* feat(lang): add all brazilian portuguese translations

* feat: update pt-BR errors on translation

* fix: atualizar fix base

* feat: add reports translations

* feat: finish translation to brazilian portuguese

* fix: add to supported locales

* fix: number of translations

* fix: errors on translations

* fix: error on rubocop lint

---------

Co-authored-by: Leonardo Ralph <theleoralph@gmail.com>

* Add exclude transaction rule action (#437)

* Initial plan

* Add ExcludeTransaction rule action executor with tests

Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com>

* Copy clarification

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>

* Preparing for v0.6.6-alpha.3

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>

* fix: remove account_id clearing for Enable Banking accounts

* fix: Remove unexisting available_balance attribute and rename variable for consistency

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: soky srm <sokysrm@gmail.com>
Co-authored-by: Marcon Neves <marconwillian@icloud.com>
Co-authored-by: Leonardo Ralph <theleoralph@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com>
2025-12-12 11:19:50 +01:00

584 lines
22 KiB
Ruby

class EnableBankingItemsController < ApplicationController
include EnableBankingItems::MapsHelper
before_action :set_enable_banking_item, only: [ :update, :destroy, :sync, :select_bank, :authorize, :reauthorize, :setup_accounts, :complete_account_setup, :new_connection ]
skip_before_action :verify_authenticity_token, only: [ :callback ]
def new
@enable_banking_item = Current.family.enable_banking_items.build
end
def create
@enable_banking_item = Current.family.enable_banking_items.build(enable_banking_item_params)
@enable_banking_item.name ||= "Enable Banking Connection"
if @enable_banking_item.save
if turbo_frame_request?
flash.now[:notice] = t(".success", default: "Successfully configured Enable Banking.")
@enable_banking_items = Current.family.enable_banking_items.ordered
render turbo_stream: [
turbo_stream.replace(
"enable_banking-providers-panel",
partial: "settings/providers/enable_banking_panel",
locals: { enable_banking_items: @enable_banking_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @enable_banking_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"enable_banking-providers-panel",
partial: "settings/providers/enable_banking_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity
end
end
end
def update
if @enable_banking_item.update(enable_banking_item_params)
if turbo_frame_request?
flash.now[:notice] = t(".success", default: "Successfully updated Enable Banking configuration.")
@enable_banking_items = Current.family.enable_banking_items.ordered
render turbo_stream: [
turbo_stream.replace(
"enable_banking-providers-panel",
partial: "settings/providers/enable_banking_panel",
locals: { enable_banking_items: @enable_banking_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @enable_banking_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"enable_banking-providers-panel",
partial: "settings/providers/enable_banking_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity
end
end
end
def destroy
# Ensure we detach provider links before scheduling deletion
begin
@enable_banking_item.unlink_all!(dry_run: false)
rescue => e
Rails.logger.warn("Enable Banking unlink during destroy failed: #{e.class} - #{e.message}")
end
@enable_banking_item.revoke_session
@enable_banking_item.destroy_later
redirect_to settings_providers_path, notice: t(".success", default: "Scheduled Enable Banking connection for deletion.")
end
def sync
unless @enable_banking_item.syncing?
@enable_banking_item.sync_later
end
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end
# Show bank selection page
def select_bank
unless @enable_banking_item.credentials_configured?
redirect_to settings_providers_path, alert: t(".credentials_required", default: "Please configure your Enable Banking credentials first.")
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"] || []
rescue Provider::EnableBanking::EnableBankingError => e
Rails.logger.error "Enable Banking API error in select_bank: #{e.message}"
@error_message = e.message
@aspsps = []
end
render layout: false
end
# Initiate authorization for a selected bank
def authorize
aspsp_name = params[:aspsp_name]
unless aspsp_name.present?
redirect_to settings_providers_path, alert: t(".bank_required", default: "Please select a bank.")
return
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",
country_code: @enable_banking_item.country_code,
application_id: @enable_banking_item.application_id,
client_certificate: @enable_banking_item.client_certificate
)
else
@enable_banking_item
end
redirect_url = target_item.start_authorization(
aspsp_name: aspsp_name,
redirect_url: enable_banking_callback_url,
state: target_item.id
)
safe_redirect_to_enable_banking(
redirect_url,
fallback_path: settings_providers_path,
fallback_alert: t(".invalid_redirect", default: "Invalid authorization URL received. Please try again or contact support.")
)
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)
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)
end
rescue => e
Rails.logger.error "Unexpected error in authorize: #{e.class}: #{e.message}"
redirect_to settings_providers_path, alert: t(".unexpected_error", default: "An unexpected error occurred. Please try again.")
end
end
# Handle OAuth callback from Enable Banking
def callback
code = params[:code]
state = params[:state]
error = params[:error]
error_description = params[:error_description]
if error.present?
Rails.logger.error "Enable Banking callback error: #{error} - #{error_description}"
redirect_to settings_providers_path, alert: t(".authorization_error", default: "Authorization failed: %{error}", error: error_description || error)
return
end
unless code.present? && state.present?
redirect_to settings_providers_path, alert: t(".invalid_callback", default: "Invalid callback parameters.")
return
end
# Find the enable_banking_item by ID from state
enable_banking_item = Current.family.enable_banking_items.find_by(id: state)
unless enable_banking_item.present?
redirect_to settings_providers_path, alert: t(".item_not_found", default: "Connection not found.")
return
end
begin
enable_banking_item.complete_authorization(code: code)
# Trigger sync to process accounts
enable_banking_item.sync_later
redirect_to accounts_path, notice: t(".success", default: "Successfully connected to your bank. Your accounts are being synced.")
rescue Provider::EnableBanking::EnableBankingError => e
Rails.logger.error "Enable Banking session creation error: #{e.message}"
redirect_to settings_providers_path, alert: t(".session_failed", default: "Failed to complete authorization: %{message}", message: e.message)
rescue => e
Rails.logger.error "Unexpected error in callback: #{e.class}: #{e.message}"
redirect_to settings_providers_path, alert: t(".unexpected_error", default: "An unexpected error occurred. Please try again.")
end
end
# Show bank selection for a new connection using credentials from an existing item
# Does NOT create a new item - that happens in authorize when user selects a bank
def new_connection
# Redirect to select_bank with a flag indicating this is for a new connection
redirect_to select_bank_enable_banking_item_path(@enable_banking_item, new_connection: true), data: { turbo_frame: "modal" }
end
# Re-authorize an expired session
def reauthorize
begin
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
)
safe_redirect_to_enable_banking(
redirect_url,
fallback_path: settings_providers_path,
fallback_alert: t(".invalid_redirect", default: "Invalid authorization URL received. Please try again or contact support.")
)
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)
end
end
# Link accounts from Enable Banking to internal accounts
def link_accounts
selected_uids = params[:account_uids] || []
accountable_type = params[:accountable_type] || "Depository"
if selected_uids.empty?
redirect_to accounts_path, alert: t(".no_accounts_selected", default: "No accounts selected.")
return
end
enable_banking_item = Current.family.enable_banking_items.where.not(session_id: nil).first
unless enable_banking_item.present?
redirect_to settings_providers_path, alert: t(".no_session", default: "No active Enable Banking connection. Please connect a bank first.")
return
end
created_accounts = []
already_linked_accounts = []
# Wrap in transaction so partial failures don't leave orphaned accounts without provider links
begin
ActiveRecord::Base.transaction do
selected_uids.each do |uid|
enable_banking_account = enable_banking_item.enable_banking_accounts.find_by(uid: uid)
next unless enable_banking_account
# Check if already linked
if enable_banking_account.account_provider.present?
already_linked_accounts << enable_banking_account.name
next
end
# Create the internal Account (uses save! internally, will raise on failure)
account = Account.create_and_sync(
family: Current.family,
name: enable_banking_account.name,
balance: enable_banking_account.current_balance || 0,
currency: enable_banking_account.currency || "EUR",
accountable_type: accountable_type,
accountable_attributes: {}
)
# Link account to enable_banking_account via account_providers
# Uses create! so any failure will rollback the entire transaction
AccountProvider.create!(
account: account,
provider: enable_banking_account
)
created_accounts << account
end
end
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
Rails.logger.error "Enable Banking link_accounts failed: #{e.class} - #{e.message}"
redirect_to accounts_path, alert: t(".link_failed", default: "Failed to link accounts: %{error}", error: e.message)
return
end
# Trigger sync if accounts were created
enable_banking_item.sync_later if created_accounts.any?
if created_accounts.any?
redirect_to accounts_path, notice: t(".success", default: "%{count} account(s) linked successfully.", count: created_accounts.count)
elsif already_linked_accounts.any?
redirect_to accounts_path, alert: t(".already_linked", default: "Selected accounts are already linked.")
else
redirect_to accounts_path, alert: t(".link_failed", default: "Failed to link accounts.")
end
end
# Show setup accounts modal
def setup_accounts
@enable_banking_accounts = @enable_banking_item.enable_banking_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
@account_type_options = [
[ "Skip this account", "skip" ],
[ "Checking or Savings Account", "Depository" ],
[ "Credit Card", "CreditCard" ],
[ "Investment Account", "Investment" ],
[ "Loan or Mortgage", "Loan" ],
[ "Other Asset", "OtherAsset" ]
]
@subtype_options = {
"Depository" => {
label: "Account Subtype:",
options: Depository::SUBTYPES.map { |k, v| [ v[:long], k ] }
},
"CreditCard" => {
label: "",
options: [],
message: "Credit cards will be automatically set up as credit card accounts."
},
"Investment" => {
label: "Investment Type:",
options: Investment::SUBTYPES.map { |k, v| [ v[:long], k ] }
},
"Loan" => {
label: "Loan Type:",
options: Loan::SUBTYPES.map { |k, v| [ v[:long], k ] }
},
"OtherAsset" => {
label: nil,
options: [],
message: "Other assets will be set up as general assets."
}
}
render layout: false
end
# Complete account setup from modal
def complete_account_setup
account_types = params[:account_types] || {}
account_subtypes = params[:account_subtypes] || {}
# Update sync start date from form if provided
if params[:sync_start_date].present?
@enable_banking_item.update!(sync_start_date: params[:sync_start_date])
end
created_count = 0
skipped_count = 0
account_types.each do |enable_banking_account_id, selected_type|
# Skip accounts marked as "skip"
if selected_type == "skip" || selected_type.blank?
skipped_count += 1
next
end
enable_banking_account = @enable_banking_item.enable_banking_accounts.find(enable_banking_account_id)
selected_subtype = account_subtypes[enable_banking_account_id]
# Default subtype for CreditCard since it only has one option
selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank?
# Create account with user-selected type and subtype
account = Account.create_from_enable_banking_account(
enable_banking_account,
selected_type,
selected_subtype
)
# Link account via AccountProvider
AccountProvider.create!(
account: account,
provider: enable_banking_account
)
created_count += 1
end
# Clear pending status and mark as complete
@enable_banking_item.update!(pending_account_setup: false)
# Trigger a sync to process the imported data if accounts were created
@enable_banking_item.sync_later if created_count > 0
if created_count > 0
flash[:notice] = t(".success", default: "%{count} account(s) created successfully!", count: created_count)
elsif skipped_count > 0
flash[:notice] = t(".all_skipped", default: "All accounts were skipped. You can set them up later from the accounts page.")
else
flash[:notice] = t(".no_accounts", default: "No accounts to set up.")
end
redirect_to accounts_path, status: :see_other
end
def select_existing_account
@account = Current.family.accounts.find(params[:account_id])
# Filter out Enable Banking accounts that are already linked to any account
# (either via account_provider or legacy account association)
@available_enable_banking_accounts = Current.family.enable_banking_items
.includes(:enable_banking_accounts)
.flat_map(&:enable_banking_accounts)
.reject { |sfa| sfa.account_provider.present? || sfa.account.present? }
.sort_by { |sfa| sfa.updated_at || sfa.created_at }
.reverse
# Always render a modal: either choices or a helpful empty-state
render :select_existing_account, layout: false
end
def link_existing_account
@account = Current.family.accounts.find(params[:account_id])
enable_banking_account = EnableBankingAccount.find(params[:enable_banking_account_id])
# Guard: only manual accounts can be linked (no existing provider links or legacy IDs)
if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present?
flash[:alert] = "Only manual accounts can be linked"
if turbo_frame_request?
return render turbo_stream: Array(flash_notification_stream_items)
else
return redirect_to account_path(@account), alert: flash[:alert]
end
end
# Verify the Enable Banking account belongs to this family's Enable Banking items
unless enable_banking_account.enable_banking_item.present? &&
Current.family.enable_banking_items.include?(enable_banking_account.enable_banking_item)
flash[:alert] = "Invalid Enable Banking account selected"
if turbo_frame_request?
render turbo_stream: Array(flash_notification_stream_items)
else
redirect_to account_path(@account), alert: flash[:alert]
end
return
end
# Relink behavior: detach any legacy link and point provider link at the chosen account
Account.transaction do
enable_banking_account.lock!
# Upsert the AccountProvider mapping deterministically
ap = AccountProvider.find_or_initialize_by(provider: enable_banking_account)
previous_account = ap.account
ap.account_id = @account.id
ap.save!
# If the provider was previously linked to a different account in this family,
# and that account is now orphaned, quietly disable it so it disappears from the
# visible manual list. This mirrors the unified flow expectation that the provider
# follows the chosen account.
if previous_account && previous_account.id != @account.id && previous_account.family_id == @account.family_id
begin
previous_account.disable!
rescue => e
Rails.logger.warn("Failed to disable orphaned account #{previous_account.id}: #{e.class} - #{e.message}")
end
end
end
if turbo_frame_request?
# Reload the item to ensure associations are fresh
enable_banking_account.reload
item = enable_banking_account.enable_banking_item
item.reload
# Recompute data needed by Accounts#index partials
@manual_accounts = Account.uncached {
Current.family.accounts
.visible_manual
.order(:name)
.to_a
}
@enable_banking_items = Current.family.enable_banking_items.ordered.includes(:syncs)
build_enable_banking_maps_for(@enable_banking_items)
flash[:notice] = "Account successfully linked to Enable Banking"
@account.reload
manual_accounts_stream = if @manual_accounts.any?
turbo_stream.update(
"manual-accounts",
partial: "accounts/index/manual_accounts",
locals: { accounts: @manual_accounts }
)
else
turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts"))
end
render turbo_stream: [
# Optimistic removal of the specific account row if it exists in the DOM
turbo_stream.remove(ActionView::RecordIdentifier.dom_id(@account)),
manual_accounts_stream,
turbo_stream.replace(
ActionView::RecordIdentifier.dom_id(item),
partial: "enable_banking_items/enable_banking_item",
locals: { enable_banking_item: item }
),
turbo_stream.replace("modal", view_context.turbo_frame_tag("modal"))
] + Array(flash_notification_stream_items)
else
redirect_to accounts_path(cache_bust: SecureRandom.hex(6)), notice: "Account successfully linked to Enable Banking", status: :see_other
end
end
private
def set_enable_banking_item
@enable_banking_item = Current.family.enable_banking_items.find(params[:id])
end
def enable_banking_item_params
params.require(:enable_banking_item).permit(
:name,
:sync_start_date,
:country_code,
:application_id,
:client_certificate
)
end
# Generate the callback URL for Enable Banking OAuth
# In production, uses the standard Rails route
# In development, uses DEV_WEBHOOKS_URL if set (e.g., ngrok URL)
def enable_banking_callback_url
return callback_enable_banking_items_url if Rails.env.production?
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/enable_banking_items/callback"
end
# Validate redirect URLs from Enable Banking API to prevent open redirect attacks
# Only allows HTTPS URLs from trusted Enable Banking domains
TRUSTED_ENABLE_BANKING_HOSTS = %w[
enablebanking.com
api.enablebanking.com
auth.enablebanking.com
].freeze
def valid_enable_banking_redirect_url?(url)
return false if url.blank?
begin
uri = URI.parse(url)
# Must be HTTPS
return false unless uri.scheme == "https"
# Host must be present
return false if uri.host.blank?
# Check if host matches or is a subdomain of trusted domains
TRUSTED_ENABLE_BANKING_HOSTS.any? do |trusted_host|
uri.host == trusted_host || uri.host.end_with?(".#{trusted_host}")
end
rescue URI::InvalidURIError => e
Rails.logger.warn("Enable Banking invalid redirect URL: #{url.inspect} - #{e.message}")
false
end
end
def safe_redirect_to_enable_banking(redirect_url, fallback_path:, fallback_alert:)
if valid_enable_banking_redirect_url?(redirect_url)
redirect_to redirect_url, allow_other_host: true
else
Rails.logger.warn("Enable Banking redirect blocked - invalid URL: #{redirect_url.inspect}")
redirect_to fallback_path, alert: fallback_alert
end
end
end