Provider optimisation (#375)

* Implement a provider configured account type

* Fix for SimpleFIN

* FIX tests and linter
This commit is contained in:
soky srm
2025-11-24 19:52:34 +01:00
committed by GitHub
parent 4cd575fa23
commit 226207e2f7
27 changed files with 275 additions and 111 deletions

View File

@@ -56,7 +56,10 @@ class AccountsController < ApplicationController
end
def new
@show_lunchflow_link = family.can_connect_lunchflow?
# Get all registered providers with any credentials configured
@provider_configs = Provider::Factory.registered_adapters.flat_map do |adapter_class|
adapter_class.connection_configs(family: family)
end
end
def sync_all
@@ -158,45 +161,21 @@ class AccountsController < ApplicationController
return
end
@available_providers = []
account_type_name = @account.accountable_type
# Check SimpleFIN
if family.can_connect_simplefin?
@available_providers << {
name: "SimpleFIN",
key: "simplefin",
description: "Connect to your bank via SimpleFIN",
path: select_existing_account_simplefin_items_path(account_id: @account.id)
}
end
# Get all available provider configs dynamically for this account type
provider_configs = Provider::Factory.connection_configs_for_account_type(
account_type: account_type_name,
family: family
)
# Check Plaid US
if family.can_connect_plaid_us?
@available_providers << {
name: "Plaid",
key: "plaid_us",
description: "Connect to your US bank via Plaid",
path: select_existing_account_plaid_items_path(account_id: @account.id, region: "us")
}
end
# Check Plaid EU
if family.can_connect_plaid_eu?
@available_providers << {
name: "Plaid (EU)",
key: "plaid_eu",
description: "Connect to your EU bank via Plaid",
path: select_existing_account_plaid_items_path(account_id: @account.id, region: "eu")
}
end
# Check Lunch Flow
if family.can_connect_lunchflow?
@available_providers << {
name: "Lunch Flow",
key: "lunchflow",
description: "Connect to your bank via Lunch Flow",
path: select_existing_account_lunchflow_items_path(account_id: @account.id)
# Build available providers list with paths resolved for this specific account
@available_providers = provider_configs.map do |config|
{
name: config[:name],
key: config[:key],
description: config[:description],
path: config[:existing_account_path].call(@account.id)
}
end

View File

@@ -66,9 +66,13 @@ module AccountableResource
private
def set_link_options
@show_us_link = Current.family.can_connect_plaid_us?
@show_eu_link = Current.family.can_connect_plaid_eu?
@show_lunchflow_link = Current.family.can_connect_lunchflow?
account_type_name = accountable_type.name
# Get all available provider configs dynamically for this account type
@provider_configs = Provider::Factory.connection_configs_for_account_type(
account_type: account_type_name,
family: Current.family
)
end
def accountable_type

View File

@@ -31,6 +31,27 @@ class Provider::Base
raise NotImplementedError, "#{self.class} must implement #provider_name"
end
# Defines which account types this provider supports
# Override in subclasses to specify supported account types
# @return [Array<String>] Array of account type class names (e.g., ["Depository", "CreditCard"])
def self.supported_account_types
[]
end
# Returns provider connection configurations
# Override in subclasses to provide connection metadata for UI
# @param family [Family] The family to check connection availability for
# @return [Array<Hash>] Array of connection configurations with keys:
# - key: Unique identifier (e.g., "lunchflow", "plaid_us")
# - name: Display name (e.g., "Lunch Flow", "Plaid")
# - description: User-facing description
# - can_connect: Boolean, whether family can connect to this provider
# - new_account_path: Proc that generates path for new account flow
# - existing_account_path: Proc that generates path for linking existing account
def self.connection_configs(family:)
[]
end
# Returns the provider type (class name)
# @return [String] The provider account class name
def provider_type

View File

@@ -63,6 +63,39 @@ class Provider::Factory
find_adapter_class(provider_type).present?
end
# Get all registered adapter classes
# @return [Array<Class>] List of registered adapter classes
def registered_adapters
ensure_adapters_loaded
registry.values.uniq
end
# Get adapters that support a specific account type
# @param account_type [String] The account type class name (e.g., "Depository", "CreditCard")
# @return [Array<Class>] List of adapter classes that support this account type
def adapters_for_account_type(account_type)
registered_adapters.select do |adapter_class|
adapter_class.supported_account_types.include?(account_type)
end
end
# Check if any provider supports a given account type
# @param account_type [String] The account type class name
# @return [Boolean]
def supports_account_type?(account_type)
adapters_for_account_type(account_type).any?
end
# Get all available provider connection configs for a given account type
# @param account_type [String] The account type class name (e.g., "Depository")
# @param family [Family] The family to check connection availability for
# @return [Array<Hash>] Array of connection configurations from all providers
def connection_configs_for_account_type(account_type:, family:)
adapters_for_account_type(account_type).flat_map do |adapter_class|
adapter_class.connection_configs(family: family)
end
end
# Clear all registered adapters (useful for testing)
def clear_registry!
@registry = {}

View File

@@ -5,6 +5,34 @@ class Provider::LunchflowAdapter < Provider::Base
# Register this adapter with the factory
Provider::Factory.register("LunchflowAccount", self)
# Define which account types this provider supports
def self.supported_account_types
%w[Depository CreditCard Loan]
end
# Returns connection configurations for this provider
def self.connection_configs(family:)
return [] unless family.can_connect_lunchflow?
[ {
key: "lunchflow",
name: "Lunch Flow",
description: "Connect to your bank via Lunch Flow",
can_connect: true,
new_account_path: ->(accountable_type, return_to) {
Rails.application.routes.url_helpers.select_accounts_lunchflow_items_path(
accountable_type: accountable_type,
return_to: return_to
)
},
existing_account_path: ->(account_id) {
Rails.application.routes.url_helpers.select_existing_account_lunchflow_items_path(
account_id: account_id
)
}
} ]
end
def provider_name
"lunchflow"
end

View File

@@ -17,6 +17,63 @@ class Provider::PlaidAdapter < Provider::Base
# Register this adapter with the factory for ALL PlaidAccount instances
Provider::Factory.register("PlaidAccount", self)
# Define which account types this provider supports (US region)
def self.supported_account_types
%w[Depository CreditCard Loan Investment]
end
# Returns connection configurations for this provider
# Plaid can return multiple configs (US and EU) depending on family setup
def self.connection_configs(family:)
configs = []
# US configuration
if family.can_connect_plaid_us?
configs << {
key: "plaid_us",
name: "Plaid",
description: "Connect to your US bank via Plaid",
can_connect: true,
new_account_path: ->(accountable_type, return_to) {
Rails.application.routes.url_helpers.new_plaid_item_path(
region: "us",
accountable_type: accountable_type
)
},
existing_account_path: ->(account_id) {
Rails.application.routes.url_helpers.select_existing_account_plaid_items_path(
account_id: account_id,
region: "us"
)
}
}
end
# EU configuration
if family.can_connect_plaid_eu?
configs << {
key: "plaid_eu",
name: "Plaid (EU)",
description: "Connect to your EU bank via Plaid",
can_connect: true,
new_account_path: ->(accountable_type, return_to) {
Rails.application.routes.url_helpers.new_plaid_item_path(
region: "eu",
accountable_type: accountable_type
)
},
existing_account_path: ->(account_id) {
Rails.application.routes.url_helpers.select_existing_account_plaid_items_path(
account_id: account_id,
region: "eu"
)
}
}
end
configs
end
# Mutex for thread-safe configuration loading
# Initialized at class load time to avoid race conditions on mutex creation
@config_mutex = Mutex.new

View File

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

View File

@@ -1,6 +1,6 @@
<%= render layout: "accounts/new/container", locals: { title: t(".title") } do %>
<div class="text-sm"
<% if @show_lunchflow_link %>
<% if @provider_configs.any? { |c| c[:key] == "lunchflow" } %>
data-controller="lunchflow-preload"
<% end %>>
<% unless params[:classification] == "liability" %>

View File

@@ -1,14 +1,15 @@
<%# locals: (path:, accountable_type:, show_us_link: true, show_eu_link: true, show_lunchflow_link: false) %>
<%# locals: (path:, accountable_type:, provider_configs:) %>
<%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
<div class="text-sm"
<% if show_lunchflow_link %>
<% if provider_configs.any? { |c| c[:key] == "lunchflow" } %>
data-controller="lunchflow-preload"
data-lunchflow-preload-accountable-type-value="<%= h(accountable_type) %>"
<% if params[:return_to] %>
data-lunchflow-preload-return-to-value="<%= h(params[:return_to]) %>"
<% end %>
<% end %>>
<%# Manual entry option %>
<%= link_to path, class: "flex items-center gap-4 w-full text-center text-primary focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-surface rounded-lg p-2" do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= icon("keyboard") %>
@@ -16,48 +17,31 @@
<%= t("accounts.new.method_selector.manual_entry") %>
<% end %>
<% if show_us_link %>
<%# Default US-only Link %>
<%= link_to new_plaid_item_path(region: "us", accountable_type: accountable_type),
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2",
data: { turbo_frame: "modal" } do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= icon("link-2") %>
</span>
<%= t("accounts.new.method_selector.connected_entry") %>
<% end %>
<% end %>
<%# Dynamic provider links %>
<% provider_configs.each do |config| %>
<% link_path = config[:new_account_path].call(accountable_type, params[:return_to]) %>
<% is_lunchflow = config[:key] == "lunchflow" %>
<%# EU Link %>
<% if show_eu_link %>
<%= link_to new_plaid_item_path(region: "eu", accountable_type: accountable_type),
<%= link_to link_path,
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2",
data: { turbo_frame: "modal" } do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= icon("link-2") %>
</span>
<%= t("accounts.new.method_selector.connected_entry_eu") %>
<% end %>
<% end %>
<%# Lunchflow Link %>
<% if show_lunchflow_link %>
<%= link_to select_accounts_lunchflow_items_path(accountable_type: accountable_type, return_to: params[:return_to]),
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2",
data: {
data: is_lunchflow ? {
turbo_frame: "modal",
turbo_action: "advance",
lunchflow_preload_target: "link"
} do %>
} : { turbo_frame: "modal" } do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= icon("link-2") %>
</span>
<span class="flex items-center gap-2">
<%= t("accounts.new.method_selector.lunchflow_entry") %>
<span data-lunchflow-preload-target="spinner" class="hidden">
<%= icon("loader-2", class: "animate-spin") %>
<% if is_lunchflow %>
<span class="flex items-center gap-2">
<%= t("accounts.new.method_selector.link_with_provider", provider: config[:name]) %>
<span data-lunchflow-preload-target="spinner" class="hidden">
<%= icon("loader-2", class: "animate-spin") %>
</span>
</span>
</span>
<% else %>
<%= t("accounts.new.method_selector.link_with_provider", provider: config[:name]) %>
<% end %>
<% end %>
<% end %>

View File

@@ -1,9 +1,7 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector",
path: new_credit_card_path(return_to: params[:return_to]),
show_us_link: @show_us_link,
show_eu_link: @show_eu_link,
show_lunchflow_link: @show_lunchflow_link,
provider_configs: @provider_configs,
accountable_type: "CreditCard" %>
<% else %>
<%= render DS::Dialog.new do |dialog| %>

View File

@@ -1,8 +1,7 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector",
path: new_crypto_path(return_to: params[:return_to]),
show_us_link: @show_us_link,
show_eu_link: @show_eu_link,
provider_configs: @provider_configs,
accountable_type: "Crypto" %>
<% else %>
<%= render DS::Dialog.new do |dialog| %>

View File

@@ -1,9 +1,7 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector",
path: new_depository_path(return_to: params[:return_to]),
show_us_link: @show_us_link,
show_eu_link: @show_eu_link,
show_lunchflow_link: @show_lunchflow_link,
provider_configs: @provider_configs,
accountable_type: "Depository" %>
<% else %>
<%= render DS::Dialog.new do |dialog| %>

View File

@@ -1,8 +1,7 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector",
path: new_investment_path(return_to: params[:return_to]),
show_us_link: @show_us_link,
show_eu_link: @show_eu_link,
provider_configs: @provider_configs,
accountable_type: "Investment" %>
<% else %>
<%= render DS::Dialog.new do |dialog| %>

View File

@@ -1,9 +1,7 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector",
path: new_loan_path(return_to: params[:return_to]),
show_us_link: @show_us_link,
show_eu_link: @show_eu_link,
show_lunchflow_link: @show_lunchflow_link,
provider_configs: @provider_configs,
accountable_type: "Loan" %>
<% else %>
<%= render DS::Dialog.new do |dialog| %>

View File

@@ -1,24 +1,26 @@
<header class="flex items-center gap-2 mb-4">
<h1 class="text-xl text-primary font-medium">Connect SimpleFin</h1>
</header>
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %>
<% if @error_message.present? %>
<div class="mb-4 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
<%= @error_message %>
</div>
<% end %>
<% dialog.with_body do %>
<% if @error_message.present? %>
<div class="mb-4 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
<%= @error_message %>
</div>
<% end %>
<div class="bg-container p-4 rounded-lg shadow-border-xs">
<%= form_with model: @simplefin_item, url: simplefin_items_path, method: :post, data: { turbo: true } do |f| %>
<div class="space-y-3">
<div>
<%= f.label :setup_token, "Setup token", class: "text-sm text-secondary block mb-1" %>
<%= f.text_field :setup_token, class: "input", placeholder: "paste your SimpleFin setup token" %>
</div>
<div class="flex items-center gap-2">
<%= f.submit "Connect", class: "btn btn--primary" %>
<%= link_to "Cancel", accounts_path, class: "btn" %>
</div>
</div>
<%= form_with model: @simplefin_item, url: simplefin_items_path, method: :post, data: { turbo: true, turbo_frame: "_top" } do |f| %>
<div class="space-y-3">
<div>
<%= f.label :setup_token, t(".setup_token"), class: "text-sm text-secondary block mb-1" %>
<%= f.text_field :setup_token, class: "input", placeholder: t(".setup_token_placeholder") %>
</div>
<div class="flex items-center gap-2 justify-end pt-2">
<%= link_to t(".cancel"), accounts_path, class: "btn", data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<%= f.submit t(".connect"), class: "btn btn--primary" %>
</div>
</div>
<% end %>
<% end %>
<% end %>
</div>
<% end %>

View File

@@ -28,6 +28,7 @@ ca:
method_selector:
connected_entry: Enllaça el compte
connected_entry_eu: Enllaça el compte de la UE
link_with_provider: "Enllaça amb %{provider}"
manual_entry: Introdueix el saldo del compte
title: Com vols afegir-lo?
title: Què vols afegir?

View File

@@ -28,6 +28,7 @@ de:
method_selector:
connected_entry: Konto verknüpfen
connected_entry_eu: EU-Konto verknüpfen
link_with_provider: "Mit %{provider} verknüpfen"
manual_entry: Kontostand manuell eingeben
title: Wie möchtest du es hinzufügen
title: Was möchtest du hinzufügen

View File

@@ -34,6 +34,7 @@ en:
method_selector:
connected_entry: Link account
connected_entry_eu: Link EU account
link_with_provider: "Link with %{provider}"
lunchflow_entry: Link Lunch Flow account
manual_entry: Enter account balance
title: How would you like to add it?

View File

@@ -29,6 +29,7 @@ es:
method_selector:
connected_entry: Vincular cuenta
connected_entry_eu: Vincular cuenta de la UE
link_with_provider: "Vincular con %{provider}"
lunchflow_entry: Vincular cuenta de Lunch Flow
manual_entry: Introducir saldo de la cuenta
title: ¿Cómo te gustaría añadirla?

View File

@@ -28,6 +28,7 @@ nb:
method_selector:
connected_entry: Koble til konto
connected_entry_eu: Koble til EU-konto
link_with_provider: "Koble til %{provider}"
manual_entry: Angi kontobalanse
title: Hvordan vil du legge den til?
title: Hva vil du legge til?

View File

@@ -29,6 +29,7 @@ ro:
method_selector:
connected_entry: Conectează cont
connected_entry_eu: Conectează cont UE
link_with_provider: "Conectează cu %{provider}"
lunchflow_entry: Conectează cont Lunch Flow
manual_entry: Introdu soldul contului
title: Cum dorești să adaugi?

View File

@@ -28,6 +28,7 @@ tr:
method_selector:
connected_entry: Hesabı bağla
connected_entry_eu: AB hesabı bağla
link_with_provider: "%{provider} ile bağla"
manual_entry: Hesap bakiyesi gir
title: Nasıl eklemek istersiniz?
title: Ne eklemek istersiniz?

View File

@@ -1,6 +1,12 @@
---
ca:
simplefin_items:
new:
title: Connecta SimpleFin
setup_token: Token de configuració
setup_token_placeholder: enganxa el teu token de configuració SimpleFin
connect: Connecta
cancel: Cancel·la
create:
success: Connexió SimpleFin afegida correctament! Els teus comptes apareixeran en breu mentre es sincronitzen en segon pla.
errors:

View File

@@ -1,6 +1,12 @@
---
de:
simplefin_items:
new:
title: SimpleFin verbinden
setup_token: Setup-Token
setup_token_placeholder: Füge dein SimpleFin-Setup-Token ein
connect: Verbinden
cancel: Abbrechen
create:
success: SimpleFin-Verbindung erfolgreich hinzugefügt! Deine Konten werden in Kürze angezeigt, sobald sie im Hintergrund synchronisiert wurden.
errors:

View File

@@ -1,6 +1,12 @@
---
en:
simplefin_items:
new:
title: Connect SimpleFin
setup_token: Setup token
setup_token_placeholder: paste your SimpleFin setup token
connect: Connect
cancel: Cancel
create:
success: SimpleFin connection added successfully! Your accounts will appear shortly as they sync in the background.
errors:

View File

@@ -1,6 +1,12 @@
---
es:
simplefin_items:
new:
title: Conectar SimpleFin
setup_token: Token de configuración
setup_token_placeholder: pega tu token de configuración de SimpleFin
connect: Conectar
cancel: Cancelar
create:
success: ¡Conexión SimpleFin añadida con éxito! Tus cuentas aparecerán en breve mientras se sincronizan en segundo plano.
errors:

View File

@@ -1,6 +1,12 @@
---
ro:
simplefin_items:
new:
title: Conectează SimpleFin
setup_token: Token de configurare
setup_token_placeholder: lipește token-ul tău de configurare SimpleFin
connect: Conectează
cancel: Anulează
create:
success: Conexiunea SimpleFin a fost adăugată cu succes! Conturile tale vor apărea în scurt timp pe măsură ce se sincronizează în fundal.
errors: