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 %>
"> <%= 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 @@