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>
This commit is contained in:
Alessio Cappa
2025-12-12 11:19:50 +01:00
committed by GitHub
parent eb6bbb754d
commit dd461faf84
11 changed files with 409 additions and 15 deletions

View File

@@ -0,0 +1,94 @@
# frozen_string_literal: true
module EnableBankingItems
module MapsHelper
extend ActiveSupport::Concern
# Build per-item maps consumed by the enable_banking_item partial.
# Accepts a single EnableBankingItem or a collection.
def build_enable_banking_maps_for(items)
items = Array(items).compact
return if items.empty?
@enable_banking_sync_stats_map ||= {}
@enable_banking_has_unlinked_map ||= {}
@enable_banking_unlinked_count_map ||= {}
@enable_banking_duplicate_only_map ||= {}
@enable_banking_show_relink_map ||= {}
# Batch-check if ANY family has manual accounts (same result for all items from same family)
family_ids = items.map { |i| i.family_id }.uniq
families_with_manuals = Account
.visible_manual
.where(family_id: family_ids)
.distinct
.pluck(:family_id)
.to_set
# Batch-fetch unlinked counts for all items in one query
unlinked_counts = EnableBankingAccount
.where(enable_banking_item_id: items.map(&:id))
.left_joins(:account, :account_provider)
.where(accounts: { id: nil }, account_providers: { id: nil })
.group(:enable_banking_item_id)
.count
items.each do |item|
# Latest sync stats (avoid N+1; rely on includes(:syncs) where appropriate)
latest_sync = if item.syncs.loaded?
item.syncs.max_by(&:created_at)
else
item.syncs.ordered.first
end
stats = (latest_sync&.sync_stats || {})
@enable_banking_sync_stats_map[item.id] = stats
# Whether the family has any manual accounts available to link (from batch query)
@enable_banking_has_unlinked_map[item.id] = families_with_manuals.include?(item.family_id)
# Count from batch query (defaults to 0 if not found)
@enable_banking_unlinked_count_map[item.id] = unlinked_counts[item.id] || 0
# Whether all reported errors for this item are duplicate-account warnings
@enable_banking_duplicate_only_map[item.id] = compute_duplicate_only_flag(stats)
# Compute CTA visibility: show relink only when there are zero unlinked SFAs,
# there exist manual accounts to link, and the item has at least one SFA
begin
unlinked_count = @enable_banking_unlinked_count_map[item.id] || 0
manuals_exist = @enable_banking_has_unlinked_map[item.id]
sfa_any = if item.enable_banking_accounts.loaded?
item.enable_banking_accounts.any?
else
item.enable_banking_accounts.exists?
end
@enable_banking_show_relink_map[item.id] = (unlinked_count.to_i == 0 && manuals_exist && sfa_any)
rescue StandardError => e
Rails.logger.warn("Enable Banking card: CTA computation failed for item #{item.id}: #{e.class} - #{e.message}")
@enable_banking_show_relink_map[item.id] = false
end
end
# Ensure maps are hashes even when items empty
@enable_banking_sync_stats_map ||= {}
@enable_banking_has_unlinked_map ||= {}
@enable_banking_unlinked_count_map ||= {}
@enable_banking_duplicate_only_map ||= {}
@enable_banking_show_relink_map ||= {}
end
private
def compute_duplicate_only_flag(stats)
errs = Array(stats && stats["errors"]).map do |e|
if e.is_a?(Hash)
e["message"] || e[:message]
else
e.to_s
end
end
errs.present? && errs.all? { |m| m.to_s.downcase.include?("duplicate upstream account detected") }
rescue
false
end
end
end

View File

@@ -1,7 +1,12 @@
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"
@@ -150,7 +155,7 @@ 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 allowew. 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)
@@ -403,6 +408,115 @@ class EnableBankingItemsController < ApplicationController
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

View File

@@ -5,6 +5,33 @@ class Provider::EnableBankingAdapter < Provider::Base
# Register this adapter with the factory
Provider::Factory.register("EnableBankingAccount", self)
# Define which account types this provider supports
def self.supported_account_types
%w[Depository CreditCard]
end
# Returns connection configurations for this provider
def self.connection_configs(family:)
return [] unless family.can_connect_enable_banking?
[ {
key: "enable_banking",
name: "Enable Banking",
description: "Connect to your bank via Enable Banking",
can_connect: true,
new_account_path: ->(accountable_type, return_to) {
Rails.application.routes.url_helpers.new_enable_banking_item_path(
accountable_type: accountable_type
)
},
existing_account_path: ->(account_id) {
Rails.application.routes.url_helpers.select_existing_account_enable_banking_items_path(
account_id: account_id
)
}
} ]
end
def provider_name
"enable_banking"
end

View File

@@ -32,7 +32,7 @@
<% elsif enable_banking_item.requires_update? %>
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span "Re-authorization required" %>
<%= tag.span "Reconnect" %>
</div>
<% else %>
<p class="text-secondary">
@@ -56,7 +56,7 @@
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-white bg-warning hover:opacity-90 transition-colors",
data: { turbo: false } do %>
<%= icon "refresh-cw", size: "sm" %>
Re-authorize
Update
<% end %>
<% elsif Rails.env.development? %>
<%= icon(

View File

@@ -0,0 +1,117 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".link_enable_banking_title")) %>
<% dialog.with_body do %>
<% items = local_assigns[:enable_banking_items] || @enable_banking_items || Current.family.enable_banking_items.where.not(client_certificate: nil) %>
<% if items&.any? %>
<%
# Find the first item with valid session to use for "Add Connection" button
item_for_new_connection = items.find(&:session_valid?)
# Check if any item needs initial connection (configured but no session yet)
item_needing_connection = items.find { |i| !i.session_valid? && !i.session_expired? }
%>
<div class="border-t border-primary pt-4 space-y-3">
<% items.each do |item| %>
<div class="flex items-center justify-between p-3 rounded-lg bg-container border border-primary">
<div class="flex items-center gap-3">
<% if item.session_valid? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || "Connected Bank" %></p>
<p class="text-xs text-secondary">
Session expires: <%= item.session_expires_at&.strftime("%b %d, %Y") || "Unknown" %>
</p>
</div>
<% elsif item.session_expired? %>
<div class="w-2 h-2 bg-warning rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || "Connection" %></p>
<p class="text-xs text-destructive">Session expired - re-authorization required</p>
</div>
<% else %>
<div class="w-2 h-2 bg-secondary rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary">Configured</p>
<p class="text-xs text-secondary">Ready to connect a bank</p>
</div>
<% end %>
</div>
<div class="flex items-center gap-2">
<% if item.session_valid? %>
<%= button_to sync_enable_banking_item_path(item),
method: :post,
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-primary bg-container border border-primary hover:bg-gray-50 transition-colors",
data: { turbo: false } do %>
Sync
<% end %>
<% elsif item.session_expired? %>
<%= button_to reauthorize_enable_banking_item_path(item),
method: :post,
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-warning hover:opacity-90 transition-colors",
data: { turbo: false } do %>
Reconnect
<% end %>
<% else %>
<%= link_to select_bank_enable_banking_item_path(item),
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-gray-900 hover:bg-gray-800 transition-colors",
data: { turbo_frame: "modal" } do %>
Connect Bank
<% end %>
<% end %>
<%= button_to enable_banking_item_path(item),
method: :delete,
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors",
data: { turbo_confirm: "Are you sure you want to remove this connection?" } do %>
Remove
<% end %>
</div>
</div>
<% end %>
<%# Add Connection button below the list - only show if we have a valid session to copy credentials from %>
<% if item_for_new_connection %>
<div class="flex justify-center pt-2">
<%= button_to new_connection_enable_banking_item_path(item_for_new_connection),
method: :post,
class: "inline-flex items-center gap-2 justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 transition-colors",
data: { turbo_frame: "modal" } do %>
<%= icon "plus", size: "sm" %>
Add Connection
<% end %>
</div>
<% end %>
</div>
<% else %>
<div class="space-y-4">
<div class="flex items-start gap-3">
<%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %>
<div class="text-sm text-secondary">
<p class="font-medium text-primary mb-2">Enable Banking connection not configured</p>
<p>Before you can link Enable Banking accounts, you need to configure your Enable Banking connection.</p>
</div>
</div>
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<p class="font-medium text-primary">Setup Steps:</p>
<ol class="list-decimal list-inside space-y-1 text-secondary">
<li>Go to <strong>Settings → Bank Sync Providers</strong></li>
<li>Find the <strong>Enable Banking</strong> section</li>
<li>Enter your Enable Banking credentials</li>
<li>Return here to link your accounts</li>
</ol>
</div>
<div class="mt-4">
<%= link_to settings_providers_path,
class: "w-full inline-flex items-center justify-center rounded-lg font-medium whitespace-nowrap rounded-lg hidden md:inline-flex px-3 py-2 text-sm text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
data: { turbo: false } do %>
Go to Provider Settings
<% end %>
</div>
</div>
<% end %>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,40 @@
<%# Modal: Link an existing manual account to a Enable Banking account %>
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Link Enable Banking account") %>
<% dialog.with_body do %>
<% if @available_enable_banking_accounts.blank? %>
<div class="p-4 text-sm text-secondary">
<p class="mb-2">All Enable Banking accounts appear to be linked already.</p>
<ul class="list-disc list-inside space-y-1">
<li>If you just connected or synced, try again after the sync completes.</li>
<li>To link a different account, first unlink it from the accounts actions menu.</li>
</ul>
</div>
<% else %>
<%= form_with url: link_existing_account_enable_banking_items_path, method: :post, class: "space-y-4" do %>
<%= hidden_field_tag :account_id, @account.id %>
<div class="space-y-2 max-h-64 overflow-auto">
<% @available_enable_banking_accounts.each do |eba| %>
<label class="flex items-center gap-3 p-2 rounded border border-surface-inset/50 hover:border-primary cursor-pointer">
<%= radio_button_tag :enable_banking_account_id, eba.id, false %>
<div class="flex flex-col">
<span class="text-sm text-primary font-medium"><%= eba.name.presence || eba.account_id %></span>
<span class="text-xs text-secondary">
<%= eba.currency %> • Balance: <%= number_to_currency((eba.current_balance || 0), unit: eba.currency) %>
</span>
</div>
</label>
<% end %>
</div>
<div class="flex items-center justify-end gap-2">
<%= render DS::Button.new(text: "Link", variant: :primary, icon: "link-2", type: :submit) %>
<%= render DS::Link.new(text: "Cancel", variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>

View File

@@ -127,13 +127,13 @@
<div class="w-2 h-2 bg-warning rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || "Connection" %></p>
<p class="text-xs text-destructive">Session expired - re-authorization required</p>
<p class="text-xs text-destructive">Session expired - reconnect</p>
</div>
<% else %>
<div class="w-2 h-2 bg-secondary rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary">Configured</p>
<p class="text-xs text-secondary">Ready to connect a bank</p>
<p class="text-xs text-secondary">Ready to link accounts</p>
</div>
<% end %>
</div>
@@ -151,7 +151,7 @@
method: :post,
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-warning hover:opacity-90 transition-colors",
data: { turbo: false } do %>
Re-authorize
Reconnect
<% end %>
<% else %>
<%= link_to select_bank_enable_banking_item_path(item),

View File

@@ -16,12 +16,12 @@
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div>
<p class="text-sm text-primary mb-2">
<strong>Your SimpleFin connection needs to be updated:</strong>
<strong>Your SimpleFIN connection needs to be updated:</strong>
</p>
<ol class="text-xs text-secondary space-y-1 list-decimal list-inside">
<li>Visit <a href="https://bridge.simplefin.org/simplefin/create" target="_blank" rel="noopener noreferrer" class="text-link hover:text-link underline">SimpleFin Bridge</a> to create a new setup token</li>
<li>Visit <a href="https://bridge.simplefin.org/simplefin/create" target="_blank" rel="noopener noreferrer" class="text-link hover:text-link underline">SimpleFIN Bridge</a> to create a new setup token</li>
<li>Copy the token and paste it below</li>
<li>Click "Update Connection" to restore access</li>
<li>Click "Update" to restore access</li>
</ol>
</div>
</div>
@@ -46,7 +46,7 @@
<div class="flex gap-3">
<%= render DS::Button.new(
text: "Update Connection",
text: "Update",
variant: "primary",
icon: "refresh-cw",
type: "submit",

View File

@@ -19,11 +19,11 @@ en:
no_accounts_description: We could not load any accounts from this financial
institution.
no_accounts_title: No accounts found
requires_update: Requires re-authentication
requires_update: Reconnect
status: Last synced %{timestamp} ago
status_never: Requires data sync
syncing: Syncing...
update: Update connection
update: Update
select_existing_account:
title: "Link %{account_name} to Plaid"
description: Select a Plaid account to link to your existing account

View File

@@ -44,7 +44,7 @@ en:
error: Error occurred while syncing data
no_accounts_description: This connection doesn't have any synchronized accounts yet.
no_accounts_title: No accounts found
requires_update: Requires re-authentication
requires_update: Reconnect
setup_needed: New accounts ready to set up
setup_description: Choose account types for your newly imported SimpleFin accounts.
setup_action: Set Up New Accounts
@@ -52,7 +52,7 @@ en:
status_never: Never synced
status_with_summary: "Last synced %{timestamp} ago • %{summary}"
syncing: Syncing...
update: Update connection
update: Update
select_existing_account:
title: "Link %{account_name} to SimpleFIN"
description: Select a SimpleFIN account to link to your existing account

View File

@@ -2,10 +2,12 @@ require "sidekiq/web"
require "sidekiq/cron/web"
Rails.application.routes.draw do
resources :enable_banking_items, only: [ :create, :update, :destroy ] do
resources :enable_banking_items, only: [ :new, :create, :update, :destroy ] do
collection do
get :callback
post :link_accounts
get :select_existing_account
post :link_existing_account
end
member do
post :sync