From 4623bc36534a49c8c65c5e207f5f3ee93587f613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 8 May 2026 21:36:24 +0000 Subject: [PATCH] feat(settings/providers): card grid for available providers with connect drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../settings/provider_card.html.erb | 29 +++++ app/components/settings/provider_card.rb | 31 ++++++ .../settings/providers_controller.rb | 101 +++++++++++++++--- app/helpers/settings_helper.rb | 37 ++++--- app/jobs/sync_all_providers_job.rb | 9 ++ app/models/provider/metadata.rb | 98 +++++++++++++++++ app/views/settings/_section.html.erb | 8 +- .../providers/_add_provider_cta.html.erb | 10 ++ .../providers/_group_heading.html.erb | 4 +- .../settings/providers/_sync_button.html.erb | 10 ++ .../settings/providers/connect_form.html.erb | 14 +++ app/views/settings/providers/show.html.erb | 101 +++++++++--------- config/locales/views/settings/en.yml | 26 +++++ config/routes.rb | 10 +- ..._last_sync_all_attempted_at_to_families.rb | 5 + db/schema.rb | 29 +++-- test/system/settings/providers_test.rb | 84 +++++++++++++-- 17 files changed, 506 insertions(+), 100 deletions(-) create mode 100644 app/components/settings/provider_card.html.erb create mode 100644 app/components/settings/provider_card.rb create mode 100644 app/jobs/sync_all_providers_job.rb create mode 100644 app/models/provider/metadata.rb create mode 100644 app/views/settings/providers/_add_provider_cta.html.erb create mode 100644 app/views/settings/providers/_sync_button.html.erb create mode 100644 app/views/settings/providers/connect_form.html.erb create mode 100644 db/migrate/20260508120000_add_last_sync_all_attempted_at_to_families.rb diff --git a/app/components/settings/provider_card.html.erb b/app/components/settings/provider_card.html.erb new file mode 100644 index 000000000..2a286a8bc --- /dev/null +++ b/app/components/settings/provider_card.html.erb @@ -0,0 +1,29 @@ +
+
+
+ <%= logo_text %> +
+
+
+ <%= name %> + <% if maturity_label %> + <%= maturity_label %> + <% end %> +
+ <% if meta_line.present? %> +

<%= meta_line %>

+ <% end %> +
+
+ <% if tagline.present? %> +

<%= tagline %>

+ <% 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 %> +
+
diff --git a/app/components/settings/provider_card.rb b/app/components/settings/provider_card.rb new file mode 100644 index 000000000..2f9e2bc5e --- /dev/null +++ b/app/components/settings/provider_card.rb @@ -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 diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index ff017748a..3549f6321 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -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 diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index fef350335..3e73933b9 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -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 diff --git a/app/jobs/sync_all_providers_job.rb b/app/jobs/sync_all_providers_job.rb new file mode 100644 index 000000000..c13e6c151 --- /dev/null +++ b/app/jobs/sync_all_providers_job.rb @@ -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 diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb new file mode 100644 index 000000000..378e133ed --- /dev/null +++ b/app/models/provider/metadata.rb @@ -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 diff --git a/app/views/settings/_section.html.erb b/app/views/settings/_section.html.erb index 0f32b3561..fcbe77f00 100644 --- a/app/views/settings/_section.html.erb +++ b/app/views/settings/_section.html.erb @@ -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 %>
class="group bg-container shadow-border-xs rounded-xl p-4" @@ -7,7 +7,10 @@
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
-

<%= title %>

+
+

<%= title %>

+ <%= badge if badge.present? %> +
<% if subtitle.present? %>

<%= subtitle %>

<% end %> @@ -19,6 +22,7 @@ <%= meta %> <% end %> <%= render "settings/providers/status_pill", status: status %> + <%= actions if actions.present? %>
<% end %> diff --git a/app/views/settings/providers/_add_provider_cta.html.erb b/app/views/settings/providers/_add_provider_cta.html.erb new file mode 100644 index 000000000..4eef9dcac --- /dev/null +++ b/app/views/settings/providers/_add_provider_cta.html.erb @@ -0,0 +1,10 @@ +
+
+

<%= t("settings.providers.add_provider_cta.title") %>

+

<%= t("settings.providers.add_provider_cta.body") %>

+
+ + <%= icon "plus", class: "w-4 h-4" %> + <%= t("settings.providers.add_provider_cta.cta") %> + +
diff --git a/app/views/settings/providers/_group_heading.html.erb b/app/views/settings/providers/_group_heading.html.erb index 42eb69764..5b234c4f2 100644 --- a/app/views/settings/providers/_group_heading.html.erb +++ b/app/views/settings/providers/_group_heading.html.erb @@ -1,5 +1,5 @@ -<%# locals: (title:, count: nil, description: nil) %> -
+<%# locals: (title:, count: nil, description: nil, anchor: nil) %> +

<%= title %> <% if count %> diff --git a/app/views/settings/providers/_sync_button.html.erb b/app/views/settings/providers/_sync_button.html.erb new file mode 100644 index 000000000..039fcb626 --- /dev/null +++ b/app/views/settings/providers/_sync_button.html.erb @@ -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 %> diff --git a/app/views/settings/providers/connect_form.html.erb b/app/views/settings/providers/connect_form.html.erb new file mode 100644 index 000000000..340d584a7 --- /dev/null +++ b/app/views/settings/providers/connect_form.html.erb @@ -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 %> + + <%= render "settings/providers/#{@panel_partial}" %> + + <% else %> + + <%= render "settings/providers/provider_form", configuration: @provider_configuration %> + + <% end %> + <% end %> +<% end %> diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index cea4d7e89..b0a2e06e4 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -1,4 +1,4 @@ -<%= content_for :page_title, "Sync Providers" %> +<%= content_for :page_title, "Bank Sync" %>
<% if @encryption_error %> @@ -12,31 +12,48 @@

<% else %> -
-

- Configure credentials for third-party sync providers. Settings configured here will override environment variables. +

+

+ Connect external accounts so transactions, balances and holdings flow into Sure automatically.

+ <% 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 %>
<%= 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? %>

<%= t("settings.providers.groups.empty_connected") %>

<% 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 %> - - <%= render "settings/providers/#{entry[:partial]}" %> - - <% 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 %> - - <%= render "settings/providers/#{entry[:partial]}" %> - + <% if @available.empty? %> +

<%= t("settings.providers.groups.empty_available") %>

+ <% else %> +
+ <% @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 %> +
<% end %> <% end %>
diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 176a2e839..8f1e51e83 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -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: diff --git a/config/routes.rb b/config/routes.rb index 6bc6e2a2a..97898248d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20260508120000_add_last_sync_all_attempted_at_to_families.rb b/db/migrate/20260508120000_add_last_sync_all_attempted_at_to_families.rb new file mode 100644 index 000000000..2e3403de2 --- /dev/null +++ b/db/migrate/20260508120000_add_last_sync_all_attempted_at_to_families.rb @@ -0,0 +1,5 @@ +class AddLastSyncAllAttemptedAtToFamilies < ActiveRecord::Migration[8.0] + def change + add_column :families, :last_sync_all_attempted_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 1c11eea00..3c58b76a4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/test/system/settings/providers_test.rb b/test/system/settings/providers_test.rb index ede47edf2..256b6524c 100644 --- a/test/system/settings/providers_test.rb +++ b/test/system/settings/providers_test.rb @@ -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