Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
391364d
feat(settings/providers): surface connection status in section headers
claude May 8, 2026
87ff9c0
feat(settings/providers): group providers into Connected and Available
jjmata May 8, 2026
6633f29
feat(settings/providers): health strip, action-needed group, and sync…
jjmata May 8, 2026
4623bc3
feat(settings/providers): card grid for available providers with conn…
jjmata May 8, 2026
2b59dd6
feat(settings): retire /settings/bank_sync; merge into providers page
jjmata May 8, 2026
a235b5a
Merge branch 'main' into feat/surface-provider-status
jjmata May 8, 2026
7e2f47d
Migrations are 7.2 here
jjmata May 8, 2026
423b209
Minimize schema noise
jjmata May 8, 2026
db7080d
Schema duplication
jjmata May 8, 2026
45b5bc0
Small copy edits
jjmata May 8, 2026
3851543
Fix tests
jjmata May 8, 2026
fff5c97
Merge remote-tracking branch 'origin/main' into feat/surface-provider…
jjmata May 9, 2026
60b2f2b
Address provider settings review feedback
jjmata May 9, 2026
bf73e3a
refactor(settings/providers): finish design-review cleanup pass
gariasf May 9, 2026
d037412
feat(settings/providers): replace Add another provider CTA with a sea…
gariasf May 9, 2026
4899d98
Private method fix
jjmata May 9, 2026
89726e6
refactor(settings/providers): drawer cleanup, header lock-up, trust s…
gariasf May 9, 2026
4bd9ddd
chore(locales): drop unused provider-panel status strings
gariasf May 9, 2026
0002237
feat(settings/providers): connected-state polish per design §05 + Lin…
gariasf May 9, 2026
b019944
fix(settings/providers): align connected state with the final design …
gariasf May 9, 2026
e0aab86
refactor(settings/providers): tighten paddings, dedupe maturity badge…
gariasf May 9, 2026
313ab83
fix(settings/providers): icons + search input height
gariasf May 9, 2026
693e3e4
refactor(settings/providers): align Sync all + search input with DS, …
gariasf May 9, 2026
229f657
fix(settings/providers): drawer trust statement uses border-tertiary
gariasf May 9, 2026
8a2f16a
refactor(settings/providers): swap arbitrary Tailwind values for scal…
gariasf May 9, 2026
41cd641
revert(settings/providers): drop the slim health strip
gariasf May 9, 2026
ee798ae
refactor(settings/providers): align with DS conventions
gariasf May 9, 2026
77a100b
fix(provider/metadata): add plaid_eu entry
gariasf May 9, 2026
e57f7ce
Merge branch 'main' into feat/surface-provider-status
gariasf May 9, 2026
8c96195
fix(settings/providers): center-align Sync all next to the lede
gariasf May 9, 2026
6abceb0
fix(settings/providers): drop colour palette + filter polish + drawer…
gariasf May 9, 2026
4c9e0f2
refactor(settings/providers): drawer alerts use DS::Alert; drop card-…
gariasf May 9, 2026
1c49750
fix(settings/providers): hoist warning alerts to top of drawer
gariasf May 9, 2026
c90836e
fix(DS::Alert): align icon to cap-height of first text line
gariasf May 9, 2026
3f3e717
copy(settings/providers): tighten alert messaging per voice review
gariasf May 9, 2026
81dd431
feat(settings/providers): SetupSteps partial for connect-drawer instr…
gariasf May 9, 2026
c5668cb
fix(settings/providers): tighten panel spacing + relocate per-panel n…
gariasf May 9, 2026
4b9ca7d
fix(settings/providers): plaid + indexa drawers join the SetupSteps look
gariasf May 9, 2026
2e0d36d
fix(settings/providers): surface configured plaid_eu + dedup show con…
gariasf May 9, 2026
39ac899
i18n(settings/providers): localize plaid setup steps + drop dead defa…
gariasf May 9, 2026
31e23d7
test(settings/providers): cover plaid_eu, clear filters, warn outline
gariasf May 9, 2026
95eec78
copy(settings/providers): drop em dashes, naturalize phrasing
gariasf May 9, 2026
59122cd
fix(settings/providers): address CodeRabbit pass on PR #1717
gariasf May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Procfile.dev
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0 -p ${PORT:-3000}
css: bundle exec bin/rails tailwindcss:watch 2>/dev/null
worker: bundle exec sidekiq
2 changes: 1 addition & 1 deletion app/components/DS/alert.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div class="<%= container_classes %>">
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %>
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 mt-0.5" %>

<div class="flex-1 text-sm">
<%= message %>
Expand Down
25 changes: 25 additions & 0 deletions app/components/settings/provider_card.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<%= link_to connect_path,
class: "bg-container shadow-border-xs rounded-xl p-4 flex flex-col gap-2.5 text-primary hover:bg-container-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100",
data: { turbo_frame: "drawer", turbo_prefetch: "false" }.merge(filter_data) do %>
<div class="flex items-start gap-2.5">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 bg-surface-inset">
<span class="text-xs font-bold text-primary"><%= logo_text %></span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-primary"><%= name %></span>
<%= render "settings/providers/maturity_badge", label: maturity_label %>
</div>
<% if meta_line.present? %>
<p class="text-xs text-subdued mt-0.5"><%= meta_line %></p>
<% end %>
</div>
</div>
<% if tagline.present? %>
<p class="text-sm text-secondary grow leading-snug"><%= tagline %></p>
<% end %>
<div class="flex items-center justify-end gap-1.5 text-sm font-medium text-primary">
<%= t("settings.providers.connect") %>
<%= helpers.icon "arrow-right", size: "sm", class: "!w-3.5 !h-3.5" %>
</div>
<% end %>
46 changes: 46 additions & 0 deletions app/components/settings/provider_card.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
class Settings::ProviderCard < ApplicationComponent
MATURITY_LABELS = {
beta: "settings.providers.maturity.beta",
alpha: "settings.providers.maturity.alpha"
}.freeze

def self.maturity_label(maturity)
key = MATURITY_LABELS[maturity&.to_sym]
I18n.t(key) if key
end

def initialize(provider_key:, name:, tagline: nil, region: nil, kind: nil, tier: nil,
maturity: :stable, logo_text: nil)
@provider_key = provider_key
@name = name
@tagline = tagline
@region = region
@kind = kind
@tier = tier
@maturity = maturity.to_sym
@logo_text = logo_text || name.first(2).upcase
end

attr_reader :name, :tagline, :logo_text

def maturity_label
self.class.maturity_label(@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

def filter_data
{
providers_filter_target: "card",
provider_name: @name.to_s.downcase,
provider_region: @region.to_s.downcase,
provider_kind: @kind.to_s.downcase
}
end
end
43 changes: 0 additions & 43 deletions app/controllers/settings/bank_sync_controller.rb

This file was deleted.

228 changes: 213 additions & 15 deletions app/controllers/settings/providers_controller.rb
Original file line number Diff line number Diff line change
@@ -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 ]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

show is now admin-gated, so the shared Bank Sync entry dead-ends for family members.

Including :show here means a non-admin who follows the new Bank Sync nav item gets bounced to root_path with "Not authorized" instead of seeing the page. If the page is meant to be reachable for both admin and non-admin users per the PR objective, keep the write/sync actions restricted but leave show accessible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/controllers/settings/providers_controller.rb` at line 4, The
before_action currently gates :show behind ensure_admin (before_action
:ensure_admin, only: [ :show, :update, :sync_all, :sync, :connect_form ]), which
blocks non-admin family members from viewing the Bank Sync page; remove :show
from the only list so that ensure_admin continues to protect write/sync actions
(:update, :sync_all, :sync, :connect_form) but allows non-admins to reach the
show action.


def show
@breadcrumbs = [
[ "Home", root_path ],
[ "Bank Sync Providers", nil ]
[ "Bank sync", nil ]
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

prepare_show_context
Expand Down Expand Up @@ -77,6 +77,61 @@ def update
render :show, status: :unprocessable_entity
end

def sync_all
family = Current.family
now = Time.current

updated_count = Family
.where(id: family.id)
.where("last_sync_all_attempted_at IS NULL OR last_sync_all_attempted_at <= ?", 30.seconds.ago)
.update_all(last_sync_all_attempted_at: now, updated_at: now)

if updated_count.zero?
return redirect_to settings_providers_path, notice: t("settings.providers.sync_all_recently")
end

SyncAllProvidersJob.perform_later(family.id)
redirect_to settings_providers_path, notice: t("settings.providers.sync_all_in_progress")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
scheduled = items.reject(&:syncing?)
scheduled.each(&:sync_later)

notice_key = scheduled.any? ? "settings.providers.sync_provider_in_progress" : "settings.providers.sync_provider_no_items"
redirect_to settings_providers_path, notice: t(notice_key)
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::Metadata.for(provider_key)[:name] || provider_key.titleize
@provider_configuration = config
return render :connect_form
end

redirect_to settings_providers_path, alert: t("settings.providers.not_found")
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
Expand All @@ -93,7 +148,9 @@ def provider_params
end

def ensure_admin
redirect_to settings_providers_path, alert: "Not authorized" unless Current.user.admin?
return if Current.user.admin?

redirect_to root_path, alert: t("settings.providers.not_authorized")
end

# Reload provider configurations after settings update
Expand All @@ -119,19 +176,71 @@ def reload_provider_configs(updated_fields)
end
end

# Hardcoded family-scoped panels — provider connections are managed through
# their own models (SimplefinItem, LunchflowItem, etc.) rather than global
# settings, so they need custom UI per-provider for connection management,
# 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", 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

# Maps panel key → ActiveRecord model name for sync health queries
PANEL_SYNCABLE_TYPES = {
"simplefin" => "SimplefinItem",
"lunchflow" => "LunchflowItem",
"enable_banking" => "EnableBankingItem",
"coinstats" => "CoinstatsItem",
"mercury" => "MercuryItem",
"coinbase" => "CoinbaseItem",
"binance" => "BinanceItem",
"snaptrade" => "SnaptradeItem",
"indexa_capital" => "IndexaCapitalItem",
"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 SimpleFin and Lunchflow, which have their own family-specific panels below)
# Load all provider configurations (exclude family-scoped panels, which have their own UI below)
Provider::Factory.ensure_adapters_loaded
@provider_configurations = Provider::ConfigurationRegistry.all.reject do |config|
config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \
config.provider_key.to_s.casecmp("enable_banking").zero? || \
config.provider_key.to_s.casecmp("sophtron").zero? || \
config.provider_key.to_s.casecmp("coinstats").zero? || \
config.provider_key.to_s.casecmp("mercury").zero? || \
config.provider_key.to_s.casecmp("coinbase").zero? || \
config.provider_key.to_s.casecmp("snaptrade").zero? || \
config.provider_key.to_s.casecmp("indexa_capital").zero?
FAMILY_PANEL_KEYS.any? { |key| config.provider_key.to_s.casecmp(key).zero? }
end

# Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials
Expand All @@ -141,9 +250,98 @@ def prepare_show_context
# Providers page only needs to know whether any Sophtron connections exist with valid credentials
@sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id)
@coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display
@mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts)
@mercury_items = Current.family.mercury_items.active.ordered
@coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display
@snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered
@snaptrade_items = Current.family.snaptrade_items.ordered
@indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id)
@binance_items = Current.family.binance_items.active.ordered

@provider_sync_health = compute_provider_sync_health(family_panel_items)

entries = build_provider_entries

@connected = entries.select { |e| e[:summary][:status] == :ok }
@needs_attention = entries.select { |e| [ :warn, :err ].include?(e[:summary][:status]) }
@available = entries.select { |e| e[:summary][:status] == :off }
end

# Maps each family panel key to the loaded item collection. Used by
# compute_provider_sync_health and build_provider_entries to avoid relying
# on instance_variable_get for control flow.
def family_panel_items
{
"simplefin" => @simplefin_items,
"lunchflow" => @lunchflow_items,
"enable_banking" => @enable_banking_items,
"coinstats" => @coinstats_items,
"mercury" => @mercury_items,
"coinbase" => @coinbase_items,
"binance" => @binance_items,
"snaptrade" => @snaptrade_items,
"indexa_capital" => @indexa_capital_items,
"sophtron" => @sophtron_items
}
end

# Returns a hash mapping provider key → { error:, last_synced_at:, stale: }
# by querying the latest sync per item for each family panel provider.
def compute_provider_sync_health(items_map)
PANEL_SYNCABLE_TYPES.each_with_object({}) do |(key, syncable_type), health|
ids = items_map[key]&.map(&:id)&.compact
next if ids.blank?

health[key] = sync_health_for(syncable_type, ids)
end
end

# Determines error/stale status and last successful sync time for a set of items.
def sync_health_for(syncable_type, item_ids)
# Use window function to get the single latest sync per item (same pattern as ProviderConnectionStatus)
ranked_subq = Sync
.where(syncable_type: syncable_type, syncable_id: item_ids)
.select("syncs.*, ROW_NUMBER() OVER (PARTITION BY syncable_id ORDER BY created_at DESC, id DESC) AS sync_rank")

latest_per_item = Sync.from(ranked_subq, :syncs).where("sync_rank = 1").to_a

has_error = latest_per_item.any? { |s| s.failed? || s.stale? }

last_synced = Sync
.where(syncable_type: syncable_type, syncable_id: item_ids, status: "completed")
.maximum(:completed_at)

stale = !has_error && last_synced.present? && last_synced < 24.hours.ago

{ error: has_error, last_synced_at: last_synced, stale: stale }
end

# Builds a unified list of provider entries (registry-driven configurations
# and hardcoded family panels) with pre-computed status, sorted
# alphabetically by display title. Each entry carries enough data for the
# view to render either a provider_form or a family panel partial.
def build_provider_entries
configuration_entries = @provider_configurations.map do |config|
meta = Provider::Metadata.for(config.provider_key)
{
provider_key: config.provider_key.to_s,
title: meta[:name] || config.provider_key.to_s.titleize,
configuration: config,
maturity: meta[:maturity],
summary: view_context.provider_summary(config.provider_key)
}
end

family_entries = FAMILY_PANELS.map do |panel|
{
provider_key: panel[:key],
title: panel[:title],
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

(configuration_entries + family_entries).sort_by { |entry| entry[:title].downcase }
end
end
Loading
Loading