<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
<% 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 @@
+
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