diff --git a/.env.example b/.env.example index 409e23de..20095a17 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,7 @@ 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 f003ab31..65728c64 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -5,6 +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 render layout: "settings" end diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index ec1d5401..687df0cb 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -52,7 +52,7 @@ module AccountableResource end def destroy - if @account.linked? + if !@account.can_delete? redirect_to account_path(@account), alert: "Cannot delete a linked account" else @account.destroy_later diff --git a/app/controllers/simple_fin_controller.rb b/app/controllers/simple_fin_controller.rb index 81fdd076..89a0d701 100644 --- a/app/controllers/simple_fin_controller.rb +++ b/app/controllers/simple_fin_controller.rb @@ -21,18 +21,11 @@ class SimpleFinController < ApplicationController Current.family.accounts.find_by(name: acc["name"]) end - - ## - # Starts a sync across all SimpleFIN accounts - def sync - puts "Should sync" - end - def create selected_ids = params[:selected_account_ids] if selected_ids.blank? Rails.logger.error "No accounts were selected." - redirect_to new_simple_fin_connection_path(accountable_type: @accountable_type) + redirect_to root_path, alert: t(".no_acc_selected") return end @@ -65,24 +58,20 @@ class SimpleFinController < ApplicationController # Create SimpleFinAccount and its associated Account - SimpleFinAccount.find_or_create_from_simple_fin_data!( + simple_fin_account = SimpleFinAccount.find_or_create_from_simple_fin_data!( acc_detail, simple_fin_connection ) - # Optionally, trigger an initial sync for the new account if needed, - # though find_or_create_from_simple_fin_data! already populates it. - # simple_fin_account.sync_account_data!(acc_detail) - - # The Account record is created via accepts_nested_attributes_for in SimpleFinAccount - # or by the logic within find_or_create_from_simple_fin_data! + # Trigger an account sync of our data + simple_fin_account.sync_account_data!(acc_detail) end end redirect_to root_path, notice: t(".accounts_created_success") rescue StandardError => e Rails.logger.error "SimpleFIN: Failed to create accounts - #{e.message}" - redirect_to new_simple_fin_connection_path + redirect_to new_simple_fin_path end private diff --git a/app/jobs/family_reset_job.rb b/app/jobs/family_reset_job.rb index 185df111..0de6f57f 100644 --- a/app/jobs/family_reset_job.rb +++ b/app/jobs/family_reset_job.rb @@ -10,6 +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.imports.destroy_all family.budgets.destroy_all diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb index 12859684..0265d8b2 100644 --- a/app/models/account/linkable.rb +++ b/app/models/account/linkable.rb @@ -6,6 +6,14 @@ module Account::Linkable belongs_to :simple_fin_account, optional: true end + def can_delete? + # SimpleFIN accounts can be removed and re-added + if simple_fin_account_id.present? + return true + end + !linked? + end + # A "linked" account gets transaction and balance data from a third party like Plaid def linked? plaid_account_id.present? || simple_fin_account_id.present? diff --git a/app/models/assistant/function/get_accounts.rb b/app/models/assistant/function/get_accounts.rb index b912d81d..45125115 100644 --- a/app/models/assistant/function/get_accounts.rb +++ b/app/models/assistant/function/get_accounts.rb @@ -22,6 +22,7 @@ class Assistant::Function::GetAccounts < Assistant::Function type: account.accountable_type, start_date: account.start_date, is_plaid_linked: account.plaid_account_id.present?, + is_simple_fin_linked: account.simple_fin_id.present?, is_active: account.is_active, historical_balances: historical_balances(account) } diff --git a/app/models/simple_fin_account.rb b/app/models/simple_fin_account.rb index 4309ee70..24f8ec94 100644 --- a/app/models/simple_fin_account.rb +++ b/app/models/simple_fin_account.rb @@ -15,6 +15,9 @@ class SimpleFinAccount < ApplicationRecord validates :external_id, presence: true, uniqueness: true validates :simple_fin_connection_id, presence: true + after_destroy :cleanup_connection_if_orphaned + + class << self def find_or_create_from_simple_fin_data!(sf_account_data, sfc) sfc.simple_fin_accounts.find_or_create_by!(external_id: sf_account_data["id"]) do |sfa| @@ -35,66 +38,129 @@ class SimpleFinAccount < ApplicationRecord # sfa.sf_subtype = sf_account_data["name"]&.include?("Credit") ? "Credit Card" : accountable_klass.name end end + def family + simple_fin_connection&.family + end end # sf_account_data is a hash from Provider::SimpleFin#get_available_accounts def sync_account_data!(sf_account_data) # Ensure accountable_attributes has the ID for updates - accountable_attributes = { id: account.accountable_id } + # 'account' here refers to self.account (the associated Account instance) + accountable_attributes = { id: self.account.accountable_id } - # Example: Update specific accountable types like PlaidAccount does - # This will depend on the structure of sf_account_data and your Accountable models - # case account.accountable_type - # when "CreditCard" - # accountable_attributes.merge!( - # # minimum_payment: sf_account_data.dig("credit_details", "minimum_payment"), - # # apr: sf_account_data.dig("credit_details", "apr") - # ) - # when "Loan" - # accountable_attributes.merge!( - # # interest_rate: sf_account_data.dig("loan_details", "interest_rate") - # ) - # end - - update!( + self.update!( current_balance: sf_account_data["balance"].to_d, available_balance: sf_account_data["available-balance"]&.to_d, currency: sf_account_data["currency"], - # sf_type: derive_sf_type(sf_account_data), # Potentially update type/subtype - # sf_subtype: derive_sf_subtype(sf_account_data), - simple_fin_errors: sf_account_data["errors"] || [], # Assuming errors might come on account data + # simple_fin_errors: sf_account_data["errors"] || [], account_attributes: { - id: account.id, + id: self.account.id, balance: sf_account_data["balance"].to_d, - # cash_balance: derive_sf_cash_balance(sf_account_data), # If applicable last_synced_at: Time.current, accountable_attributes: accountable_attributes } ) + + # Sync transactions if present in the data + if sf_account_data["transactions"].is_a?(Array) + sync_transactions!(sf_account_data["transactions"]) + end + + # Sync holdings if present in the data and it's an investment account + if self.account&.investment? && sf_account_data["holdings"].is_a?(Array) + sync_holdings!(sf_account_data["holdings"]) + end end - # TODO: Implement if SimpleFIN provides investment transactions/holdings - # def sync_investments!(transactions:, holdings:, securities:) - # # Similar to PlaidInvestmentSync.new(self).sync!(...) - # end + # sf_holdings_data is an array of holding hashes from SimpleFIN for this specific account + def sync_holdings!(sf_holdings_data) + # 'account' here refers to self.account + return unless self.account.present? && self.account.investment? && sf_holdings_data.is_a?(Array) + Rails.logger.info "SimpleFinAccount (#{self.account.id}): Entering sync_holdings! with #{sf_holdings_data.length} items." - # TODO: Implement if SimpleFIN provides transactions - # def sync_transactions!(added:, modified:, removed:) - # # Similar to PlaidAccount's sync_transactions! - # end + # Get existing SimpleFIN holding IDs for this account to detect deletions + existing_provider_holding_ids = self.account.holdings.where.not(simple_fin_holding_id: nil).pluck(:simple_fin_holding_id) + current_provider_holding_ids = sf_holdings_data.map { |h_data| h_data["id"] } - def family - simple_fin_connection&.family + # Delete holdings that are no longer present in SimpleFIN's data + holdings_to_delete_ids = existing_provider_holding_ids - current_provider_holding_ids + Rails.logger.info "SimpleFinAccount (#{self.account.id}): Will delete SF holding IDs: #{holdings_to_delete_ids}" + self.account.holdings.where(simple_fin_holding_id: holdings_to_delete_ids).destroy_all + + sf_holdings_data.each do |holding_data| + # Find or create the Security based on the holding data + security = find_or_create_security_from_holding_data(holding_data) + next unless security # Skip if we can't determine a security + + Rails.logger.info "SimpleFinAccount (#{self.account.id}): Processing SF holding ID #{holding_data['id']}" + existing_holding = self.account.holdings.find_or_initialize_by( + security: security, + date: Date.current, + currency: holding_data["currency"] + ) + + existing_holding.qty = holding_data["shares"]&.to_d + existing_holding.price = holding_data["purchase_price"]&.to_d + existing_holding.amount = holding_data["market_value"]&.to_d + # Cost basis is at holding level, not per share + # existing_holding.cost_basis = holding_data["cost_basis"]&.to_d + existing_holding.save! + end + end + + # sf_transactions_data is an array of transaction hashes from SimpleFIN for this specific account + def sync_transactions!(sf_transactions_data) + # 'account' here refers to self.account + return unless self.account.present? && sf_transactions_data.is_a?(Array) + + sf_transactions_data.each do |transaction_data| + entry = self.account.entries.find_or_initialize_by(simple_fin_transaction_id: transaction_data["id"]) + + entry.assign_attributes( + name: transaction_data["description"], + amount: transaction_data["amount"].to_d, + currency: self.account.currency, + date: Time.at(transaction_data["posted"].to_i).to_date, + source: "simple_fin" + ) + + entry.entryable ||= Transaction.new + unless entry.entryable.is_a?(Transaction) + entry.entryable = Transaction.new + end + + entry.entryable.simple_fin_category = transaction_data.dig("extra", "category") if entry.entryable.respond_to?(:simple_fin_category=) + + if entry.changed? || entry.entryable.changed? # Check if entryable also changed + entry.save! + else + Rails.logger.info "SimpleFinAccount (#{self.account.id}): Entry for SF transaction ID #{transaction_data['id']} not changed, not saving." + end + end + end + + ## + # Helper to find or create a Security record based on SimpleFIN holding data + # SimpleFIN data is less detailed than Plaid securities, often just providing symbol and description. + def find_or_create_security_from_holding_data(holding_data) + symbol = holding_data["symbol"]&.upcase + description = holding_data["description"] + + # We need at least a symbol or description to create/find a security + return nil unless symbol.present? || description.present? + + # Try finding by ticker first, then by name (description) if no ticker + Security.find_or_create_by!(ticker: symbol) do |sec| + sec.name = description if description.present? + end end private - # Example helper, if needed - # def derive_sf_cash_balance(sf_balances) - # if account.investment? - # sf_balances["available-balance"]&.to_d || 0 - # else - # sf_balances["balance"]&.to_d - # end - # end + 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.destroy_later if connection.simple_fin_accounts.empty? + end end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 4893e44c..5a30ea93 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? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simple_fin_connections.empty? %> <%= render "empty" %> <% else %>
No accounts found matching this type from SimpleFIN.
Please ensure your SimpleFIN subscription is active.
- <%= link_to "Try Again", new_simple_fin_connection_path(accountable_type: @accountable_type), class: "mt-4 inline-block text-primary hover:underline" %> + <%= link_to "Try Again", new_simple_fin_path(accountable_type: @accountable_type), class: "mt-4 inline-block text-primary hover:underline" %>