-
Notifications
You must be signed in to change notification settings - Fork 933
Bank Sync cleanup #1710
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Bank Sync cleanup #1710
Changes from 5 commits
391364d
87ff9c0
6633f29
4623bc3
2b59dd6
a235b5a
7e2f47d
423b209
db7080d
45b5bc0
3851543
fff5c97
60b2f2b
bf73e3a
d037412
4899d98
89726e6
4bd9ddd
0002237
b019944
e0aab86
313ab83
693e3e4
229f657
8a2f16a
41cd641
ee798ae
77a100b
e57f7ce
8c96195
6abceb0
4c9e0f2
1c49750
c90836e
3f3e717
81dd431
c5668cb
4b9ca7d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| <div class="grid grid-cols-4 gap-2"> | ||
| <% tiles.each do |tile| %> | ||
| <div class="bg-container shadow-border-xs rounded-xl p-3 text-center"> | ||
| <div class="text-2xl font-bold tabular-nums <%= tile[:color_class] %>"><%= tile[:count] %></div> | ||
| <div class="text-xs text-secondary mt-0.5"><%= tile[:label] %></div> | ||
| </div> | ||
| <% end %> | ||
| </div> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| class Settings::HealthSummary < ApplicationComponent | ||
| def initialize(counts:) | ||
| @counts = counts | ||
| end | ||
|
|
||
| private | ||
| attr_reader :counts | ||
|
|
||
| def connected_count = counts[:connected] | ||
| def needs_attention_count = counts[:needs_attention] | ||
| def errors_count = counts[:errors] | ||
| def accounts_synced_count = counts[:accounts_synced] | ||
|
|
||
| def tiles | ||
| [ | ||
| { | ||
| count: connected_count, | ||
| label: t("settings.providers.health.connected"), | ||
| color_class: connected_count > 0 ? "text-success" : "text-subdued" | ||
| }, | ||
| { | ||
| count: needs_attention_count, | ||
| label: t("settings.providers.health.needs_attention"), | ||
| color_class: needs_attention_count > 0 ? "text-warning" : "text-subdued" | ||
| }, | ||
| { | ||
| count: errors_count, | ||
| label: t("settings.providers.health.errors"), | ||
| color_class: errors_count > 0 ? "text-destructive" : "text-subdued" | ||
| }, | ||
| { | ||
| count: accounts_synced_count, | ||
| label: t("settings.providers.health.accounts_synced"), | ||
| color_class: "text-primary" | ||
| } | ||
| ] | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| <div class="bg-container shadow-border-xs rounded-xl p-4 flex flex-col gap-3 hover:shadow-border-sm transition-shadow"> | ||
| <div class="flex items-start gap-3"> | ||
| <div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 <%= logo_bg %>"> | ||
| <span class="text-xs font-bold text-white"><%= logo_text %></span> | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| </div> | ||
| <div class="flex-1 min-w-0"> | ||
| <div class="flex items-center gap-2 flex-wrap"> | ||
| <span class="font-medium text-primary"><%= name %></span> | ||
| <% if maturity_label %> | ||
| <span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-alpha-black-50 text-secondary"><%= maturity_label %></span> | ||
| <% end %> | ||
| </div> | ||
| <% if meta_line.present? %> | ||
| <p class="text-xs text-subdued mt-0.5"><%= meta_line %></p> | ||
| <% end %> | ||
| </div> | ||
| </div> | ||
| <% if tagline.present? %> | ||
| <p class="text-sm text-secondary grow"><%= tagline %></p> | ||
| <% end %> | ||
| <div class="flex justify-end"> | ||
| <%= link_to connect_path, | ||
| class: "inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:text-primary/70 transition-colors", | ||
| data: { turbo_frame: "drawer", turbo_prefetch: "false" } do %> | ||
| <%= t("settings.providers.connect") %> | ||
| <%= helpers.icon "arrow-right", class: "w-4 h-4" %> | ||
| <% end %> | ||
| </div> | ||
| </div> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file was deleted.
| 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 ] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Including 🤖 Prompt for AI Agents |
||
|
|
||
| def show | ||
| @breadcrumbs = [ | ||
| [ "Home", root_path ], | ||
| [ "Bank Sync Providers", nil ] | ||
| [ "Bank sync", nil ] | ||
| ] | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| prepare_show_context | ||
|
|
@@ -77,6 +77,54 @@ def update | |
| 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") | ||
|
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 | ||
| 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 | ||
|
|
@@ -119,19 +167,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 | ||
|
|
@@ -145,5 +245,83 @@ def prepare_show_context | |
| @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 | ||
| @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 | ||
|
|
||
| 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 } | ||
|
|
||
| @health_counts = { | ||
| connected: @connected.size + @needs_attention.size, | ||
| needs_attention: @needs_attention.size, | ||
| errors: @needs_attention.count { |e| e[:summary][:status] == :err }, | ||
| accounts_synced: Current.family.accounts.joins(:account_providers).distinct.count | ||
| } | ||
| 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 | ||
| PANEL_SYNCABLE_TYPES.each_with_object({}) do |(key, syncable_type), health| | ||
| items = instance_variable_get("@#{key}_items") | ||
| ids = items&.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| | ||
| { | ||
| 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 | ||
|
|
||
| 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 | ||
Uh oh!
There was an error while loading. Please reload this page.