From 7e05681144cb88f81441f1960fc927861f5286b9 Mon Sep 17 00:00:00 2001 From: Cameron Roudebush Date: Sun, 18 May 2025 09:40:59 -0400 Subject: [PATCH] fixes: Merge request fixes - Changing to the new syncable structure - Fixing missing account error display - Shuffling some code around --- app/models/account.rb | 2 +- app/models/family.rb | 3 +- app/models/family/simple_fin_connectable.rb | 15 ++++ app/models/simple_fin_item.rb | 77 +++++++++---------- app/models/simple_fin_item/syncer.rb | 51 ++++++++++++ app/views/accounts/_account_error.html.erb | 12 +++ .../accounts/_accountable_group.html.erb | 3 + db/schema.rb | 21 ----- 8 files changed, 122 insertions(+), 62 deletions(-) create mode 100644 app/models/family/simple_fin_connectable.rb create mode 100644 app/models/simple_fin_item/syncer.rb diff --git a/app/models/account.rb b/app/models/account.rb index 6e2695de..b3cdb2f0 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -70,7 +70,7 @@ class Account < ApplicationRecord # Since Plaid Items sync as a "group", if the item is syncing, even if the account # sync hasn't yet started (i.e. we're still fetching the Plaid data), show it as syncing in UI. if linked? - plaid_account&.plaid_item&.syncing? || self_syncing + plaid_account&.plaid_item&.syncing? || simple_fin_account&.simple_fin_item&.syncing? || self_syncing else self_syncing end diff --git a/app/models/family.rb b/app/models/family.rb index cd068cae..8f1119d2 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,5 +1,5 @@ class Family < ApplicationRecord - include PlaidConnectable, Syncable, AutoTransferMatchable, Subscribeable + include PlaidConnectable, SimpleFinConnectable, Syncable, AutoTransferMatchable, Subscribeable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], @@ -38,6 +38,7 @@ class Family < ApplicationRecord # If any accounts or plaid items are syncing, the family is also syncing, even if a formal "Family Sync" is not running. def syncing? Sync.joins("LEFT JOIN plaid_items ON plaid_items.id = syncs.syncable_id AND syncs.syncable_type = 'PlaidItem'") + .joins("LEFT JOIN simple_fin_items ON simple_fin_items.id = syncs.syncable_id AND syncs.syncable_type = 'SimpleFinItem'") .joins("LEFT JOIN accounts ON accounts.id = syncs.syncable_id AND syncs.syncable_type = 'Account'") .where("syncs.syncable_id = ? OR accounts.family_id = ? OR plaid_items.family_id = ?", id, id, id) .visible diff --git a/app/models/family/simple_fin_connectable.rb b/app/models/family/simple_fin_connectable.rb new file mode 100644 index 00000000..6474dd2a --- /dev/null +++ b/app/models/family/simple_fin_connectable.rb @@ -0,0 +1,15 @@ +module Family::SimpleFinConnectable + extend ActiveSupport::Concern + + included do + has_many :simple_fin_items, dependent: :destroy + end + + def get_simple_fin_available(accountable_type: nil) + provider = Provider::Registry.get_provider(:simple_fin) + + provider.is_available(id, accountable_type) + end + + private +end diff --git a/app/models/simple_fin_item.rb b/app/models/simple_fin_item.rb index 985f98b0..9adc8cda 100644 --- a/app/models/simple_fin_item.rb +++ b/app/models/simple_fin_item.rb @@ -16,45 +16,6 @@ class SimpleFinItem < ApplicationRecord scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } - ## - # 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) - Rails.logger.info("SimpleFINItem: Starting sync for all SimpleFIN accounts") - - begin - # Fetch all accounts for this specific connection from SimpleFIN. - sf_accounts_data = provider.get_available_accounts(nil) - # Iterate over every account and attempt to apply transactions where possible - sf_accounts_data.each do |sf_account_data| - 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 - begin - # Sync the detailed data for this account - sfa.sync_account_data!(sf_account_data) - rescue StandardError => e - Rails.logger.error("SimpleFINItem: Sync failed for account #{sf_account_data["id"]}: #{e.message}") - sfa.simple_fin_item.update(id: sf_account_data["id"], status: :requires_update) # We had problems so make sure this account knows - end - end - end - - Rails.logger.info("SimpleFINItem: Sync completed for all accounts") - - rescue Provider::SimpleFin::RateLimitExceededError =>e - Rails.logger.error("SimpleFINItem: Sync failed: #{e.message}") - raise StandardError, "SimpleFIN Rate Limit: #{e.message}" # Re-raise as a generic StandardError - rescue StandardError => e - Rails.logger.error("SimpleFINItem: Sync failed: #{e.message}") - raise e # Re-raise so Sync#perform can record the failure. - end - end - def provider @provider ||= Provider::Registry.get_provider(:simple_fin) end @@ -63,4 +24,42 @@ class SimpleFinItem < ApplicationRecord update!(scheduled_for_deletion: true) DestroyJob.perform_later(self) end + + def syncing? + Sync.joins("LEFT JOIN accounts a ON a.id = syncs.syncable_id AND syncs.syncable_type = 'Account'") + .joins("LEFT JOIN simple_fin_accounts sfa ON sfa.id = a.simple_fin_account_id") + .where("syncs.syncable_id = ? OR sfa.simple_fin_item_id = ?", id, id) + .visible + .exists? + end + + def auto_match_categories! + if family.categories.none? + family.categories.bootstrap! + end + + alias_matcher = build_category_alias_matcher(family.categories) + + accounts.each do |account| + matchable_transactions = account.transactions + .where(category_id: nil) + .where.not(simple_fin_category: nil) + .enrichable(:category_id) + + matchable_transactions.each do |transaction| + category = alias_matcher.match(transaction.simple_fin_category) + + if category.present? + SimpleFinItem.transaction do + transaction.log_enrichment!( + attribute_name: "category_id", + attribute_value: category.id, + source: "simple_fin" + ) + transaction.set_category!(category) + end + end + end + end + end end diff --git a/app/models/simple_fin_item/syncer.rb b/app/models/simple_fin_item/syncer.rb new file mode 100644 index 00000000..c38d5c08 --- /dev/null +++ b/app/models/simple_fin_item/syncer.rb @@ -0,0 +1,51 @@ +class SimpleFinItem::Syncer + attr_reader :simple_fin_item + + def initialize(simple_fin_item) + @simple_fin_item = simple_fin_item + @provider ||= Provider::Registry.get_provider(:simple_fin) + end + + def perform_sync(sync) + Rails.logger.info("Starting sync for all SimpleFIN accounts") + + begin + # Fetch all accounts for this specific connection from SimpleFIN. + sf_accounts_data = @provider.get_available_accounts(nil) + # Iterate over every account and attempt to apply transactions where possible + sf_accounts_data.each do |sf_account_data| + 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 + begin + # Sync the detailed data for this account + sfa.sync_account_data!(sf_account_data) + rescue StandardError => e + Rails.logger.error("Sync failed for account #{sf_account_data["id"]}: #{e.message}") + sfa.simple_fin_item.update(id: sf_account_data["id"], status: :requires_update) # We had problems so make sure this account knows + end + end + end + + Rails.logger.info("Sync completed for all accounts") + + rescue Provider::SimpleFin::RateLimitExceededError =>e + Rails.logger.error("Sync failed: #{e.message}") + raise StandardError, "SimpleFIN Rate Limit: #{e.message}" # Re-raise as a generic StandardError + rescue StandardError => e + Rails.logger.error("Sync failed: #{e.message}") + raise e # Re-raise so Sync#perform can record the failure. + end + end + + def perform_post_sync + simple_fin_item.auto_match_categories! + end + + private +end diff --git a/app/views/accounts/_account_error.html.erb b/app/views/accounts/_account_error.html.erb index 03b969bc..9d626502 100644 --- a/app/views/accounts/_account_error.html.erb +++ b/app/views/accounts/_account_error.html.erb @@ -2,6 +2,17 @@ <%# Flag indicators of account issues %> <% if account.simple_fin_account&.simple_fin_item&.status == "requires_update" %> + <% if link_to_path == nil %> + <%= icon( + "alert-triangle", + id: "account-issue-icon", + as_button: true, + size: "sm", + rel: "noopener noreferrer", + disabled: account.syncing?, + title: given_title + ) %> + <% elsif %> <%= link_to link_to_path, target: target do %> <%= icon( "alert-triangle", @@ -13,6 +24,7 @@ title: given_title ) %> <% end %> + <% end %> <% end %> <%# Force some styling that keeps getting overwritten %> diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index 26633918..05169c28 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -37,6 +37,9 @@ <%= 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" %> <% if account.syncing? %>
diff --git a/db/schema.rb b/db/schema.rb index 580cf764..b96772e5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -592,27 +592,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_17_134646) do t.index ["date"], name: "index_sfrl_on_date", unique: true end - create_table "stock_exchanges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "acronym" - t.string "mic", null: false - t.string "country", null: false - t.string "country_code", null: false - t.string "city" - t.string "website" - t.string "timezone_name" - t.string "timezone_abbr" - t.string "timezone_abbr_dst" - t.string "currency_code" - t.string "currency_symbol" - t.string "currency_name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["country"], name: "index_stock_exchanges_on_country" - t.index ["country_code"], name: "index_stock_exchanges_on_country_code" - t.index ["currency_code"], name: "index_stock_exchanges_on_currency_code" - end - create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "status", null: false