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