From 1137f1f57bdb453e37b43429cfff17aece184548 Mon Sep 17 00:00:00 2001 From: Cameron Roudebush Date: Mon, 12 May 2025 22:52:35 -0400 Subject: [PATCH] feat(errors): Updating error display to better handle SimpleFIN errors - Also updating models to follow the plaid style a bit more --- .env.example | 1 - app/controllers/accounts_controller.rb | 2 +- app/controllers/simple_fin_controller.rb | 31 ++- app/jobs/family_reset_job.rb | 2 +- app/models/account.rb | 2 +- app/models/family.rb | 12 +- app/models/provider/simple_fin.rb | 19 +- app/models/simple_fin_account.rb | 16 +- app/models/simple_fin_connection.rb | 87 --------- app/models/simple_fin_item.rb | 178 ++++++++++++++++++ app/views/accounts/_account.html.erb | 2 + app/views/accounts/_account_error.html.erb | 34 ++++ .../accounts/_accountable_group.html.erb | 37 ++-- app/views/accounts/index.html.erb | 6 +- app/views/accounts/show/_chart.html.erb | 8 +- app/views/accounts/show/_header.html.erb | 20 +- .../_simple_fin_connection.html.erb | 16 +- config/routes.rb | 3 + .../20250509134646_simple_fin_integration.rb | 16 +- db/schema.rb | 23 +-- 20 files changed, 350 insertions(+), 165 deletions(-) delete mode 100644 app/models/simple_fin_connection.rb create mode 100644 app/models/simple_fin_item.rb create mode 100644 app/views/accounts/_account_error.html.erb diff --git a/.env.example b/.env.example index 20095a17..409e23de 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,6 @@ APP_DOMAIN= # Allows configuration of SimpleFIN for account linking: https://www.simplefin.org/ # You'll want to follow the steps here for getting an AccessURL https://beta-bridge.simplefin.org/info/developers SIMPLE_FIN_ACCESS_URL= -SIMPLE_FIN_UPDATE_CRON="0 6 * * *" # Disable enforcing SSL connections # DISABLE_SSL=true diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 65728c64..47b6a258 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -5,7 +5,7 @@ class AccountsController < ApplicationController def index @manual_accounts = family.accounts.manual.alphabetically @plaid_items = family.plaid_items.ordered - @simple_fin_connections = family.simple_fin_connections.ordered + @simple_fin_items = family.simple_fin_items.ordered render layout: "settings" end diff --git a/app/controllers/simple_fin_controller.rb b/app/controllers/simple_fin_controller.rb index 89a0d701..5df9de41 100644 --- a/app/controllers/simple_fin_controller.rb +++ b/app/controllers/simple_fin_controller.rb @@ -3,8 +3,8 @@ class SimpleFinController < ApplicationController before_action :set_accountable_type before_action :authenticate_user! - before_action :set_simple_fin_provider - before_action :require_simple_fin_provider + before_action :set_simple_fin_provider, only: %i[create new] + before_action :require_simple_fin_provider, only: %i[create new] def new @simple_fin_accounts = @simple_fin_provider.get_available_accounts(@accountable_type) @@ -21,6 +21,20 @@ class SimpleFinController < ApplicationController Current.family.accounts.find_by(name: acc["name"]) end + ## + # Requests all accounts to be re-synced + def sync + @simple_fin_item = Current.family.simple_fin_items.find(params[:id]) + unless @simple_fin_item.syncing? + @simple_fin_item.sync_later + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + def create selected_ids = params[:selected_account_ids] if selected_ids.blank? @@ -39,17 +53,17 @@ class SimpleFinController < ApplicationController first_sf_account = sf_accounts_for_institution.first # Use data from the first account for connection details org_details = first_sf_account["org"] - # Find or Create the SimpleFinConnection for this institution - simple_fin_connection = Current.family.simple_fin_connections.find_or_create_by!(institution_id: institution_id) do |sfc| - sfc.name = org_details["name"] || "SimpleFIN Connection" + # Find or Create the SimpleFinItem for this institution + simple_fin_item = Current.family.simple_fin_items.find_or_create_by!(institution_id: institution_id) do |sfc| sfc.institution_name = org_details["name"] sfc.institution_url = org_details["url"] sfc.institution_domain = org_details["domain"] - sfc.last_synced_at = Time.current # Mark as synced upon creation + # TODO: Fix + sfc.last_sync_count_reset_at = Time.current # Mark as synced upon creation end sf_accounts_for_institution.each do |acc_detail| - next if simple_fin_connection.simple_fin_accounts.exists?(external_id: acc_detail["id"]) + next if simple_fin_item.simple_fin_accounts.exists?(external_id: acc_detail["id"]) next if account_exists(acc_detail) # Get sub type for this account from params @@ -60,7 +74,7 @@ class SimpleFinController < ApplicationController # Create SimpleFinAccount and its associated Account simple_fin_account = SimpleFinAccount.find_or_create_from_simple_fin_data!( acc_detail, - simple_fin_connection + simple_fin_item ) # Trigger an account sync of our data @@ -75,7 +89,6 @@ class SimpleFinController < ApplicationController end private - def set_accountable_type @accountable_type = params[:accountable_type] end diff --git a/app/jobs/family_reset_job.rb b/app/jobs/family_reset_job.rb index 0de6f57f..01a554e1 100644 --- a/app/jobs/family_reset_job.rb +++ b/app/jobs/family_reset_job.rb @@ -10,7 +10,7 @@ class FamilyResetJob < ApplicationJob family.tags.destroy_all family.merchants.destroy_all family.plaid_items.destroy_all - family.simple_fin_connections.destroy_all + family.simple_fin_items.destroy_all family.imports.destroy_all family.budgets.destroy_all diff --git a/app/models/account.rb b/app/models/account.rb index c01bc069..14dd85c6 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -68,7 +68,7 @@ class Account < ApplicationRecord url_string = if plaid_account.present? plaid_account.plaid_item&.institution_url elsif simple_fin_account.present? - simple_fin_account.simple_fin_connection&.institution_domain + simple_fin_account.simple_fin_item&.institution_domain end return nil unless url_string.present? diff --git a/app/models/family.rb b/app/models/family.rb index d130a685..0136d540 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -16,7 +16,7 @@ class Family < ApplicationRecord has_many :users, dependent: :destroy has_many :accounts, dependent: :destroy has_many :plaid_items, dependent: :destroy - has_many :simple_fin_connections, dependent: :destroy + has_many :simple_fin_items, dependent: :destroy has_many :invitations, dependent: :destroy has_many :imports, dependent: :destroy @@ -82,6 +82,11 @@ class Family < ApplicationRecord plaid_item.sync_later(start_date: start_date, parent_sync: sync) end + Rails.logger.info("Syncing simple_fin items for family #{id}") + simple_fin_items.each do |simple_fin_item| + simple_fin_item.sync_later(start_date: start_date, parent_sync: sync) + end + Rails.logger.info("Applying rules for family #{id}") rules.each do |rule| rule.apply_later @@ -103,8 +108,9 @@ class Family < ApplicationRecord Sync.where( "(syncable_type = 'Family' AND syncable_id = ?) OR (syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR - (syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))", - id, id, id + (syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?)) OR + (syncable_type = 'SimpleFinItem' AND syncable_id IN (SELECT id FROM simple_fin_items WHERE family_id = ?))", + id, id, id, id ).where(status: [ "pending", "syncing" ], created_at: 10.minutes.ago..).exists? end diff --git a/app/models/provider/simple_fin.rb b/app/models/provider/simple_fin.rb index 9fc764b0..5a43e82b 100644 --- a/app/models/provider/simple_fin.rb +++ b/app/models/provider/simple_fin.rb @@ -92,14 +92,17 @@ class Provider::SimpleFin # TODO: Remove JSON Reading for real requests. Disabled currently due to preventing rate limits. json_file_path = Rails.root.join("sample.simple.fin.json") accounts = [] + error_messages = [] if File.exist?(json_file_path) file_content = File.read(json_file_path) parsed_json = JSON.parse(file_content) accounts = parsed_json["accounts"] || [] + error_messages = parsed_json["errors"] || [] else Rails.logger.warn "SimpleFIN: Sample JSON file not found at #{json_file_path}. Returning empty accounts." end + # The only way we can really determine types right now is by some properties. Try and set their types accounts.each do |account| # Accounts can be considered Investment accounts if they have any holdings associated to them @@ -112,10 +115,22 @@ class Provider::SimpleFin else account["type"] = "Depository" # Default for positive balance end + + # Set error messages if related + account["org"]["institution_errors"] = [] + error_messages.each do |error| + if error.include? account["org"]["name"] + account["org"]["institution_errors"].push(error) + end + end end - # Update accounts to only include relevant accounts to the typ - accounts.filter { |acc| acc["type"] == accountable_type } + if accountable_type == nil + accounts + else + # Update accounts to only include relevant accounts to the type + accounts.filter { |acc| acc["type"] == accountable_type } + end end # Returns if this is a supported API of SimpleFIN by the access url in the config. diff --git a/app/models/simple_fin_account.rb b/app/models/simple_fin_account.rb index b253211e..4c53b3fd 100644 --- a/app/models/simple_fin_account.rb +++ b/app/models/simple_fin_account.rb @@ -7,13 +7,13 @@ class SimpleFinAccount < ApplicationRecord "Other" => OtherAsset } - belongs_to :simple_fin_connection + belongs_to :simple_fin_item has_one :account, dependent: :destroy, foreign_key: :simple_fin_account_id, inverse_of: :simple_fin_account accepts_nested_attributes_for :account validates :external_id, presence: true, uniqueness: true - validates :simple_fin_connection_id, presence: true + validates :simple_fin_item_id, presence: true after_destroy :cleanup_connection_if_orphaned @@ -60,7 +60,7 @@ class SimpleFinAccount < ApplicationRecord def family - simple_fin_connection&.family + simple_fin_item&.family end end @@ -70,6 +70,8 @@ class SimpleFinAccount < ApplicationRecord # 'account' here refers to self.account (the associated Account instance) accountable_attributes = { id: self.account.accountable_id } + puts "SFA #{sf_account_data}" + self.update!( current_balance: sf_account_data["balance"].to_d, available_balance: sf_account_data["available-balance"]&.to_d, @@ -83,6 +85,12 @@ class SimpleFinAccount < ApplicationRecord } ) + self.simple_fin_item.update!( + institution_errors: sf_account_data["org"]["institution_errors"], + status: sf_account_data["org"]["institution_errors"].empty? ? :good : :requires_update + + ) + # Sync transactions if present in the data if sf_account_data["transactions"].is_a?(Array) sync_transactions!(sf_account_data["transactions"]) @@ -181,7 +189,7 @@ class SimpleFinAccount < ApplicationRecord def cleanup_connection_if_orphaned # Reload the connection to get the most up-to-date count of associated accounts - connection = simple_fin_connection.reload + connection = simple_fin_item.reload connection.destroy_later if connection.simple_fin_accounts.empty? end end diff --git a/app/models/simple_fin_connection.rb b/app/models/simple_fin_connection.rb deleted file mode 100644 index 6aa0d155..00000000 --- a/app/models/simple_fin_connection.rb +++ /dev/null @@ -1,87 +0,0 @@ -class SimpleFinConnection < ApplicationRecord - include Syncable - - enum :status, { good: "good", requires_update: "requires_update" }, default: :good - - validates :name, presence: true - validates :family_id, presence: true - - belongs_to :family - has_one_attached :logo - - has_many :simple_fin_accounts, dependent: :destroy - has_many :accounts, through: :simple_fin_accounts - - scope :active, -> { where(scheduled_for_deletion: false) } - scope :ordered, -> { order(created_at: :desc) } - scope :needs_update, -> { where(status: :requires_update) } - - class << self - # `provided_access_url` is the full URL from SimpleFIN (https://user:pass@beta-bridge.simplefin.org/simplefin) - # `connection_name` can be user-provided or derived. - def create_and_sync_from_access_url(provided_access_url, connection_name, family_obj) - # Basic validation of the URL format - uri = URI.parse(provided_access_url) - raise ArgumentError, "Invalid SimpleFIN Access URL: Missing credentials" unless uri.user && uri.password - raise ArgumentError, "Invalid SimpleFIN Access URL: Must be HTTPS" unless uri.scheme == "https" - - # Create the connection object first - connection = family_obj.simple_fin_connections.create!( - name: connection_name, - access_url: provided_access_url, - status: :good # Assume good initially - ) - - # Perform an initial sync to populate institution details and accounts - connection.sync_later - connection - end - end - - def sync_data(sync, start_date: nil) - update!(last_synced_at: Time.current) - Rails.logger.info("SimpleFinConnection: Starting sync for connection ID #{id}") - - begin - # Fetch initial info if not present (like institution details) - if institution_id.blank? || api_versions_supported.blank? - info_data = provider.get_api_versions_and_org_details_from_accounts - update!( - institution_id: info_data[:org_id], - institution_name: info_data[:org_name], - institution_url: info_data[:org_url], - institution_domain: info_data[:org_domain], - api_versions_supported: info_data[:versions] - ) - end - - sf_accounts_data = provider.get_available_accounts(nil) # Pass nil to get all types - - sf_accounts_data.each do |sf_account_data| - accountable_klass_name = Provider::SimpleFin::ACCOUNTABLE_TYPE_MAPPING.find { |key, _val| sf_account_data["type"]&.downcase == key.downcase }&.last - accountable_klass_name ||= (sf_account_data["balance"].to_d >= 0 ? Depository : CreditCard) # Basic fallback - accountable_klass = accountable_klass_name - - sfa = simple_fin_accounts.find_or_create_from_simple_fin_data!(sf_account_data, self, accountable_klass) - sfa.sync_account_data!(sf_account_data) - end - - update!(status: :good) if requires_update? - Rails.logger.info("SimpleFinConnection: Sync completed for connection ID #{id}") - - rescue StandardError => e - Rails.logger.error("SimpleFinConnection: Sync failed for connection ID #{id}: #{e.message}") - update!(status: :requires_update) - raise e - end - end - - def provider - @provider ||= Provider::SimpleFin.new() - end - - def destroy_later - update!(scheduled_for_deletion: true) - DestroyJob.perform_later(self) - end -end diff --git a/app/models/simple_fin_item.rb b/app/models/simple_fin_item.rb new file mode 100644 index 00000000..2b85ade6 --- /dev/null +++ b/app/models/simple_fin_item.rb @@ -0,0 +1,178 @@ +class SimpleFinItem < ApplicationRecord + include Syncable + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + validates :institution_name, presence: true + validates :family_id, presence: true + + belongs_to :family + has_one_attached :logo + + has_many :simple_fin_accounts, dependent: :destroy + has_many :accounts, through: :simple_fin_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + class << self + # # `provided_access_url` is the full URL from SimpleFIN (https://user:pass@beta-bridge.simplefin.org/simplefin) + # # `connection_name` can be user-provided or derived. + # def create_and_sync_from_access_url(provided_access_url, connection_name, family_obj) + # # Basic validation of the URL format + # uri = URI.parse(provided_access_url) + # raise ArgumentError, "Invalid SimpleFIN Access URL: Missing credentials" unless uri.user && uri.password + # raise ArgumentError, "Invalid SimpleFIN Access URL: Must be HTTPS" unless uri.scheme == "https" + + # # Create the connection object first + # connection = family_obj.simple_fin_connections.create!( + # name: connection_name, + # access_url: provided_access_url, + # status: :good # Assume good initially + # ) + + # # Perform an initial sync to populate institution details and accounts + # connection.sync_later + # connection + # end + end + + ## + # Syncs the simple_fin_item given and all other simple_fin accounts available (reduces calls to the API) + def sync_data(sync, start_date: nil) + # TODO: Rate limit + # now = Time.current + # # Rate Limiting Check + # # We use a transaction here to ensure that checking the count, resetting it if needed, + # # and incrementing it are atomic. + # ActiveRecord::Base.transaction do + # # Reload self to ensure we have the latest values from the DB, + # # especially if this method could be called concurrently for the same item. + # self.reload + + # if self.last_sync_count_reset_at.nil? || self.last_sync_count_reset_at.to_date < now.to_date + # # If it's a new day (or first sync ever for rate limiting), reset the count and the reset timestamp. + # self.update_columns(syncs_today_count: 0, last_sync_count_reset_at: now) + # self.reload # Reload again to get the just-updated values for the check below. + # end + + # if self.syncs_today_count >= 24 + # msg = "SimpleFinItem ID #{self.id}: Sync limit of 24 per day reached. Count: #{self.syncs_today_count}." + # Rails.logger.warn(msg) + # sync.fail!(StandardError.new(msg)) # Record failure in the Sync object + # raise StandardError, msg # Raise to stop execution and ensure SyncJob handles it as a failure + # end + + # # If not rate-limited, increment the count for this sync attempt. + # self.increment!(:syncs_today_count) + # end + + + # unless access_url.present? + # # This is a configuration error for the connection itself. + # msg = "SimpleFinConnection: Sync cannot proceed for connection ID #{id}: Missing access_url." + # Rails.logger.error(msg) + # update!(status: :requires_update) # Mark connection as needing attention + # # Raise an error to ensure the SyncJob records this failure. + # # Sync#perform will catch this and call sync.fail! + # raise StandardError, msg + # end + + # TODO: Populate this + # update!(last_synced_at: Time.current, status: :requires_update) + + Rails.logger.info("SimpleFinConnection: Starting sync for connection ID #{id}") + + begin + # Fetch all accounts for this specific connection from SimpleFIN. + sf_accounts_data = provider.get_available_accounts(nil) + + + # Keep track of external IDs reported by the provider in this sync. + # This can be used later to identify accounts that might have been removed on the SimpleFIN side. + current_provider_external_ids = [] + + sf_accounts_data.each do |sf_account_data| + current_provider_external_ids << sf_account_data["id"] + + begin + # Find or create the SimpleFinAccount record. + sfa = SimpleFinAccount.find_by(external_id: sf_account_data["id"]) + rescue StandardError + # Ignore because it could be non existent accounts from the central sync + end + + if sfa != nil + # Sync the detailed data for this account (e.g., balance, and potentially transactions). + # This method is expected to be on the SimpleFinAccount model. + sfa.sync_account_data!(sf_account_data) + end + end + + # Optional: You could add logic here to handle accounts that exist in your DB for this + # SimpleFinConnection but were NOT reported by the provider in `sf_accounts_data`. + # These could be marked as closed, archived, etc. For example: + # simple_fin_accounts.where.not(external_id: current_provider_external_ids).find_each(&:archive!) + + update!(status: :good) # Mark connection as successfully synced. + Rails.logger.info("SimpleFinConnection: Sync completed for connection ID #{id}") + + # rescue Provider::SimpleFin::AuthenticationError => e # Catch specific auth errors if your provider defines them. + # Rails.logger.error("SimpleFinConnection: Authentication failed for connection ID #{id}: #{e.message}") + # update!(status: :requires_update) # Mark the connection so the user knows to update credentials. + # raise e # Re-raise so Sync#perform can record the failure. + rescue StandardError => e + Rails.logger.error("SimpleFinConnection: Sync failed for connection ID #{id}: #{e.message}") + update!(status: :requires_update) + raise e # Re-raise so Sync#perform can record the failure. + end + end + + # def sync_data(sync, start_date: nil) + # update!(last_synced_at: Time.current) + # Rails.logger.info("SimpleFinConnection: Starting sync for connection ID #{id}") + + # # begin + # # # Fetch initial info if not present (like institution details) + # # if institution_id.blank? || api_versions_supported.blank? + # # info_data = provider.get_api_versions_and_org_details_from_accounts + # # update!( + # # institution_id: info_data[:org_id], + # # institution_name: info_data[:org_name], + # # institution_url: info_data[:org_url], + # # institution_domain: info_data[:org_domain], + # # api_versions_supported: info_data[:versions] + # # ) + # # end + + # # sf_accounts_data = provider.get_available_accounts(nil) # Pass nil to get all types + + # # sf_accounts_data.each do |sf_account_data| + # # accountable_klass_name = Provider::SimpleFin::ACCOUNTABLE_TYPE_MAPPING.find { |key, _val| sf_account_data["type"]&.downcase == key.downcase }&.last + # # accountable_klass_name ||= (sf_account_data["balance"].to_d >= 0 ? Depository : CreditCard) # Basic fallback + # # accountable_klass = accountable_klass_name + + # # sfa = simple_fin_accounts.find_or_create_from_simple_fin_data!(sf_account_data, self, accountable_klass) + # # sfa.sync_account_data!(sf_account_data) + # # end + + # update!(status: :good) if requires_update? + # Rails.logger.info("SimpleFinConnection: Sync completed for connection ID #{id}") + + # # rescue StandardError => e + # # Rails.logger.error("SimpleFinConnection: Sync failed for connection ID #{id}: #{e.message}") + # # update!(status: :requires_update) + # # raise e + # # end + # end + + def provider + @provider ||= Provider::Registry.get_provider(:simple_fin) + end + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end +end diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index 07ffd3d5..b5a65fa0 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -30,6 +30,8 @@ <% end %>
+ <%= render "accounts/account_error", account: account, given_title: "Account has an error", link_to_path: account_path(account, return_to: return_to) %> +

"> <%= format_money account.balance_money %>

diff --git a/app/views/accounts/_account_error.html.erb b/app/views/accounts/_account_error.html.erb new file mode 100644 index 00000000..157f2065 --- /dev/null +++ b/app/views/accounts/_account_error.html.erb @@ -0,0 +1,34 @@ +<%# locals: (account:, link_to_path: nil, given_title: nil, target: nil) %> + +<%# Flag indicators of account issues %> +<% if account.simple_fin_account.simple_fin_item.status == "requires_update" %> + <%= link_to link_to_path, target: target do %> + <%= icon( + "alert-triangle", + id: "account-issue-icon", + as_button: true, + size: "sm", + rel: "noopener noreferrer", + disabled: account.syncing?, + title: given_title + ) %> + <% end %> +<% end %> + +<%# Force some styling that keeps getting overwritten %> + \ No newline at end of file diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index a1ab2e3a..48808245 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -15,21 +15,30 @@
<% account_group.accounts.each do |account| %> - <%= link_to account_path(account), - class: class_names( - "block flex items-center gap-2 px-3 py-2 rounded-lg", - page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover" - ), - data: { sidebar_tabs_target: "account", action: "click->sidebar-tabs#select" }, - title: account.name do %> - <%= render "accounts/logo", account: account, size: "sm", color: account_group.color %> -
- <%= tag.p account.name, class: "text-sm text-primary font-medium mb-0.5 truncate" %> - <%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %> -
+
" + data-sidebar-tabs-target="account"> -
+ <%= link_to account_path(account), + style: "overflow: hidden;", + class: "flex items-center gap-2 grow", + title: account.name, + data: { action: "click->sidebar-tabs#select" } do %> + <%= render "accounts/logo", account: account, size: "sm", color: account_group.color %> + +
+ <%= tag.p account.name, class: "text-sm text-primary font-medium mb-0.5 truncate" %> + <%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %> +
+ <%# Render account error warnings %> + <%= render "accounts/account_error", account: account, given_title: "Account has an error", link_to_path: account_path(account) %> + <% end %> + +
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %> @@ -38,7 +47,7 @@
<% end %>
- <% end %> +
<% end %>
diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 5a30ea93..788848c0 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -23,7 +23,7 @@
-<% if @manual_accounts.empty? && @plaid_items.empty? && @simple_fin_connections.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simple_fin_items.empty? %> <%= render "empty" %> <% else %>
@@ -31,8 +31,8 @@ <%= render @plaid_items.sort_by(&:created_at) %> <% end %> - <% if @simple_fin_connections.any? %> - <%= render @simple_fin_connections.sort_by(&:created_at) %> + <% if @simple_fin_items.any? %> + <%= render @simple_fin_items.sort_by(&:created_at) %> <% end %> <% if @manual_accounts.any? %> diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index 08e67e84..356ba8ca 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -14,21 +14,19 @@ <%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
- <%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %> -
+ <%= styled_form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %> +
<% if chart_view.present? %> <%= form.select :chart_view, [["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]], { selected: chart_view }, - class: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0", data: { "auto-submit-form-target": "auto" } %> <% end %> <%= form.select :period, Period.as_options, { selected: period.key }, - data: { "auto-submit-form-target": "auto" }, - class: "bg-container border border-secondary rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %> + data: { "auto-submit-form-target": "auto" } %>
<% end %>
diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index a6dc4fd0..3780f904 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -20,6 +20,13 @@ <% end %> + <%# Show error warnings %> + <% if account.simple_fin_account_id.present? %> +
+ <%= account.simple_fin_account.simple_fin_item.institution_errors.join(",") %> +
+ <% end %> +
<% if account.plaid_account_id.present? %> <% if Rails.env.development? %> @@ -33,8 +40,17 @@ ) %> <% end %> <% elsif account.simple_fin_account_id.present? %> - <%# SimpleFIN information %> <%= image_tag "simple-fin-logo.svg", class: "h-6 w-auto", title: "Connected via SimpleFIN. This account will auto refresh." %> + <%= render "accounts/account_error", account: account, link_to_path: "https://beta-bridge.simplefin.org/my-account", given_title: "This account has issues in SimpleFIN bridge. Update it and then re-sync.", target: :_blank %> + <%# Re sync button. Will sync all SimpleFIN accounts %> + <%= icon( + "refresh-cw", + as_button: true, + size: "sm", + href: sync_simple_fin_path(account.simple_fin_account.simple_fin_item), + disabled: account.syncing?, + frame: :_top + ) %> <% else %> <%= icon( "refresh-cw", @@ -49,4 +65,4 @@ <%= render "accounts/show/menu", account: account %>
- + \ No newline at end of file diff --git a/app/views/simple_fin_connections/_simple_fin_connection.html.erb b/app/views/simple_fin_connections/_simple_fin_connection.html.erb index 9aa5ee5e..7553498d 100644 --- a/app/views/simple_fin_connections/_simple_fin_connection.html.erb +++ b/app/views/simple_fin_connections/_simple_fin_connection.html.erb @@ -1,29 +1,25 @@ -<%# locals: (simple_fin_connection:) %> +<%# locals: (simple_fin_item:) %>
- <% if simple_fin_connection.logo.attached? %> - <%= image_tag simple_fin_connection.logo, class: "h-8 w-8 rounded-full" %> + <% if simple_fin_item.logo.attached? %> + <%= image_tag simple_fin_item.logo, class: "h-8 w-8 rounded-full" %> <% else %>
- <%= simple_fin_connection.institution_name&.first || "?" %> + <%= simple_fin_item.institution_name&.first || "?" %>
<% end %>

- <%= simple_fin_connection.institution_name || "SimpleFIN Connection" %> + <%= simple_fin_item.institution_name || "SimpleFIN Connection" %>

-
- <%# Add a sync button for the entire connection if needed %> - <%# Example: render ButtonComponent.new(href: sync_simple_fin_connection_path(simple_fin_connection), method: :post, text: "Sync", icon: "refresh-cw", variant: "outline", size: "sm") %> -
diff --git a/config/routes.rb b/config/routes.rb index c1825b41..1bea328c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -202,6 +202,9 @@ Rails.application.routes.draw do # SimpleFIN routes resources :simple_fin, only: %i[create new] do + member do + post :sync + end end diff --git a/db/migrate/20250509134646_simple_fin_integration.rb b/db/migrate/20250509134646_simple_fin_integration.rb index b403dd19..d6545b03 100644 --- a/db/migrate/20250509134646_simple_fin_integration.rb +++ b/db/migrate/20250509134646_simple_fin_integration.rb @@ -1,33 +1,31 @@ class SimpleFinIntegration < ActiveRecord::Migration[7.2] def change - create_table :simple_fin_connections do |t| + create_table :simple_fin_items, id: :uuid do |t| t.references :family, null: false, foreign_key: true, type: :uuid - t.string :name # e.g., "Chase via SimpleFIN" or user-defined t.string :institution_id # From SimpleFIN org.id (e.g., "www.chase.com") t.string :institution_name t.string :institution_url t.string :institution_domain t.string :status, default: "good" # e.g., good, requires_update - t.datetime :last_synced_at + t.string :institution_errors, array: true, default: [] t.boolean :scheduled_for_deletion, default: false - t.string :api_versions_supported, array: true, default: [] + # Columns for rate limiting + t.integer :syncs_today_count, default: 0, null: false + t.datetime :last_sync_count_reset_at t.timestamps end create_table :simple_fin_accounts do |t| - t.references :simple_fin_connection, null: false, foreign_key: true + t.references :simple_fin_item, null: false, foreign_key: true, type: :uuid t.string :external_id, null: false t.decimal :current_balance, precision: 19, scale: 4 t.decimal :available_balance, precision: 19, scale: 4 t.string :currency - t.string :sf_type - t.string :sf_subtype - t.string :simple_fin_errors, array: true, default: [] t.timestamps end - add_index :simple_fin_accounts, [ :simple_fin_connection_id, :external_id ], unique: true, name: 'index_sfa_on_sfc_id_and_external_id' + add_index :simple_fin_accounts, [ :simple_fin_item_id, :external_id ], unique: true, name: 'index_sfa_on_sfc_id_and_external_id' add_reference :accounts, :simple_fin_account, foreign_key: true, null: true, index: true diff --git a/db/schema.rb b/db/schema.rb index b18556e8..15e40eb7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -560,34 +560,31 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_09_134646) do end create_table "simple_fin_accounts", force: :cascade do |t| - t.bigint "simple_fin_connection_id", null: false + t.uuid "simple_fin_item_id", null: false t.string "external_id", null: false t.decimal "current_balance", precision: 19, scale: 4 t.decimal "available_balance", precision: 19, scale: 4 t.string "currency" - t.string "sf_type" - t.string "sf_subtype" - t.string "simple_fin_errors", default: [], array: true t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["simple_fin_connection_id", "external_id"], name: "index_sfa_on_sfc_id_and_external_id", unique: true - t.index ["simple_fin_connection_id"], name: "index_simple_fin_accounts_on_simple_fin_connection_id" + t.index ["simple_fin_item_id", "external_id"], name: "index_sfa_on_sfc_id_and_external_id", unique: true + t.index ["simple_fin_item_id"], name: "index_simple_fin_accounts_on_simple_fin_item_id" end - create_table "simple_fin_connections", force: :cascade do |t| + create_table "simple_fin_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false - t.string "name" t.string "institution_id" t.string "institution_name" t.string "institution_url" t.string "institution_domain" t.string "status", default: "good" - t.datetime "last_synced_at" + t.string "institution_errors", default: [], array: true t.boolean "scheduled_for_deletion", default: false - t.string "api_versions_supported", default: [], array: true + t.integer "syncs_today_count", default: 0, null: false + t.datetime "last_sync_count_reset_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_simple_fin_connections_on_family_id" + t.index ["family_id"], name: "index_simple_fin_items_on_family_id" end create_table "stock_exchanges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -794,8 +791,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_09_134646) do add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" add_foreign_key "sessions", "users" - add_foreign_key "simple_fin_accounts", "simple_fin_connections" - add_foreign_key "simple_fin_connections", "families" + add_foreign_key "simple_fin_accounts", "simple_fin_items" + add_foreign_key "simple_fin_items", "families" add_foreign_key "subscriptions", "families" add_foreign_key "syncs", "syncs", column: "parent_id" add_foreign_key "taggings", "tags"