feat(settings/providers): card grid for available providers with connect drawer

- Add Provider::Metadata registry with static display data (region, kind,
  tier, maturity, logo) for all 11 providers
- Add Settings::ProviderCard ViewComponent rendering logo square, name,
  Beta/Alpha pill, meta line (region · type · tier), tagline, and Connect link
- Add connect_form action + route (GET /settings/providers/:key/connect_form)
  that opens the existing panel partial or config form in a DS::Dialog drawer
- Replace the Available accordion loop with a 2-column responsive card grid;
  empty state when all providers are connected
- Fix layout override: use turbo_rails/frame layout for frame requests so the
  drawer response is not wrapped in the full settings layout (was causing
  Turbo to pick the empty outer drawer frame instead of the filled one)
- Add SyncAllProvidersJob and last_sync_all_attempted_at migration (sync-all
  throttle support)
- Unify Connected + Action needed into a single "Your connections" section;
  items with warn/err status auto-open
- Fix Enable Banking grouping: items with expired sessions were returning
  :off (Available) instead of :warn (Your connections); gate now checks
  any? instead of any?(&:session_valid?)
- Add reconsent_required locale key for fully-expired EB sessions
- Surface Beta/Alpha maturity pills on connected provider accordion rows
  via new badge: param on settings_section helper
- Add i18n taglines for all 11 providers; add connect and empty_available keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Juan José Mata
2026-05-08 21:36:24 +00:00
parent 6633f29a2c
commit 4623bc3653
17 changed files with 506 additions and 100 deletions

View File

@@ -0,0 +1,29 @@
<div class="bg-container shadow-border-xs rounded-xl p-4 flex flex-col gap-3 hover:shadow-border-sm transition-shadow">
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 <%= logo_bg %>">
<span class="text-xs font-bold text-white"><%= logo_text %></span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-primary"><%= name %></span>
<% if maturity_label %>
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-alpha-black-50 text-secondary"><%= maturity_label %></span>
<% end %>
</div>
<% if meta_line.present? %>
<p class="text-xs text-subdued mt-0.5"><%= meta_line %></p>
<% end %>
</div>
</div>
<% if tagline.present? %>
<p class="text-sm text-secondary grow"><%= tagline %></p>
<% end %>
<div class="flex justify-end">
<%= link_to connect_path,
class: "inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:text-primary/70 transition-colors",
data: { turbo_frame: "drawer", turbo_prefetch: "false" } do %>
<%= t("settings.providers.connect") %>
<%= helpers.icon "arrow-right", class: "w-4 h-4" %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,31 @@
class Settings::ProviderCard < ApplicationComponent
MATURITY_LABELS = { beta: "Beta", alpha: "Alpha" }.freeze
def initialize(provider_key:, name:, tagline: nil, region: nil, kind: nil, tier: nil,
maturity: :stable, logo_bg: "bg-gray-500", logo_text: nil)
@provider_key = provider_key
@name = name
@tagline = tagline
@region = region
@kind = kind
@tier = tier
@maturity = maturity.to_sym
@logo_bg = logo_bg
@logo_text = logo_text || name.first(2).upcase
end
def maturity_label
MATURITY_LABELS[@maturity]
end
def meta_line
[ @region, @kind, @tier ].compact.join(" · ")
end
def connect_path
helpers.connect_form_settings_providers_path(provider_key: @provider_key)
end
private
attr_reader :provider_key, :name, :tagline, :region, :kind, :tier, :maturity, :logo_bg, :logo_text
end

View File

@@ -1,12 +1,12 @@
class Settings::ProvidersController < ApplicationController
layout "settings"
layout -> { turbo_frame_request? ? "turbo_rails/frame" : "settings" }
before_action :ensure_admin, only: [ :show, :update ]
before_action :ensure_admin, only: [ :show, :update, :sync_all, :sync, :connect_form ]
def show
@breadcrumbs = [
[ "Home", root_path ],
[ "Bank Sync Providers", nil ]
[ "Bank sync", nil ]
]
prepare_show_context
@@ -77,6 +77,54 @@ class Settings::ProvidersController < ApplicationController
render :show, status: :unprocessable_entity
end
def sync_all
family = Current.family
if family.last_sync_all_attempted_at.present? && family.last_sync_all_attempted_at > 30.seconds.ago
return redirect_to settings_providers_path, notice: t("settings.providers.sync_all_recently")
end
family.update!(last_sync_all_attempted_at: Time.current)
SyncAllProvidersJob.perform_later(family.id)
redirect_to settings_providers_path, notice: t("settings.providers.sync_all_in_progress")
end
def sync
provider_key = params[:provider_key]
syncable_type = PANEL_SYNCABLE_TYPES[provider_key]
return redirect_to settings_providers_path unless syncable_type
items = syncable_type.constantize.where(family: Current.family).syncable
items.each { |item| item.sync_later unless item.syncing? }
redirect_to settings_providers_path, notice: t("settings.providers.sync_provider_in_progress")
end
def connect_form
provider_key = params[:provider_key]
panel = FAMILY_PANELS.find { |p| p[:key] == provider_key }
if panel
@panel_key = panel[:key]
@panel_partial = panel[:partial]
@panel_title = panel[:title]
load_provider_items(provider_key)
return render :connect_form
end
Provider::Factory.ensure_adapters_loaded
config = Provider::ConfigurationRegistry.all.find { |c| c.provider_key.to_s == provider_key }
if config
@panel_title = provider_key.titleize
@provider_configuration = config
return render :connect_form
end
redirect_to settings_providers_path
rescue ActiveRecord::Encryption::Errors::Configuration
redirect_to settings_providers_path, alert: t("settings.providers.encryption_error.title")
end
private
def provider_params
# Dynamically permit all provider configuration fields
@@ -125,16 +173,16 @@ class Settings::ProvidersController < ApplicationController
# status display, and sync actions. The configuration registry excludes
# them (see prepare_show_context).
FAMILY_PANELS = [
{ key: "lunchflow", title: "Lunch Flow", turbo_id: "lunchflow", partial: "lunchflow_panel" },
{ key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" },
{ key: "enable_banking", title: "Enable Banking (beta)", turbo_id: "enable_banking", partial: "enable_banking_panel" },
{ key: "coinstats", title: "CoinStats (beta)", turbo_id: "coinstats", partial: "coinstats_panel" },
{ key: "mercury", title: "Mercury (beta)", turbo_id: "mercury", partial: "mercury_panel" },
{ key: "coinbase", title: "Coinbase (beta)", turbo_id: "coinbase", partial: "coinbase_panel" },
{ key: "binance", title: "Binance (beta)", turbo_id: "binance", partial: "binance_panel" },
{ key: "snaptrade", title: "SnapTrade (beta)", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" },
{ key: "indexa_capital", title: "Indexa Capital (alpha)", turbo_id: "indexa_capital", partial: "indexa_capital_panel" },
{ key: "sophtron", title: "Sophtron (alpha)", turbo_id: "sophtron", partial: "sophtron_panel" }
{ key: "lunchflow", title: "Lunch Flow", turbo_id: "lunchflow", partial: "lunchflow_panel" },
{ key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" },
{ key: "enable_banking", title: "Enable Banking", turbo_id: "enable_banking", partial: "enable_banking_panel" },
{ key: "coinstats", title: "CoinStats", turbo_id: "coinstats", partial: "coinstats_panel" },
{ key: "mercury", title: "Mercury", turbo_id: "mercury", partial: "mercury_panel" },
{ key: "coinbase", title: "Coinbase", turbo_id: "coinbase", partial: "coinbase_panel" },
{ key: "binance", title: "Binance", turbo_id: "binance", partial: "binance_panel" },
{ key: "snaptrade", title: "SnapTrade", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" },
{ key: "indexa_capital", title: "Indexa Capital", turbo_id: "indexa_capital", partial: "indexa_capital_panel" },
{ key: "sophtron", title: "Sophtron", turbo_id: "sophtron", partial: "sophtron_panel" }
].freeze
FAMILY_PANEL_KEYS = FAMILY_PANELS.map { |p| p[:key] }.freeze
@@ -153,6 +201,31 @@ class Settings::ProvidersController < ApplicationController
"sophtron" => "SophtronItem"
}.freeze
def load_provider_items(provider_key)
case provider_key
when "simplefin"
@simplefin_items = Current.family.simplefin_items.ordered
when "lunchflow"
@lunchflow_items = Current.family.lunchflow_items.ordered
when "enable_banking"
@enable_banking_items = Current.family.enable_banking_items.ordered
when "coinstats"
@coinstats_items = Current.family.coinstats_items.ordered
when "mercury"
@mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts)
when "coinbase"
@coinbase_items = Current.family.coinbase_items.ordered
when "binance"
@binance_items = Current.family.binance_items.active.ordered
when "snaptrade"
@snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered
when "indexa_capital"
@indexa_capital_items = Current.family.indexa_capital_items.ordered
when "sophtron"
@sophtron_items = Current.family.sophtron_items.ordered
end
end
# Prepares instance vars needed by the show view and partials
def prepare_show_context
# Load all provider configurations (exclude family-scoped panels, which have their own UI below)
@@ -232,6 +305,7 @@ class Settings::ProvidersController < ApplicationController
provider_key: config.provider_key.to_s,
title: config.provider_key.to_s.titleize,
configuration: config,
maturity: Provider::Metadata.for(config.provider_key)[:maturity],
summary: view_context.provider_summary(config.provider_key)
}
end
@@ -243,6 +317,7 @@ class Settings::ProvidersController < ApplicationController
turbo_id: panel[:turbo_id],
partial: panel[:partial],
auto_open_param: panel[:auto_open],
maturity: Provider::Metadata.for(panel[:key])[:maturity],
summary: view_context.provider_summary(panel[:key])
}
end

View File

@@ -2,7 +2,7 @@ module SettingsHelper
SETTINGS_ORDER = [
# General section
{ name: "Accounts", path: :accounts_path },
{ name: "Bank Sync", path: :settings_bank_sync_path },
{ name: "Bank Sync", path: :settings_providers_path },
{ name: "Preferences", path: :settings_preferences_path },
{ name: "Appearance", path: :settings_appearance_path },
{ name: "Profile Info", path: :settings_profile_path },
@@ -19,7 +19,6 @@ module SettingsHelper
{ name: "LLM Usage", path: :settings_llm_usage_path, condition: :admin_user? },
{ name: "API Key", path: :settings_api_key_path, condition: :admin_user? },
{ name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted_and_admin? },
{ name: "Providers", path: :settings_providers_path, condition: :admin_user? },
{ name: "Imports", path: :imports_path, condition: :admin_user? },
{ name: "Exports", path: :family_exports_path, condition: :admin_user? },
# More section
@@ -45,9 +44,9 @@ module SettingsHelper
}
end
def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, &block)
def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil, &block)
content = capture(&block)
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param, status: status, meta: meta }
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param, status: status, meta: meta, actions: actions, badge: badge }
end
def provider_summary(provider_key)
@@ -64,7 +63,7 @@ module SettingsHelper
return { status: :off } unless @lunchflow_items&.any?
sync_based_summary(key)
when "enable_banking"
return { status: :off } unless @enable_banking_items&.any?(&:session_valid?)
return { status: :off } unless @enable_banking_items&.any?
enable_banking_summary
when "coinstats"
return { status: :off } unless @coinstats_items&.any?
@@ -119,39 +118,49 @@ module SettingsHelper
private
def sync_based_summary(provider_key)
health = @provider_sync_health&.dig(provider_key) || {}
last_synced_at = health[:last_synced_at]
if health[:error]
base = if health[:error]
{ status: :err, meta: t("settings.providers.meta.sync_error") }
elsif health[:stale]
{ status: :warn, meta: t("settings.providers.meta.no_recent_sync") }
elsif health[:last_synced_at].present?
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: time_ago_in_words(health[:last_synced_at])) }
elsif last_synced_at.present?
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: time_ago_in_words(last_synced_at)) }
else
{ status: :ok }
end
base.merge(last_synced_at: last_synced_at)
end
def enable_banking_summary
health = @provider_sync_health&.dig("enable_banking") || {}
last_synced_at = health[:last_synced_at]
return { status: :err, meta: t("settings.providers.meta.sync_error") } if health[:error]
return { status: :err, meta: t("settings.providers.meta.sync_error"), last_synced_at: nil } if health[:error]
valid_items = @enable_banking_items&.select(&:session_valid?) || []
# All items have expired/missing sessions — need re-authorization
if valid_items.empty?
return { status: :warn, meta: t("settings.providers.meta.reconsent_required"), last_synced_at: last_synced_at }
end
expiring = valid_items.find do |item|
item.session_expires_at.present? && item.session_expires_at < 7.days.from_now
end
if expiring
days = [ ((expiring.session_expires_at - Time.current) / 1.day).ceil, 1 ].max
return { status: :warn, meta: t("settings.providers.meta.reconsent_needed", count: days) }
return { status: :warn, meta: t("settings.providers.meta.reconsent_needed", count: days), last_synced_at: last_synced_at }
end
return { status: :warn, meta: t("settings.providers.meta.no_recent_sync") } if health[:stale]
return { status: :warn, meta: t("settings.providers.meta.no_recent_sync"), last_synced_at: last_synced_at } if health[:stale]
if health[:last_synced_at].present?
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: time_ago_in_words(health[:last_synced_at])) }
if last_synced_at.present?
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: time_ago_in_words(last_synced_at)), last_synced_at: last_synced_at }
else
{ status: :ok }
{ status: :ok, last_synced_at: nil }
end
end

View File

@@ -0,0 +1,9 @@
class SyncAllProvidersJob < ApplicationJob
queue_as :high_priority
sidekiq_options lock: :until_executed, lock_args: ->(args) { [ args.first ] }, on_conflict: :log
def perform(family_id)
family = Family.find(family_id)
family.sync_later
end
end

View File

@@ -0,0 +1,98 @@
class Provider
module Metadata
REGISTRY = {
simplefin: {
region: "US",
kind: "Bank",
tier: "Free",
maturity: :stable,
logo_bg: "bg-blue-600",
logo_text: "SF"
},
lunchflow: {
region: "US",
kind: "Lunch",
tier: "Free",
maturity: :stable,
logo_bg: "bg-orange-500",
logo_text: "LF"
},
enable_banking: {
region: "EU",
kind: "Bank",
tier: "Free",
maturity: :beta,
logo_bg: "bg-purple-600",
logo_text: "EB"
},
coinstats: {
region: "Global",
kind: "Crypto",
tier: "Free",
maturity: :beta,
logo_bg: "bg-yellow-500",
logo_text: "CS"
},
mercury: {
region: "US",
kind: "Bank",
tier: "Free",
maturity: :beta,
logo_bg: "bg-cyan-600",
logo_text: "ME"
},
coinbase: {
region: "Global",
kind: "Crypto",
tier: "Free",
maturity: :beta,
logo_bg: "bg-blue-500",
logo_text: "CB"
},
binance: {
region: "Global",
kind: "Crypto",
tier: "Free",
maturity: :beta,
logo_bg: "bg-yellow-400",
logo_text: "BI"
},
snaptrade: {
region: "US / CA",
kind: "Investment",
tier: "Free",
maturity: :beta,
logo_bg: "bg-green-600",
logo_text: "ST"
},
indexa_capital: {
region: "ES",
kind: "Investment",
tier: "Free",
maturity: :alpha,
logo_bg: "bg-red-600",
logo_text: "IC"
},
sophtron: {
region: "US",
kind: "Bank",
tier: "Free",
maturity: :alpha,
logo_bg: "bg-teal-600",
logo_text: "SO"
},
plaid: {
region: "US",
kind: "Bank",
tier: "Paid",
maturity: :stable,
logo_bg: "bg-indigo-600",
logo_text: "PL"
}
}.freeze
def self.for(provider_key)
REGISTRY[provider_key.to_sym] || { logo_text: provider_key.to_s.first(2).upcase, logo_bg: "bg-gray-500" }
end
end
end

View File

@@ -1,4 +1,4 @@
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil) %>
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil) %>
<% if collapsible %>
<details <%= "open" if open %>
class="group bg-container shadow-border-xs rounded-xl p-4"
@@ -7,7 +7,10 @@
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
<div>
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
<div class="flex items-center gap-2 flex-wrap">
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
<%= badge if badge.present? %>
</div>
<% if subtitle.present? %>
<p class="text-secondary text-sm"><%= subtitle %></p>
<% end %>
@@ -19,6 +22,7 @@
<span class="text-xs text-subdued"><%= meta %></span>
<% end %>
<%= render "settings/providers/status_pill", status: status %>
<%= actions if actions.present? %>
</div>
<% end %>
</summary>

View File

@@ -0,0 +1,10 @@
<div class="bg-container shadow-border-xs rounded-xl p-4 flex items-center justify-between gap-4">
<div>
<p class="font-medium text-primary"><%= t("settings.providers.add_provider_cta.title") %></p>
<p class="text-sm text-secondary mt-0.5"><%= t("settings.providers.add_provider_cta.body") %></p>
</div>
<a href="#available" class="inline-flex items-center gap-2 shrink-0 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary transition-colors">
<%= icon "plus", class: "w-4 h-4" %>
<%= t("settings.providers.add_provider_cta.cta") %>
</a>
</div>

View File

@@ -1,5 +1,5 @@
<%# locals: (title:, count: nil, description: nil) %>
<div class="flex items-baseline justify-between gap-3 mt-2 mb-1 px-1">
<%# locals: (title:, count: nil, description: nil, anchor: nil) %>
<div id="<%= anchor %>" class="flex items-baseline justify-between gap-3 mt-2 mb-1 px-1">
<h2 class="text-sm font-medium text-primary flex items-baseline gap-2">
<%= title %>
<% if count %>

View File

@@ -0,0 +1,10 @@
<%# locals: (provider_key:, last_synced_at: nil) %>
<% recently_synced = last_synced_at.present? && last_synced_at > 60.seconds.ago %>
<%= button_to sync_provider_settings_providers_path(provider_key: provider_key),
method: :post,
disabled: recently_synced,
title: recently_synced ? t("settings.providers.recently_synced") : t("settings.providers.sync_provider"),
class: "inline-flex items-center p-1 rounded text-secondary hover:text-primary hover:bg-alpha-black-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed",
form: { onclick: "event.stopPropagation()", class: "inline-flex" } do %>
<%= icon "refresh-cw", class: "w-3.5 h-3.5" %>
<% end %>

View File

@@ -0,0 +1,14 @@
<%= render DS::Dialog.new(frame: "drawer", responsive: true, auto_open: true) do |dialog| %>
<% dialog.with_header(title: @panel_title) %>
<% dialog.with_body do %>
<% if @panel_partial %>
<turbo-frame id="<%= @panel_key %>-connect-form" target="_top">
<%= render "settings/providers/#{@panel_partial}" %>
</turbo-frame>
<% else %>
<turbo-frame id="config-connect-form" target="_top">
<%= render "settings/providers/provider_form", configuration: @provider_configuration %>
</turbo-frame>
<% end %>
<% end %>
<% end %>

View File

@@ -1,4 +1,4 @@
<%= content_for :page_title, "Sync Providers" %>
<%= content_for :page_title, "Bank Sync" %>
<div class="space-y-4">
<% if @encryption_error %>
@@ -12,31 +12,48 @@
</div>
</div>
<% else %>
<div>
<p class="text-secondary mb-4">
Configure credentials for third-party sync providers. Settings configured here will override environment variables.
<div class="flex items-start justify-between gap-4">
<p class="text-secondary">
Connect external accounts so transactions, balances and holdings flow into Sure automatically.
</p>
<% if @connected.any? || @needs_attention.any? %>
<% sync_all_disabled = Current.family.last_sync_all_attempted_at.present? && Current.family.last_sync_all_attempted_at > 30.seconds.ago %>
<%= button_to sync_all_settings_providers_path,
method: :post,
disabled: sync_all_disabled,
title: sync_all_disabled ? t("settings.providers.sync_all_recently") : nil,
class: "inline-flex items-center gap-2 shrink-0 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed" do %>
<%= icon "refresh-cw", class: "w-4 h-4" %>
<%= t("settings.providers.sync_all") %>
<% end %>
<% end %>
</div>
<%= render Settings::HealthSummary.new(counts: @health_counts) %>
<%= render "settings/providers/group_heading",
title: t("settings.providers.groups.connected"),
count: @connected.size %>
<% all_connections = @needs_attention + @connected %>
<% if @connected.empty? && @needs_attention.empty? %>
<%= render "settings/providers/group_heading",
title: t("settings.providers.groups.your_connections"),
count: all_connections.size %>
<% if all_connections.empty? %>
<p class="text-sm text-secondary px-1 py-2"><%= t("settings.providers.groups.empty_connected") %></p>
<% end %>
<%# Auto-open when there is exactly one connected (and no action-needed) item. %>
<% auto_open_only = @connected.size == 1 && @needs_attention.empty? %>
<% @connected.each do |entry| %>
<% all_connections.each do |entry| %>
<% auto_open = [ :warn, :err ].include?(entry[:summary][:status]) || all_connections.size == 1 %>
<% sync_action = entry[:partial].present? ? render("settings/providers/sync_button", provider_key: entry[:provider_key], last_synced_at: entry[:summary][:last_synced_at]) : nil %>
<% maturity_label = Settings::ProviderCard::MATURITY_LABELS[entry[:maturity]] %>
<% maturity_badge = maturity_label ? content_tag(:span, maturity_label, class: "text-xs font-medium px-1.5 py-0.5 rounded-full bg-alpha-black-50 text-secondary") : nil %>
<%= settings_section title: entry[:title],
collapsible: true,
open: auto_open_only,
open: auto_open,
auto_open_param: entry[:auto_open_param],
status: entry[:summary][:status],
meta: entry[:summary][:meta] do %>
meta: entry[:summary][:meta],
actions: sync_action,
badge: maturity_badge do %>
<% if entry[:configuration] %>
<%= render "settings/providers/provider_form", configuration: entry[:configuration] %>
<% else %>
@@ -47,48 +64,34 @@
<% end %>
<% end %>
<% unless @needs_attention.empty? %>
<%= render "settings/providers/group_heading",
title: t("settings.providers.groups.needs_attention"),
count: @needs_attention.size %>
<% @needs_attention.each do |entry| %>
<%= settings_section title: entry[:title],
collapsible: true,
open: true,
auto_open_param: entry[:auto_open_param],
status: entry[:summary][:status],
meta: entry[:summary][:meta] do %>
<% if entry[:configuration] %>
<%= render "settings/providers/provider_form", configuration: entry[:configuration] %>
<% else %>
<turbo-frame id="<%= entry[:turbo_id] %>-providers-panel">
<%= render "settings/providers/#{entry[:partial]}" %>
</turbo-frame>
<% end %>
<% end %>
<% end %>
<% unless @available.empty? %>
<%= render "settings/providers/add_provider_cta" %>
<% end %>
<%= render "settings/providers/group_heading",
title: t("settings.providers.groups.available"),
count: @available.size %>
count: @available.size,
anchor: "available" %>
<% @available.each do |entry| %>
<%= settings_section title: entry[:title],
collapsible: true,
open: false,
auto_open_param: entry[:auto_open_param],
status: entry[:summary][:status],
meta: entry[:summary][:meta] do %>
<% if entry[:configuration] %>
<%= render "settings/providers/provider_form", configuration: entry[:configuration] %>
<% else %>
<turbo-frame id="<%= entry[:turbo_id] %>-providers-panel">
<%= render "settings/providers/#{entry[:partial]}" %>
</turbo-frame>
<% if @available.empty? %>
<p class="text-sm text-secondary px-1 py-2"><%= t("settings.providers.groups.empty_available") %></p>
<% else %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<% @available.each do |entry| %>
<% meta = Provider::Metadata.for(entry[:provider_key]) %>
<%= render Settings::ProviderCard.new(
provider_key: entry[:provider_key],
name: entry[:title],
tagline: t("settings.providers.taglines.#{entry[:provider_key]}", default: nil),
region: meta[:region],
kind: meta[:kind],
tier: meta[:tier],
maturity: meta[:maturity] || :stable,
logo_bg: meta[:logo_bg],
logo_text: meta[:logo_text]
) %>
<% end %>
<% end %>
</div>
<% end %>
<% end %>
</div>

View File

@@ -191,11 +191,14 @@ en:
warn: Action needed
err: Error
off: Not configured
connect: Connect
groups:
your_connections: Your connections
connected: Connected
needs_attention: Action needed
available: Available
empty_connected: Nothing connected yet — pick a provider below to get started.
empty_available: All available providers are connected.
health:
connected: Connected
needs_attention: Action needed
@@ -205,10 +208,33 @@ en:
sync_error: Sync error
no_recent_sync: Sync overdue
registration_needed: Registration needed
reconsent_required: Re-consent required
reconsent_needed:
one: Re-consent needed in 1 day
other: Re-consent needed in %{count} days
last_synced: Synced %{time} ago
sync_all: Sync all
sync_all_in_progress: Syncing all connected providers…
sync_all_recently: Sync already in progress — try again in a moment.
sync_provider: Sync now
sync_provider_in_progress: Sync started.
recently_synced: Synced recently — try again in a moment.
taglines:
simplefin: Connect US bank accounts via the open SimpleFIN protocol.
lunchflow: Track school lunch account balances for your kids.
enable_banking: Sync European bank accounts via PSD2 open banking.
coinstats: Track your entire crypto portfolio across wallets and exchanges.
mercury: Sync your Mercury business banking accounts automatically.
coinbase: Import your Coinbase crypto holdings and track performance.
binance: Sync your Binance spot balances using a read-only API key.
snaptrade: Connect brokerage accounts via the SnapTrade aggregation network.
indexa_capital: Track your Indexa Capital automated investment portfolio.
sophtron: Connect US bank accounts via the Sophtron aggregation network.
plaid: Connect thousands of US financial institutions via Plaid.
add_provider_cta:
title: Add another provider
body: Connect a new bank or data source to start syncing accounts.
cta: Browse providers
show:
coinbase_title: Coinbase
encryption_error:

View File

@@ -205,8 +205,14 @@ Rails.application.routes.draw do
resource :ai_prompts, only: :show
resource :llm_usage, only: :show
resource :guides, only: :show
resource :bank_sync, only: :show, controller: "bank_sync"
resource :providers, only: %i[show update]
get "bank_sync", to: redirect("/settings/providers", status: 301)
resource :providers, only: %i[show update] do
collection do
post :sync_all
post ":provider_key/sync", action: :sync, as: :sync_provider
get ":provider_key/connect_form", action: :connect_form, as: :connect_form
end
end
end
resource :subscription, only: %i[new show create] do

View File

@@ -0,0 +1,5 @@
class AddLastSyncAllAttemptedAtToFamilies < ActiveRecord::Migration[8.0]
def change
add_column :families, :last_sync_all_attempted_at, :datetime
end
end

29
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do
ActiveRecord::Schema[7.2].define(version: 2026_05_08_120000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -40,7 +40,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do
t.index ["account_id"], name: "index_account_shares_on_account_id"
t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances"
t.index ["user_id"], name: "index_account_shares_on_user_id"
t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission"
t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying, 'read_write'::character varying, 'read_only'::character varying]::text[])", name: "chk_account_shares_permission"
end
create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -595,7 +595,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do
t.string "assistant_type", default: "builtin", null: false
t.string "default_account_sharing", default: "shared", null: false
t.string "enabled_currencies", array: true
t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing"
t.datetime "last_sync_all_attempted_at"
t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, 'private'::character varying]::text[])", name: "chk_families_default_account_sharing"
t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range"
end
@@ -1400,13 +1401,14 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do
t.jsonb "institution_metadata"
t.jsonb "raw_payload"
t.jsonb "raw_transactions_payload"
t.string "customer_id", null: false
t.string "member_id", null: false
t.string "customer_id"
t.string "member_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "account_number_mask"
t.index ["account_id"], name: "index_sophtron_accounts_on_account_id"
t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id"
t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true
t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id"
end
create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -1428,8 +1430,21 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do
t.string "base_url"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "customer_id"
t.string "customer_name"
t.jsonb "raw_customer_payload"
t.string "user_institution_id"
t.string "current_job_id"
t.string "job_status"
t.jsonb "raw_job_payload"
t.string "last_connection_error"
t.boolean "manual_sync", default: false, null: false
t.uuid "current_job_sophtron_account_id"
t.index ["current_job_sophtron_account_id"], name: "index_sophtron_items_on_current_job_sophtron_account_id"
t.index ["customer_id"], name: "index_sophtron_items_on_customer_id"
t.index ["family_id"], name: "index_sophtron_items_on_family_id"
t.index ["status"], name: "index_sophtron_items_on_status"
t.index ["user_institution_id"], name: "index_sophtron_items_on_user_institution_id"
end
create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -1648,9 +1663,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do
t.datetime "last_used_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
t.index ["credential_id"], name: "index_webauthn_credentials_on_credential_id", unique: true
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
end
add_foreign_key "account_providers", "accounts", on_delete: :cascade

View File

@@ -49,22 +49,22 @@ class Settings::ProvidersTest < ApplicationSystemTestCase
details.assert_text "Setup Token"
end
test "groups providers into Connected and Available with counts" do
test "groups providers into Your connections and Available with counts" do
SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access")
visit settings_providers_path
connected_heading = find("h2", text: /\AConnected/)
assert_match(/· 1\z/, connected_heading.text)
connections_heading = find("h2", text: /\AYour connections/)
assert_match(/· 1\z/, connections_heading.text)
available_heading = find("h2", text: /\AAvailable/)
connected_y = connected_heading.native.location.y
available_y = available_heading.native.location.y
simplefin_y = find("details", text: "SimpleFIN").native.location.y
binance_y = find("details", text: "Binance").native.location.y
connections_y = connections_heading.native.location.y
available_y = available_heading.native.location.y
simplefin_y = find("details", text: "SimpleFIN").native.location.y
binance_y = find("details", text: "Binance").native.location.y
assert connected_y < simplefin_y, "Connected heading should appear above SimpleFIN section"
assert connections_y < simplefin_y, "Your connections heading should appear above SimpleFIN section"
assert simplefin_y < available_y, "SimpleFIN should appear above Available heading"
assert available_y < binance_y, "Available heading should appear above Binance section"
end
@@ -85,10 +85,11 @@ class Settings::ProvidersTest < ApplicationSystemTestCase
visit settings_providers_path
assert_selector "h2", text: /\AYour connections/
assert_no_selector "h2", text: /\AAction needed/
end
test "enable banking with expiring session lands in action needed and auto-opens" do
test "enable banking with expiring session appears in your connections and auto-opens" do
item = EnableBankingItem.new(
family: @family,
name: "Test Bank",
@@ -102,11 +103,72 @@ class Settings::ProvidersTest < ApplicationSystemTestCase
visit settings_providers_path
assert_selector "h2", text: /\AAction needed/
assert_selector "h2", text: /\AYour connections/
# The Enable Banking section should be in the action-needed group and auto-opened
# The Enable Banking section should be in the Your connections group and auto-opened
within("details[open]", text: /Enable Banking/) do
assert_text "Re-consent needed in 5 days"
end
end
test "sync all button enqueues SyncAllProvidersJob and shows flash" do
SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access")
visit settings_providers_path
assert_enqueued_with(job: SyncAllProvidersJob) do
click_on "Sync all"
end
assert_text "Syncing all connected providers"
end
test "per-row sync button enqueues sync for that provider and shows flash" do
SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access")
visit settings_providers_path
within("details", text: "SimpleFIN") do
find("button[title='Sync now']").click
end
assert_text "Sync started"
end
test "add provider CTA banner appears above available group when providers are connected" do
SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access")
visit settings_providers_path
cta = find("a", text: "Browse providers")
available_heading = find("h2", text: /\AAvailable/)
cta_y = cta.native.location.y
available_y = available_heading.native.location.y
assert cta_y < available_y, "Add-provider CTA should appear above the Available heading"
end
test "available providers render as a card grid" do
visit settings_providers_path
# SimpleFIN is not connected, so it should appear in the card grid
within "div.grid" do
assert_text "SimpleFIN"
assert_selector "a[data-turbo-frame='drawer']", minimum: 1
end
end
test "clicking a provider card connect link opens the connect drawer" do
visit settings_providers_path
# Find and click the SimpleFIN card's Connect link
within "div.grid" do
find("a[data-turbo-frame='drawer']", text: /Connect/, match: :first).click
end
# Drawer should open with the panel content
assert_selector "dialog[open]"
assert_text "Setup Token"
end
end